mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-22 08:02:19 +00:00
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:
@@ -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>
|
||||
Reference in New Issue
Block a user