feat: redesign AI chat input as rounded card with send button

- Reshape the input region in AIChatDisplay into a single rounded-2xl card
  with a subtle border and focus shadow, hosting the textarea, mode/model
  selectors, and a circular send/stop button (Codex/Claude-Desktop style).
- Constrain the input card to max-w-xl centered so it stays readable on
  wide panes.
- Expose triggerSend() and hasContent() from AIChatInput so the parent can
  drive the send button and reflect empty-input state.
- Add a /design-sandbox route under (logged) to prototype layout tweaks
  (sidebar + AI chat + flow editor side-by-side with a fake flow and a
  mocked conversation), plus a .claude/launch.json describing the local
  dev servers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-04-29 10:53:00 +02:00
parent 05baa4ab02
commit 0da682bd7c
4 changed files with 361 additions and 115 deletions
+29
View File
@@ -0,0 +1,29 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "frontend",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev", "--", "--port", "3100"],
"port": 3100,
"cwd": "frontend",
"env": {
"REMOTE": "https://app.windmill.dev"
}
},
{
"name": "backend",
"runtimeExecutable": "cargo",
"runtimeArgs": ["run"],
"port": 8000,
"cwd": "backend"
},
{
"name": "multiplayer",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 3002,
"cwd": "multiplayer"
}
]
}
@@ -2,6 +2,7 @@
import AIChatMessage from './AIChatMessage.svelte'
import { type Snippet } from 'svelte'
import {
ArrowUp,
CheckIcon,
Code2,
FileCode,
@@ -122,58 +123,54 @@
<div class="flex flex-row items-center gap-2">
<Popover>
{#snippet trigger()}
<Button
on:click={() => {}}
title="History"
size="md"
btnClasses="!p-1"
startIcon={{ icon: HistoryIcon }}
iconOnly
variant="border"
color="light"
propagateEvent
/>
{/snippet}
<Button
on:click={() => {}}
title="History"
size="md"
btnClasses="!p-1"
startIcon={{ icon: HistoryIcon }}
iconOnly
variant="border"
color="light"
propagateEvent
/>
{/snippet}
{#snippet content({ close })}
<div class="p-1 overflow-y-auto max-h-[300px]">
{#if pastChats.length === 0}
<div class="text-center text-primary text-xs">No history</div>
{:else}
<div class="flex flex-col">
{#each pastChats as chat}
<button
class="text-left flex flex-row items-center gap-2 justify-between hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md p-1"
onclick={() => {
loadPastChat(chat.id)
close()
}}
<div class="p-1 overflow-y-auto max-h-[300px]">
{#if pastChats.length === 0}
<div class="text-center text-primary text-xs">No history</div>
{:else}
<div class="flex flex-col">
{#each pastChats as chat}
<button
class="text-left flex flex-row items-center gap-2 justify-between hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md p-1"
onclick={() => {
loadPastChat(chat.id)
close()
}}
>
<div
class="text-xs font-medium w-48 text-ellipsis overflow-hidden whitespace-nowrap flex-1"
title={chat.title}
>
<div
class="text-xs font-medium w-48 text-ellipsis overflow-hidden whitespace-nowrap flex-1"
title={chat.title}
>
{chat.title}
</div>
<Button
iconOnly
size="xs2"
btnClasses="!p-1"
variant="default"
startIcon={{ icon: X }}
on:click={() => {
deletePastChat(chat.id)
}}
/>
</button>
{/each}
</div>
{/if}
</div>
{/snippet}
{chat.title}
</div>
<Button
iconOnly
size="xs2"
btnClasses="!p-1"
variant="default"
startIcon={{ icon: X }}
on:click={() => {
deletePastChat(chat.id)
}}
/>
</button>
{/each}
</div>
{/if}
</div>
{/snippet}
</Popover>
<Button
title="New chat"
@@ -224,7 +221,7 @@
</div>
{/if}
<div class:border-t={messages.length > 0} class="relative">
<div class="relative px-2 pb-2 pt-1 w-full max-w-xl mx-auto">
{#if aiChatManager.loading}
<div class="absolute -top-10 w-full flex flex-row justify-center">
<Button
@@ -265,28 +262,28 @@
</Button>
</div>
{/if}
<div class="px-2">
<AIChatInput
bind:this={aiChatInput}
bind:selectedContext
{availableContext}
{disabled}
isFirstMessage={messages.length === 0}
/>
<div
class={`flex flex-row ${
aiChatManager.mode === 'script' && hasDiff ? 'justify-between' : 'justify-end'
} items-center`}
>
{#if aiChatManager.mode === 'script' && hasDiff}
<ChatQuickActions {askAi} {diffMode} />
{/if}
{#if disabled}
<div class="text-primary text-xs my-2 px-2">
<Markdown md={disabledMessage} />
</div>
{:else}
<div class="flex flex-row gap-x-1.5 min-w-0 flex-wrap items-center">
<div
class="rounded-2xl border border-light bg-surface shadow-sm transition-all focus-within:shadow-md focus-within:border-gray-300 dark:focus-within:border-gray-600"
>
<div class="px-1 pt-0.5">
<AIChatInput
bind:this={aiChatInput}
bind:selectedContext
{availableContext}
{disabled}
isFirstMessage={messages.length === 0}
/>
</div>
<div class="flex flex-row items-center justify-between gap-2 px-2 pb-1.5 pt-0.5">
<div class="flex flex-row items-center gap-x-1.5 min-w-0 flex-wrap">
{#if aiChatManager.mode === 'script' && hasDiff}
<ChatQuickActions {askAi} {diffMode} />
{/if}
{#if disabled}
<div class="text-primary text-xs px-1">
<Markdown md={disabledMessage} />
</div>
{:else}
<ChatMode />
{#if aiChatManager.mode === AIMode.APP}
<DatatableCreationPolicy />
@@ -367,12 +364,35 @@
</div>
{/if}
{/if}
</div>
{/if}
</div>
{#if !disabled}
<button
type="button"
aria-label={aiChatManager.loading ? 'Stop' : 'Send'}
title={aiChatManager.loading ? 'Stop' : 'Send'}
class="shrink-0 inline-flex items-center justify-center w-7 h-7 rounded-full bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 hover:bg-gray-700 dark:hover:bg-gray-300 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
disabled={!aiChatManager.loading && !aiChatInput?.hasContent()}
onclick={() => {
if (aiChatManager.loading) {
cancel()
} else {
aiChatInput?.triggerSend()
}
}}
>
{#if aiChatManager.loading}
<Square class="w-3 h-3" />
{:else}
<ArrowUp class="w-3.5 h-3.5" strokeWidth={2.5} />
{/if}
</button>
{/if}
</div>
</div>
{#if (aiChatManager.mode === AIMode.NAVIGATOR || aiChatManager.mode === AIMode.ASK) && suggestions.length > 0 && messages.filter((m) => m.role === 'user').length === 0 && !disabled}
<div class="px-2 mt-4">
<div class="mt-4">
<div class="flex flex-col gap-2">
{#each suggestions as suggestion}
<Button
@@ -159,6 +159,17 @@
}
}
export function triggerSend() {
if (disabled) {
return
}
onSendRequest ? onSendRequest(instructions) : sendRequest()
}
export function hasContent(): boolean {
return instructions.trim().length > 0
}
$effect(() => {
if (editingMessageIndex !== null) {
focusInput()
@@ -364,29 +375,25 @@
<div class="flex flex-row gap-1 mb-1 overflow-scroll pt-2 no-scrollbar">
<Popover>
{#snippet trigger()}
<div
class="border rounded-md px-1 py-0.5 font-normal text-primary text-xs hover:bg-surface-hover bg-surface"
>@</div
>
{/snippet}
<div
class="border rounded-md px-1 py-0.5 font-normal text-primary text-xs hover:bg-surface-hover bg-surface"
>@</div
>
{/snippet}
{#snippet content({ close })}
<AvailableContextList
{availableContext}
{selectedContext}
onSelect={(element) => {
addContextToSelection(element)
close()
}}
onSelectWorkspaceItem={(element) => {
addContextToSelection(element)
close()
}}
/>
{/snippet}
<AvailableContextList
{availableContext}
{selectedContext}
onSelect={(element) => {
addContextToSelection(element)
close()
}}
onSelectWorkspaceItem={(element) => {
addContextToSelection(element)
close()
}}
/>
{/snippet}
</Popover>
{#each selectedContext as element}
<ContextElementBadge
@@ -423,25 +430,21 @@
<div class="flex flex-row gap-1 mb-1 overflow-scroll pt-2 no-scrollbar">
<Popover>
{#snippet trigger()}
<div
class="border rounded-md px-1 py-0.5 font-normal text-primary text-xs hover:bg-surface-hover bg-surface"
>@</div
>
{/snippet}
<div
class="border rounded-md px-1 py-0.5 font-normal text-primary text-xs hover:bg-surface-hover bg-surface"
>@</div
>
{/snippet}
{#snippet content({ close })}
<AppAvailableContextList
{availableContext}
{selectedContext}
onSelect={(element) => {
addContextToSelection(element)
close()
}}
/>
{/snippet}
<AppAvailableContextList
{availableContext}
{selectedContext}
onSelect={(element) => {
addContextToSelection(element)
close()
}}
/>
{/snippet}
</Popover>
{#each selectedContext as element (element.type + '-' + element.title)}
<ContextElementBadge
@@ -0,0 +1,194 @@
<script lang="ts">
import { onMount } from 'svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import AIChat from '$lib/components/copilot/chat/AIChat.svelte'
import FlowWrapper from '$lib/components/FlowWrapper.svelte'
import { chatState } from '$lib/components/copilot/chat/sharedChatState.svelte'
import { aiChatManager, AIMode } from '$lib/components/copilot/chat/AIChatManager.svelte'
import { copilotInfo } from '$lib/aiStore'
import type { DisplayMessage } from '$lib/components/copilot/chat/shared'
import type { FlowBuilderWhitelabelCustomUi } from '$lib/components/custom_ui'
// Fake conversation: user asks the assistant to build a 3-step user-engagement flow.
const fakeMessages: DisplayMessage[] = [
{
role: 'user',
index: 0,
content:
'I need a flow that pulls users from our API every morning, filters out the inactive ones, and sends a re-engagement email to the rest.'
},
{
role: 'assistant',
content:
"Sounds good — I'll wire that up as three steps: a fetch from your `/users` endpoint, a filter on `last_active_at`, and a notify step that sends the email. Let me add them now."
},
{
role: 'tool',
tool_call_id: 'tool_step_a',
toolName: 'create_step',
content: 'Created step `a` — Fetch users',
parameters: { id: 'a', summary: 'Fetch users', language: 'bun' },
result: { ok: true, id: 'a' },
showDetails: false
},
{
role: 'tool',
tool_call_id: 'tool_step_b',
toolName: 'create_step',
content: 'Created step `b` — Filter active',
parameters: { id: 'b', summary: 'Filter active', language: 'python3' },
result: { ok: true, id: 'b' },
showDetails: false
},
{
role: 'tool',
tool_call_id: 'tool_step_c',
toolName: 'create_step',
content: 'Created step `c` — Notify',
parameters: { id: 'c', summary: 'Notify', language: 'bun' },
result: { ok: true, id: 'c' },
showDetails: false
},
{
role: 'assistant',
content:
'All three steps are in place:\n\n- **Fetch users** calls `GET /users` and returns the list.\n- **Filter active** keeps users whose `last_active_at` is within the last 30 days.\n- **Notify** sends the `re_engagement_v2` email template to each one.\n\nYou can hit **Test flow** in the top-right to dry-run it. Want me to add a daily schedule trigger as well?'
},
{
role: 'user',
index: 1,
content:
'Yes please, every weekday at 9am Europe/Paris. Also can the email use `name` instead of `first_name`?'
},
{
role: 'assistant',
content: "On it — adding the schedule and updating the Notify step's template variable."
},
{
role: 'tool',
tool_call_id: 'tool_schedule',
toolName: 'add_schedule',
content: 'Added weekday schedule at 09:00 Europe/Paris',
parameters: { cron: '0 9 * * 1-5', timezone: 'Europe/Paris' },
result: { ok: true, schedule_id: 'sched_001' },
showDetails: false
},
{
role: 'tool',
tool_call_id: 'tool_edit_c',
toolName: 'edit_step',
content: 'Updated `Notify` to reference `user.name`',
parameters: { id: 'c', diff: '- {{ user.first_name }}\n+ {{ user.name }}' },
result: { ok: true },
showDetails: false,
isLoading: true
}
]
function applyMocks() {
// Pretend Windmill AI is enabled with an Anthropic model so the chat input is active.
copilotInfo.set({
enabled: true,
defaultModel: { model: 'claude-sonnet-4-5', provider: 'anthropic' },
aiModels: [{ model: 'claude-sonnet-4-5', provider: 'anthropic' }],
customPrompts: {},
maxTokensPerModel: {}
})
aiChatManager.mode = AIMode.FLOW
aiChatManager.displayMessages = fakeMessages
}
// The parent (logged) layout always renders its AiChatLayout (right-side chat).
// We hide it here and render our own AIChat on the left of the page.
onMount(() => {
chatState.size = 0
applyMocks()
// loadCopilot() in the parent layout is async and may overwrite our mock once
// it resolves — re-apply after a beat to be safe for design preview.
setTimeout(applyMocks, 1500)
})
// Minimal fake flow with a few modules so the editor has something to show.
let flowStore = $state({
val: {
summary: 'Sandbox flow',
description: 'A fake flow used to prototype design changes.',
value: {
modules: [
{
id: 'a',
summary: 'Fetch users',
value: {
type: 'rawscript' as const,
language: 'bun' as const,
content:
"export async function main() {\n return [{ id: 1, name: 'Ada' }, { id: 2, name: 'Linus' }]\n}\n",
input_transforms: {}
}
},
{
id: 'b',
summary: 'Filter active',
value: {
type: 'rawscript' as const,
language: 'python3' as const,
content: 'def main(users: list):\n return [u for u in users if u]\n',
input_transforms: {
users: { type: 'javascript' as const, expr: 'results.a' }
}
}
},
{
id: 'c',
summary: 'Notify',
value: {
type: 'rawscript' as const,
language: 'bun' as const,
content:
"export async function main(users: any[]) {\n console.log('notifying', users.length)\n}\n",
input_transforms: {
users: { type: 'javascript' as const, expr: 'results.b' }
}
}
}
]
},
path: 'u/admin/sandbox_flow',
edited_at: '',
edited_by: '',
archived: false,
extra_perms: {},
schema: {
$schema: 'https://json-schema.org/draft/2020-12/schema',
properties: {},
required: [],
type: 'object'
}
}
})
let flowStateStore = $state({ val: {} })
let customUi: FlowBuilderWhitelabelCustomUi = {
aiAgent: true
}
</script>
<div class="h-full w-full flex flex-col min-h-0">
<Splitpanes horizontal={false} class="flex-1 min-h-0">
<Pane size={50} minSize={20} class="flex flex-col min-h-0 border-r border-light">
<AIChat />
</Pane>
<Pane size={50} minSize={30} class="flex flex-col min-h-0">
<FlowWrapper
disableAi
pathStoreInit="u/admin/sandbox_flow"
{customUi}
selectedId={undefined}
newFlow
{flowStore}
{flowStateStore}
/>
</Pane>
</Splitpanes>
</div>