Compare commits

...
Author SHA1 Message Date
Guilhem LemouelandClaude Opus 4.7 b0e89472fe feat: tighten chat box (bg-surface-input, single-line input, padding)
- Switch the chat input card background to bg-surface-input so the input
  area visually separates from surrounding surfaces.
- Convert the @ context-picker trigger to <Button variant="subtle">
  with btnClasses="!px-1.5 !py-0.5 font-mono" so it matches the send
  button's design-system styling.
- Remove the textarea's border and focus outline (border / outline /
  box-shadow forced to none with !important across the regular and
  focus states) so the textarea sits flush within the card.
- Shrink the resting textarea height: rows={1} on all three textareas
  (ContextTextarea, APP-mode, default-mode); autosize still grows it as
  the user types.
- Bump chat box internal padding by one step for breathing room:
  px-1 pt-1 -> px-2 pt-2 above the textarea, and px-2 pb-1.5 pt-0.5 ->
  px-3 pb-2 pt-1 on the controls row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 13:27:45 +02:00
Guilhem LemouelandClaude Opus 4.7 05f3e2845e feat: relocate context picker, refine chat input + flow card radii
- Move the @ context-picker Popover from above the textarea (in
  AIChatInput) to the bottom-left of the chat input controls row in
  AIChatDisplay, immediately to the left of the Flow mode selector.
  AIChatInput now exports addContextToSelection so the parent can drive
  the same validated add path. The badges-only row above the textarea
  only renders when there is at least one selected context (no empty
  bar). AIChatInput drops its now-unused Popover and AvailableContextList
  imports; AIChatDisplay picks them up plus AppAvailableContextList for
  the APP-mode list.
- Replace the raw send/stop button with a Button variant="subtle"
  iconOnly, keeping the rounded-full 28x28 hit area via btnClasses, the
  ArrowUp/Square icon swap, and the disabled-when-empty / cancel-when-
  loading behavior.
- Shrink the textarea: rows={3} -> rows={2} on all three textareas
  (ContextTextarea, the APP-mode textarea, the default-mode textarea)
  and bump min-height down from 3rem to 1.75rem so the textarea starts
  as a single visible line and grows with content via autosize.
- Refine chat input box radius to rounded-xl, match the textarea via
  border-radius: 0.75rem, and add pt-1 above the textarea for a touch
  of breathing room from the card's top edge.
- /design-sandbox: switch the right pane's flow editor card from
  rounded-xl to rounded-md so the flow card sits tighter while the chat
  input keeps the softer rounded-xl.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 13:09:12 +02:00
Guilhem LemouelandClaude Opus 4.7 c750bdebe9 feat: extract ResourceBreadcrumb with cascading dropdowns + recursive submenus
- Add ResourceBreadcrumb.svelte: a clickable breadcrumb with three
  segments (kind / folder / name), each opening a DropdownV2 menu. The
  depth depends on which segment is clicked: kind cascades 3 levels deep
  (kind > folder > item), folder cascades 2 levels (folder > item), name
  shows just items. Items, folders and kinds have icons; folders use
  font-mono; '/' as separator; placement="bottom-start" so menus open
  flush below the segment's left edge. Includes inline mock data so the
  dropdowns are useful in the design preview without backend wiring.
  Flow icon uses lucide AlignLeft (the bars-staggered look) instead of
  Workflow.
- DropdownSubmenuItem.svelte: render itself recursively when a sub-item
  has its own submenuItems, replacing the previous flat MenuItem-only
  rendering. Switches to a self-import (Svelte 5 deprecates
  <svelte:self>) and adds a key to the each block. Existing 2-level
  consumers (e.g. the FlowBuilder Tutorials menu) keep working — the
  recursion only activates when submenuItems.length > 0.
- FlowBuilder.svelte: replace the inline breadcrumb markup and its
  $derived helpers with <ResourceBreadcrumb kind="flow" path={$pathStore}
  summary={flowStore.val.summary} />. Drops the now-unused Workflow /
  ChevronRight lucide imports.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 12:57:27 +02:00
Guilhem LemouelandClaude Opus 4.7 b28b814e95 feat: replace flow summary card with breadcrumb + frame the flow as a chat-owned card
- FlowBuilder: replace the SummaryPathDisplay box (path / summary stack)
  with an inline breadcrumb (Workflow icon + "Flows" / folder /
  summary-or-leaf, separated by ChevronRight). The breadcrumb derives
  $pathStore segments via $derived (breadcrumbParts/Folder/Leaf) so it
  can be referenced from the template without {@const}, which is not
  allowed as a direct child of <div>. Drops the now-unused
  SummaryPathDisplay import.
- design-sandbox: position the right pane as a contained "chat-owned"
  workspace — p-2 pl-0 padding plus an inner rounded-xl border / shadow-sm
  card wrapping FlowWrapper. Drop the left pane's right border so the
  chat reads as full-bleed primary content. Add a chat-side header
  (h-12 to match FlowBuilder's max-h-12 row) with a MessageSquare icon
  and a conversationTitle derived from the first user message in the
  current chat (truncated to 60 chars). Hide the splitpanes divider
  visually via a scoped :global(.design-sandbox-splitpanes ...) rule
  while keeping the hit area so the split is still resizable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 12:27:38 +02:00
Guilhem LemouelandClaude Opus 4.7 63197f682d feat: collapsible flow editor side panel, collapsed by default
- FlowEditor: add a panelCollapsed $state (default true) and a top-right
  toggle button that swaps between PanelRightOpen / PanelRightClose
  lucide icons. The right Pane (FlowEditorPanel) is wrapped in
  {#if !panelCollapsed} so it fully unmounts when collapsed and the flow
  graph fills the full width.
- FlowGraphV2: move the top-right Controls block to position
  bottom-right so it no longer overlaps the new collapse toggle in the
  graph's top-right corner.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 11:56:28 +02:00
Guilhem LemouelandClaude Opus 4.7 9afc108c75 feat: add resource preview cards and schedule modal under tool calls
Adds a Codex-style preview card under tool calls that create or modify a
flow step, schedule, script, or app, plus a clickable flow into a centered
modal hosting ScheduleEditorInner for the schedule case.

- ToolExecutionDisplay infers a PreviewKind + title/subtitle/icon from the
  tool name and parameters. Flow steps render the LanguageIcon for the
  step's language; schedule cards use a blue Calendar; script/app/flow
  fall back to lucide brand icons. The card itself is a button (only
  enabled for schedule for now); the inner Open span stops propagation so
  card-click and Open-click stay independent.
- New previewModalState.svelte.ts holds shared $state for the modal so
  ToolExecutionDisplay (deep in the message tree) can drive a modal
  mounted at the page level.
- design-sandbox/+page.svelte mounts a Modal2 (with target="body" so the
  Portal lands on <body> instead of collapsing inside the splitpanes
  flex tree) containing ScheduleEditorInner with useDrawer={false}. An
  $effect calls openEdit(SANDBOX_SCHEDULE_PATH) on the real workspace
  schedule when the modal opens, which avoids the undefined-field crashes
  that synthesizing a defaultCfg produced.
- Chat text bumped to text-xs across user bubbles, assistant markdown
  ([&_*]:!text-xs), and tool-call labels for visual consistency.
- Removed the trailing in-flight 'Updated Notify…' tool call from the
  fake conversation so the demo ends on the schedule preview card.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 11:49:48 +02:00
Guilhem LemouelandClaude Opus 4.7 2ab9d35c85 feat: move AI chat history to sidebar and drop in-panel header
- Add an "AI chat" section to the sidebar (mobile drawer, desktop, and
  operator views) showing past chats from HistoryManager with a + button
  in the header that creates a new chat and routes to /design-sandbox.
  Section title fades out when the sidebar is collapsed.
- Remove the in-panel chat header from AIChatDisplay (Chat title,
  HideButton slot, History popover, + button, headerRight slot) along
  with the now-unused props (pastChats, loadPastChat, deletePastChat,
  saveAndClear, headerLeft, headerRight). The chat panel now starts
  directly with the messages, and history/new-chat live in the sidebar.
- AIChat.svelte updated to drop the headerLeft snippet, the HideButton
  import, and the now-unused props on AIChatDisplay.
- design-sandbox page mocks aiChatManager.historyManager.getPastChats()
  with realistic Windmill-style chat titles so the sidebar history is
  populated for the design preview without writing to IndexedDB.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 11:08:23 +02:00
Guilhem LemouelandClaude Opus 4.7 0da682bd7c 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>
2026-04-29 10:53:00 +02:00
16 changed files with 1055 additions and 366 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"
}
]
}
@@ -7,6 +7,7 @@
import type { Item } from '$lib/utils'
import type { MenubarMenuElements, createDropdownMenu } from '@melt-ui/svelte'
import { Tooltip } from './meltComponents'
import DropdownSubmenuItem from './DropdownSubmenuItem.svelte'
interface Props {
item: Item
@@ -47,38 +48,44 @@
use:melt={$subMenu}
class="z-[6000] bg-surface-tertiary dark:border w-48 origin-top-right rounded-lg shadow-lg focus:outline-none overflow-y-auto py-1"
>
{#each subItems as subItem}
{#each subItems as subItem (subItem.id ?? subItem.displayName)}
{#if subItem.separatorTop}
<div class="my-1 border-t border-border-light"></div>
{/if}
<MenuItem
onClick={(e) => subItem?.action?.(e)}
href={subItem?.href}
target={subItem?.hrefTarget}
disabled={subItem?.disabled}
class={twMerge(
'px-4 py-2 text-primary font-normal hover:bg-surface-hover cursor-pointer text-xs transition-colors w-full',
'data-[highlighted]:bg-surface-hover',
'flex flex-row gap-2 items-center rounded-sm',
subItem?.disabled && 'text-disabled cursor-not-allowed'
)}
item={meltItem}
>
{#if subItem.icon}
<subItem.icon size={14} color={subItem.iconColor} class="shrink-0" />
{/if}
<p title={subItem.displayName} class="truncate grow min-w-0 whitespace-nowrap text-left">
{subItem.displayName}
</p>
{@render subItem.extra?.()}
{#if subItem.tooltip}
<Tooltip>
{#snippet text()}
{subItem.tooltip}
{/snippet}
</Tooltip>
{/if}
</MenuItem>
{#if subItem.submenuItems && subItem.submenuItems.length > 0}
<!-- Recursive: render another DropdownSubmenuItem so the cascade
can go arbitrarily deep (e.g. kind > folder > item). -->
<DropdownSubmenuItem item={subItem} {builders} {meltItem} />
{:else}
<MenuItem
onClick={(e) => subItem?.action?.(e)}
href={subItem?.href}
target={subItem?.hrefTarget}
disabled={subItem?.disabled}
class={twMerge(
'px-4 py-2 text-primary font-normal hover:bg-surface-hover cursor-pointer text-xs transition-colors w-full',
'data-[highlighted]:bg-surface-hover',
'flex flex-row gap-2 items-center rounded-sm',
subItem?.disabled && 'text-disabled cursor-not-allowed'
)}
item={meltItem}
>
{#if subItem.icon}
<subItem.icon size={14} color={subItem.iconColor} class="shrink-0" />
{/if}
<p title={subItem.displayName} class="truncate grow min-w-0 whitespace-nowrap text-left">
{subItem.displayName}
</p>
{@render subItem.extra?.()}
{#if subItem.tooltip}
<Tooltip>
{#snippet text()}
{subItem.tooltip}
{/snippet}
</Tooltip>
{/if}
</MenuItem>
{/if}
{/each}
</div>
{/if}
@@ -61,6 +61,7 @@
CheckCheck,
Disc
} from 'lucide-svelte'
import ResourceBreadcrumb from './ResourceBreadcrumb.svelte'
import Awareness from './Awareness.svelte'
import { getAllModules } from './flows/flowExplorer'
import { type FlowCopilotContext } from './copilot/flow'
@@ -71,7 +72,6 @@
import { resetAllTodos, skipAllTodos } from '$lib/tutorialUtils'
import { tutorialsToDo } from '$lib/stores'
import { getTutorialIndex } from '$lib/tutorials/config'
import SummaryPathDisplay from './SummaryPathDisplay.svelte'
import type { FlowBuilderWhitelabelCustomUi } from './custom_ui'
import FlowYamlEditor from './flows/header/FlowYamlEditor.svelte'
import { type TriggerContext, type ScheduleTrigger } from './triggers'
@@ -1150,13 +1150,8 @@
<div
class="justify-between flex flex-row items-center pl-2 pr-4 space-x-4 scrollbar-hidden overflow-x-auto max-h-12 h-full relative"
>
<div class="flex w-full gap-8 items-center min-w-0">
<SummaryPathDisplay
bind:summary={flowStore.val.summary}
bind:path={$pathStore}
kind="flow"
editable
/>
<div class="flex w-full items-center min-w-0">
<ResourceBreadcrumb kind="flow" path={$pathStore} summary={flowStore.val.summary} />
</div>
<div class="gap-4 flex-row hidden md:flex whitespace-nowrap">
@@ -0,0 +1,211 @@
<script lang="ts" module>
import {
AlignLeft,
Calendar,
Database,
FileCode,
LayoutDashboard,
Variable,
Workflow
} from 'lucide-svelte'
export type ResourceKind = 'flow' | 'script' | 'app' | 'schedule' | 'resource' | 'variable'
export type KindMeta = {
id: ResourceKind
label: string
labelPlural: string
icon: typeof Workflow
}
export const RESOURCE_KINDS: KindMeta[] = [
{ id: 'flow', label: 'Flow', labelPlural: 'Flows', icon: AlignLeft },
{ id: 'script', label: 'Script', labelPlural: 'Scripts', icon: FileCode },
{ id: 'app', label: 'App', labelPlural: 'Apps', icon: LayoutDashboard },
{ id: 'schedule', label: 'Schedule', labelPlural: 'Schedules', icon: Calendar },
{ id: 'resource', label: 'Resource', labelPlural: 'Resources', icon: Database },
{ id: 'variable', label: 'Variable', labelPlural: 'Variables', icon: Variable }
]
</script>
<script lang="ts">
import { Folder } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import type { Item } from '$lib/utils'
import Dropdown from '$lib/components/DropdownV2.svelte'
type FolderEntry = { path: string; label?: string }
type ItemEntry = { path: string; summary?: string }
interface Props {
kind: ResourceKind
path: string
summary?: string
// Optional providers — design preview uses inline mocks if not given.
folders?: FolderEntry[]
// Items visible across all folders for the current kind. The component
// filters them per-folder when building submenus.
items?: ItemEntry[]
onSelectKind?: (kind: ResourceKind) => void
onSelectFolder?: (folderPath: string, kind: ResourceKind) => void
onSelectItem?: (item: ItemEntry, kind: ResourceKind) => void
}
let { kind, path, summary, folders, items, onSelectKind, onSelectFolder, onSelectItem }: Props =
$props()
const breadcrumbParts = $derived((path ?? '').split('/').filter(Boolean))
const folderPath = $derived(
breadcrumbParts.length > 1 ? breadcrumbParts.slice(0, -1).join('/') : ''
)
const leaf = $derived(
breadcrumbParts.length > 0 ? breadcrumbParts[breadcrumbParts.length - 1] : 'untitled'
)
const currentKind = $derived(RESOURCE_KINDS.find((k) => k.id === kind) ?? RESOURCE_KINDS[0])
// --- design-preview fallbacks ---------------------------------------------
const fallbackFolders: FolderEntry[] = [
{ path: 'u/admin', label: 'admin' },
{ path: 'u/guilhempw', label: 'guilhempw' },
{ path: 'f/team', label: 'team' },
{ path: 'f/data', label: 'data' },
{ path: 'f/integrations', label: 'integrations' }
]
const fallbackItemsByKind: Record<ResourceKind, ItemEntry[]> = {
flow: [
{ path: 'u/admin/sandbox_flow', summary: 'Sandbox flow' },
{ path: 'u/admin/payment_processor', summary: 'Payment processor' },
{ path: 'u/guilhempw/dashboard_data', summary: 'Dashboard data sync' },
{ path: 'f/team/onboarding', summary: 'Team onboarding' }
],
script: [
{ path: 'u/admin/fetch_users', summary: 'Fetch users' },
{ path: 'u/admin/notify_email', summary: 'Notify by email' },
{ path: 'f/data/csv_import', summary: 'CSV import' }
],
app: [
{ path: 'u/admin/admin_dashboard', summary: 'Admin dashboard' },
{ path: 'f/team/team_directory', summary: 'Team directory' }
],
schedule: [
{ path: 'u/guilhempw/fake_flow_schedule', summary: 'Fake flow schedule' },
{ path: 'u/admin/daily_report', summary: 'Daily report' }
],
resource: [
{ path: 'u/admin/postgres_main', summary: 'Postgres main' },
{ path: 'u/admin/s3_assets', summary: 'S3 assets' }
],
variable: [
{ path: 'u/admin/api_key', summary: 'API key' },
{ path: 'u/admin/sentry_dsn', summary: 'Sentry DSN' }
]
}
const effectiveFolders = $derived(folders ?? fallbackFolders)
function itemsForKind(k: ResourceKind): ItemEntry[] {
return items ?? fallbackItemsByKind[k] ?? []
}
function itemsInFolder(k: ResourceKind, folder: string): ItemEntry[] {
return itemsForKind(k).filter((it) => {
const parts = it.path.split('/').filter(Boolean)
const f = parts.length > 1 ? parts.slice(0, -1).join('/') : ''
return f === folder
})
}
// --------------------------------------------------------------------------
// Item-tree builders. Each level's segment opens a dropdown rooted at that
// level — clicking a kind cascades through (kind > folder > item), clicking
// a folder cascades through (folder > item), clicking the leaf jumps
// straight to the item list.
function buildItemEntries(k: ResourceKind, folder: string): Item[] {
const list = itemsInFolder(k, folder)
if (list.length === 0) {
return [{ displayName: 'No items in this folder', disabled: true }]
}
return list.map(
(it): Item => ({
displayName: it.summary || it.path.split('/').pop() || it.path,
action: () => onSelectItem?.(it, k)
})
)
}
function buildFolderEntries(k: ResourceKind): Item[] {
return effectiveFolders.map(
(f): Item => ({
displayName: f.label || f.path,
icon: Folder,
submenuItems: buildItemEntries(k, f.path)
})
)
}
const kindLevelItems = $derived(
RESOURCE_KINDS.map(
(k): Item => ({
displayName: k.labelPlural,
icon: k.icon,
submenuItems: buildFolderEntries(k.id),
action: () => onSelectKind?.(k.id)
})
)
)
const folderLevelItems = $derived(buildFolderEntries(kind))
const leafLevelItems = $derived(buildItemEntries(kind, folderPath))
// --------------------------------------------------------------------------
const segmentBtn =
'flex flex-row items-center gap-1.5 px-1.5 py-0.5 rounded-md hover:bg-surface-hover transition-colors text-primary text-xs min-w-0'
</script>
<div class="flex flex-row items-center gap-0.5 text-xs min-w-0">
<!-- Level 0: kind. Cascades 3 levels deep (kind > folder > item). -->
<Dropdown
items={kindLevelItems}
placement="bottom-start"
fixedHeight={false}
class="!justify-start"
>
{#snippet buttonReplacement()}
{@const CurrentKindIcon = currentKind.icon}
<span class={segmentBtn} title="Browse {currentKind.labelPlural}">
<CurrentKindIcon class="w-3.5 h-3.5 text-secondary shrink-0" />
<span class="text-secondary">{currentKind.labelPlural}</span>
</span>
{/snippet}
</Dropdown>
{#if folderPath}
<span class="text-secondary opacity-60 shrink-0 select-none">/</span>
<!-- Level 1: folder. Cascades 2 levels deep (folder > item). -->
<Dropdown
items={folderLevelItems}
placement="bottom-start"
fixedHeight={false}
class="!justify-start"
>
{#snippet buttonReplacement()}
<span class={twMerge(segmentBtn)} title="Browse folder {folderPath}">
<span class="text-secondary truncate font-mono">{folderPath}</span>
</span>
{/snippet}
</Dropdown>
{/if}
<span class="text-secondary opacity-60 shrink-0 select-none">/</span>
<!-- Level 2: name / summary. Just the item list, no submenus. -->
<Dropdown
items={leafLevelItems}
placement="bottom-start"
fixedHeight={false}
class="!justify-start"
>
{#snippet buttonReplacement()}
<span class={twMerge(segmentBtn, 'font-medium text-primary')} title={summary || leaf}>
<span class="truncate">{summary || leaf}</span>
</span>
{/snippet}
</Dropdown>
</div>
@@ -5,7 +5,6 @@
import { dbSchemas, userStore, workspaceStore } from '$lib/stores'
import { aiChatManager, AIMode } from './AIChatManager.svelte'
import { base } from '$lib/base'
import HideButton from '$lib/components/apps/editor/settingsPanel/HideButton.svelte'
import { SUPPORTED_CHAT_SCRIPT_LANGUAGES } from './script/core'
import { copilotInfo, copilotSessionModel } from '$lib/aiStore'
@@ -53,8 +52,6 @@
aiChatManager.sendRequest(options)
}
const historyManager = aiChatManager.historyManager
let aiChatDisplay: AIChatDisplay | undefined = $state(undefined)
$effect(() => {
@@ -80,20 +77,8 @@
}}
/>
{#snippet headerLeft()}
<HideButton
hidden={false}
direction="right"
panelName="AI"
shortcut="L"
size="md"
on:click={() => aiChatManager.toggleOpen()}
/>
{/snippet}
<AIChatDisplay
bind:this={aiChatDisplay}
pastChats={historyManager.getPastChats()}
bind:selectedContext={
() => aiChatManager.contextManager.getSelectedContext(),
(sc) => aiChatManager.contextManager.setSelectedContext(sc)
@@ -113,16 +98,8 @@
}
]
: aiChatManager.displayMessages}
saveAndClear={aiChatManager.saveAndClear}
deletePastChat={(id) => {
historyManager.deletePastChat(id)
}}
loadPastChat={(id) => {
aiChatManager.loadPastChat(id)
}}
cancel={aiChatManager.cancel}
askAi={aiChatManager.askAi}
{headerLeft}
hasDiff={aiChatManager.scriptEditorOptions &&
!!aiChatManager.scriptEditorOptions.lastDeployedCode &&
aiChatManager.scriptEditorOptions.lastDeployedCode !== aiChatManager.scriptEditorOptions.code}
@@ -1,27 +1,27 @@
<script lang="ts">
import AIChatMessage from './AIChatMessage.svelte'
import { type Snippet } from 'svelte'
import {
ArrowUp,
CheckIcon,
Code2,
FileCode,
HistoryIcon,
Loader2,
MousePointer2,
Plus,
Square,
TextSelect,
X,
XIcon
} from 'lucide-svelte'
import Button from '$lib/components/common/button/Button.svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import { type DisplayMessage } from './shared'
import type { ContextElement } from './context'
import ChatQuickActions from './ChatQuickActions.svelte'
import ProviderModelSelector from './ProviderModelSelector.svelte'
import ChatMode from './ChatMode.svelte'
import DatatableCreationPolicy from './DatatableCreationPolicy.svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import AvailableContextList from './AvailableContextList.svelte'
import AppAvailableContextList from './AppAvailableContextList.svelte'
import Markdown from 'svelte-exmarkdown'
import { aiChatManager, AIMode } from './AIChatManager.svelte'
import AIChatInput from './AIChatInput.svelte'
@@ -30,35 +30,23 @@
let {
messages,
pastChats,
hasDiff,
diffMode = false, // todo: remove default
selectedContext = $bindable([]), // todo: remove default
availableContext = [], // todo: remove default
loadPastChat,
deletePastChat,
saveAndClear,
cancel,
askAi = () => {}, // todo: remove default,
headerLeft,
headerRight,
disabled = false,
disabledMessage = '',
suggestions = []
}: {
messages: DisplayMessage[]
pastChats: { id: string; title: string }[]
hasDiff?: boolean
diffMode: boolean
selectedContext: ContextElement[]
availableContext: ContextElement[]
loadPastChat: (id: string) => void
deletePastChat: (id: string) => void
saveAndClear: () => void
cancel: () => void
askAi?: (instructions: string, options?: { withCode?: boolean; withDiff?: boolean }) => void
headerLeft?: Snippet
headerRight?: Snippet
disabled?: boolean
disabledMessage?: string
suggestions?: string[]
@@ -112,84 +100,6 @@
</script>
<div class="flex flex-col h-full">
<div
class="flex flex-row items-center justify-between gap-2 p-2 border-b border-gray-200 dark:border-gray-600"
>
<div class="flex flex-row items-center gap-2">
{@render headerLeft?.()}
<p class="text-sm font-semibold">Chat</p>
</div>
<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}
{#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="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}
</Popover>
<Button
title="New chat"
on:click={() => {
saveAndClear()
}}
size="md"
btnClasses="!p-1"
startIcon={{ icon: Plus }}
iconOnly
variant="border"
color="light"
/>
{@render headerRight?.()}
</div>
</div>
{#if messages.length === 0}
<span class="text-2xs text-gray-500 dark:text-gray-400 text-center px-2 my-2"
>You can use {getModifierKey()}L to open or close this chat, and {getModifierKey()}K in the
@@ -205,7 +115,7 @@
aiChatManager.disableAutomaticScroll()
}}
>
<div class="flex flex-col" bind:clientHeight={height}>
<div class="flex flex-col w-full max-w-xl mx-auto px-2" bind:clientHeight={height}>
{#each messages as message, messageIndex}
<AIChatMessage
{message}
@@ -224,7 +134,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 +175,68 @@
</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-xl border border-light bg-surface-input shadow-sm transition-all focus-within:shadow-md focus-within:border-gray-300 dark:focus-within:border-gray-600"
>
<div class="px-2 pt-2">
<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-3 pb-2 pt-1">
<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}
{#if aiChatManager.mode === AIMode.SCRIPT || aiChatManager.mode === AIMode.FLOW || aiChatManager.mode === AIMode.APP}
<Popover>
{#snippet trigger()}
<Button
variant="subtle"
size="xs"
title="Add context"
btnClasses="!px-1.5 !py-0.5 font-mono"
nonCaptureEvent
>
@
</Button>
{/snippet}
{#snippet content({ close })}
{#if aiChatManager.mode === AIMode.APP}
<AppAvailableContextList
{availableContext}
{selectedContext}
onSelect={(element) => {
aiChatInput?.addContextToSelection(element)
close()
}}
/>
{:else}
<AvailableContextList
{availableContext}
{selectedContext}
onSelect={(element) => {
aiChatInput?.addContextToSelection(element)
close()
}}
onSelectWorkspaceItem={(element) => {
aiChatInput?.addContextToSelection(element)
close()
}}
/>
{/if}
{/snippet}
</Popover>
{/if}
<ChatMode />
{#if aiChatManager.mode === AIMode.APP}
<DatatableCreationPolicy />
@@ -367,12 +317,31 @@
</div>
{/if}
{/if}
</div>
{/if}
</div>
{#if !disabled}
<Button
variant="subtle"
size="xs"
iconOnly
startIcon={{ icon: aiChatManager.loading ? Square : ArrowUp }}
disabled={!aiChatManager.loading && !aiChatInput?.hasContent()}
title={aiChatManager.loading ? 'Stop' : 'Send'}
btnClasses="!rounded-full !w-7 !h-7"
on:click={() => {
if (aiChatManager.loading) {
cancel()
} else {
aiChatInput?.triggerSend()
}
}}
/>
{/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
@@ -1,6 +1,4 @@
<script lang="ts">
import Popover from '$lib/components/meltComponents/Popover.svelte'
import AvailableContextList from './AvailableContextList.svelte'
import AppAvailableContextList from './AppAvailableContextList.svelte'
import ContextElementBadge from './ContextElementBadge.svelte'
import ContextTextarea from './ContextTextarea.svelte'
@@ -109,7 +107,7 @@
}
}
function addContextToSelection(contextElement: ContextElement) {
export function addContextToSelection(contextElement: ContextElement) {
if (!selectedContext || !availableContext) return
const alreadySelected = selectedContext.find(
@@ -159,6 +157,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()
@@ -360,34 +369,8 @@
<div use:clickOutside class="relative">
{#if aiChatManager.mode === AIMode.SCRIPT || aiChatManager.mode === AIMode.FLOW}
{#if showContext}
{#if showContext && selectedContext && selectedContext.length > 0}
<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}
{#snippet content({ close })}
<AvailableContextList
{availableContext}
{selectedContext}
onSelect={(element) => {
addContextToSelection(element)
close()
}}
onSelectWorkspaceItem={(element) => {
addContextToSelection(element)
close()
}}
/>
{/snippet}
</Popover>
{#each selectedContext as element}
<ContextElementBadge
contextElement={element}
@@ -419,30 +402,8 @@
{onKeyDown}
/>
{:else if aiChatManager.mode === AIMode.APP}
{#if showContext}
{#if showContext && selectedContext && selectedContext.length > 0}
<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}
{#snippet content({ close })}
<AppAvailableContextList
{availableContext}
{selectedContext}
onSelect={(element) => {
addContextToSelection(element)
close()
}}
/>
{/snippet}
</Popover>
{#each selectedContext as element (element.type + '-' + element.title)}
<ContextElementBadge
contextElement={element}
@@ -483,7 +444,7 @@
sendRequest()
}
}}
rows={3}
rows={1}
placeholder={modePlaceholder}
class="resize-none"
{disabled}
@@ -529,7 +490,7 @@
sendRequest()
}
}}
rows={3}
rows={1}
placeholder={modePlaceholder}
class="resize-none"
{disabled}
@@ -71,7 +71,7 @@
{:else}
<div
class={twMerge(
'text-sm py-1 mx-2',
'text-xs py-1 mx-2',
message.role === 'user' &&
'px-2 border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 rounded-lg relative group',
(message.role === 'assistant' || message.role === 'tool') && 'px-[1px]',
@@ -88,7 +88,7 @@
</div>
{/if}
{#if message.role === 'user' && message.snapshot}
<div class="mx-2 text-sm text-primary flex flex-row items-center justify-between gap-2 mt-2">
<div class="mx-2 text-xs text-primary flex flex-row items-center justify-between gap-2 mt-2">
Saved {message.snapshot.type === 'flow' ? 'a flow' : 'an app'} snapshot
<Button
size="xs2"
@@ -6,14 +6,14 @@
import LinkRenderer from './LinkRenderer.svelte'
interface Props {
message: DisplayMessage;
message: DisplayMessage
}
let { message }: Props = $props();
let { message }: Props = $props()
</script>
<div
class="prose prose-sm dark:prose-invert w-full max-w-full leading-snug space-y-2 prose-ul:!pl-6"
class="prose prose-sm dark:prose-invert w-full max-w-full leading-snug space-y-2 prose-ul:!pl-6 [&_*]:!text-xs"
>
<Markdown
md={message.content}
@@ -315,7 +315,7 @@
onkeydown={handleKeyDown}
bind:value
use:autosize
rows={3}
rows={1}
oninput={handleInput}
onblur={() => {
setTimeout(() => {
@@ -372,13 +372,22 @@
<style>
.textarea-input {
padding: 0.25rem;
border: 1px solid transparent;
border: none !important;
outline: none !important;
box-shadow: none !important;
border-radius: 0.75rem; /* matches rounded-xl */
font-family: inherit;
font-size: 0.75rem;
line-height: 1.72;
white-space: pre-wrap;
word-break: break-words;
width: 100%;
min-height: 3rem;
min-height: 1.75rem;
}
.textarea-input:focus,
.textarea-input:focus-visible {
border: none !important;
outline: none !important;
box-shadow: none !important;
}
</style>
@@ -1,10 +1,23 @@
<script lang="ts">
import { Loader2, ChevronDown, ChevronRight, XCircle, Play } from 'lucide-svelte'
import {
Loader2,
ChevronDown,
ChevronRight,
XCircle,
Play,
ArrowUpRight,
Workflow,
Calendar,
FileCode,
LayoutDashboard
} from 'lucide-svelte'
import { Button } from '$lib/components/common'
import { aiChatManager } from './AIChatManager.svelte'
import type { ToolDisplayMessage } from './shared'
import { twMerge } from 'tailwind-merge'
import ToolContentDisplay from './ToolContentDisplay.svelte'
import LanguageIcon from '$lib/components/common/languageIcons/LanguageIcon.svelte'
import { openPreviewModal } from './previewModalState.svelte'
interface Props {
message: ToolDisplayMessage
@@ -21,107 +34,236 @@
(message.isStreamingArguments && hasParameters) ||
(message.isLoading && message.needsConfirmation)
)
// Codex-style "open the resource" preview card under tool calls that
// create or modify a flow step, schedule, script, or app. Inferred from
// the tool name; the parameters give us the resource title when available.
type PreviewKind = 'flow' | 'schedule' | 'script' | 'app'
type PreviewInfo = {
kind: PreviewKind
title: string
subtitle: string
icon: typeof Workflow
iconBg: string
iconColor: string
// When set, render LanguageIcon for that lang instead of the lucide icon.
language?: string
}
const previewInfo = $derived.by((): PreviewInfo | undefined => {
// Only show for create/edit/update tool calls that have completed successfully.
const name = (message.toolName ?? '').toLowerCase()
const verbMatch = /\b(create|add|edit|update|modify|set)/.test(name)
if (!verbMatch) return undefined
if (message.isLoading || message.error || message.needsConfirmation) return undefined
const params = (message.parameters ?? {}) as Record<string, any>
if (/(schedule|cron)/.test(name)) {
return {
kind: 'schedule',
title: params.summary ?? params.path ?? 'Schedule',
subtitle: 'Schedule',
icon: Calendar,
iconBg: 'bg-blue-50 dark:bg-blue-900/20',
iconColor: 'text-blue-500'
}
}
if (/(script)/.test(name)) {
return {
kind: 'script',
title: params.summary ?? params.path ?? 'Script',
subtitle: 'Script',
icon: FileCode,
iconBg: 'bg-violet-50 dark:bg-violet-900/20',
iconColor: 'text-violet-500'
}
}
if (/(app)/.test(name)) {
return {
kind: 'app',
title: params.summary ?? params.path ?? 'App',
subtitle: 'App',
icon: LayoutDashboard,
iconBg: 'bg-emerald-50 dark:bg-emerald-900/20',
iconColor: 'text-emerald-500'
}
}
if (/(flow|step|module)/.test(name)) {
return {
kind: 'flow',
title: params.summary ?? params.id ?? params.path ?? 'Flow step',
subtitle: /step|module/.test(name) ? 'Flow step' : 'Flow',
icon: Workflow,
iconBg: 'bg-sky-50 dark:bg-sky-900/20',
iconColor: 'text-sky-500',
language: typeof params.language === 'string' ? params.language : undefined
}
}
return undefined
})
</script>
<div
class="bg-surface border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden font-mono text-xs"
>
<!-- Collapsible Header -->
<button
class={twMerge(
'w-full p-2 bg-surface-secondary hover:bg-surface-hover transition-colors flex items-center justify-between text-left border-b border-gray-200 dark:border-gray-700',
message.needsConfirmation ? 'opacity-80' : ''
)}
onclick={() => (isExpanded = !isExpanded)}
disabled={!message.showDetails && !message.isStreamingArguments}
<div class="flex flex-col gap-1.5">
<div
class="bg-surface border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden font-mono text-xs"
>
<div class="flex items-center gap-2 flex-1">
{#if message.showDetails || message.isStreamingArguments}
{#if isExpanded}
<ChevronDown class="w-3 h-3 text-secondary" />
{:else}
<ChevronRight class="w-3 h-3 text-secondary" />
<!-- Collapsible Header -->
<button
class={twMerge(
'w-full p-2 bg-surface-secondary hover:bg-surface-hover transition-colors flex items-center justify-between text-left border-b border-gray-200 dark:border-gray-700',
message.needsConfirmation ? 'opacity-80' : ''
)}
onclick={() => (isExpanded = !isExpanded)}
disabled={!message.showDetails && !message.isStreamingArguments}
>
<div class="flex items-center gap-2 flex-1">
{#if message.showDetails || message.isStreamingArguments}
{#if isExpanded}
<ChevronDown class="w-3 h-3 text-secondary" />
{:else}
<ChevronRight class="w-3 h-3 text-secondary" />
{/if}
{/if}
{/if}
{#if message.isLoading && !message.needsConfirmation}
<Loader2 class="w-3.5 h-3.5 animate-spin text-blue-500" />
{:else if message.error}
<span class="text-red-500"></span>
{:else if !message.isLoading && !message.error}
<span class="text-green-500"></span>
{/if}
<span class="text-primary font-medium text-2xs">
{message.content}
</span>
</div>
</button>
{#if message.isLoading && !message.needsConfirmation}
<Loader2 class="w-3.5 h-3.5 animate-spin text-blue-500" />
{:else if message.error}
<span class="text-red-500"></span>
{:else if !message.isLoading && !message.error}
<span class="text-green-500"></span>
{/if}
<span class="text-primary font-medium text-xs">
{message.content}
</span>
</div>
</button>
<!-- Expanded Content -->
{#if isExpanded}
<div class="p-2 bg-surface space-y-3">
<!-- Parameters Section - show if we have parameters, or if confirmation is needed (even with empty params) -->
{#if hasParameters || message.needsConfirmation}
<div class={message.needsConfirmation ? 'opacity-80' : ''}>
<ToolContentDisplay
title="Parameters"
content={message.parameters}
streaming={message.isStreamingArguments}
toolName={message.toolName}
showFade={message.showFade}
/>
</div>
{/if}
<!-- Expanded Content -->
{#if isExpanded}
<div class="p-2 bg-surface space-y-3">
<!-- Parameters Section - show if we have parameters, or if confirmation is needed (even with empty params) -->
{#if hasParameters || message.needsConfirmation}
<div class={message.needsConfirmation ? 'opacity-80' : ''}>
<ToolContentDisplay
title="Parameters"
content={message.parameters}
streaming={message.isStreamingArguments}
toolName={message.toolName}
showFade={message.showFade}
/>
</div>
{/if}
<!-- Confirmation Footer -->
{#if message.needsConfirmation}
<div
class={twMerge(
'mt-3 pt-3 flex flex-row items-center justify-end gap-2',
hasParameters ? 'border-t border-gray-200 dark:border-gray-700' : ''
)}
>
<Button
variant="default"
size="xs"
on:click={() => {
if (message.tool_call_id) {
aiChatManager.handleToolConfirmation(message.tool_call_id, false)
}
}}
startIcon={{ icon: XCircle }}
destructive
></Button>
<Button
variant="accent"
size="xs"
on:click={() => {
if (message.tool_call_id) {
aiChatManager.handleToolConfirmation(message.tool_call_id, true)
}
}}
startIcon={{ icon: Play }}
<!-- Confirmation Footer -->
{#if message.needsConfirmation}
<div
class={twMerge(
'mt-3 pt-3 flex flex-row items-center justify-end gap-2',
hasParameters ? 'border-t border-gray-200 dark:border-gray-700' : ''
)}
>
Run
</Button>
</div>
<Button
variant="default"
size="xs"
on:click={() => {
if (message.tool_call_id) {
aiChatManager.handleToolConfirmation(message.tool_call_id, false)
}
}}
startIcon={{ icon: XCircle }}
destructive
></Button>
<Button
variant="accent"
size="xs"
on:click={() => {
if (message.tool_call_id) {
aiChatManager.handleToolConfirmation(message.tool_call_id, true)
}
}}
startIcon={{ icon: Play }}
>
Run
</Button>
</div>
<!-- Logs and Result - hide while streaming -->
{:else if !message.isStreamingArguments}
<ToolContentDisplay
title="Logs"
content={message.logs}
loading={message.isLoading}
showWhileLoading={false}
/>
<!-- Logs and Result - hide while streaming -->
{:else if !message.isStreamingArguments}
<ToolContentDisplay
title="Logs"
content={message.logs}
loading={message.isLoading}
showWhileLoading={false}
/>
<ToolContentDisplay
title="Result"
content={message.result}
error={message.error}
loading={message.isLoading}
/>
{/if}
</div>
<ToolContentDisplay
title="Result"
content={message.result}
error={message.error}
loading={message.isLoading}
/>
{/if}
</div>
{/if}
</div>
{#if previewInfo}
{@const PreviewIcon = previewInfo.icon}
{@const clickable = previewInfo.kind === 'schedule'}
<button
type="button"
disabled={!clickable}
onclick={() => {
if (!clickable) return
openPreviewModal({
kind: previewInfo.kind,
title: previewInfo.title
})
}}
class={twMerge(
'flex items-center gap-3 p-2.5 bg-surface border border-gray-200 dark:border-gray-700 rounded-md font-sans w-full text-left',
clickable
? 'hover:bg-surface-hover hover:border-gray-300 dark:hover:border-gray-600 transition-colors cursor-pointer'
: 'cursor-default'
)}
>
<div
class={twMerge(
'w-8 h-8 rounded-md flex items-center justify-center shrink-0',
previewInfo.language ? 'bg-surface-secondary' : previewInfo.iconBg
)}
>
{#if previewInfo.language}
<LanguageIcon lang={previewInfo.language as any} width={18} height={18} />
{:else}
<PreviewIcon class={twMerge('w-4 h-4', previewInfo.iconColor)} />
{/if}
</div>
<div class="flex flex-col flex-1 min-w-0">
<span class="text-xs font-medium text-primary truncate">{previewInfo.title}</span>
<span class="text-2xs text-secondary truncate">{previewInfo.subtitle}</span>
</div>
<!-- The Open button intentionally stops propagation so the card click handler
(modal) and the Open click handler (full-page nav, TBD) stay independent. -->
<span
role="button"
tabindex="0"
class="inline-flex items-center gap-1 px-2.5 py-1 text-xs rounded-md border border-gray-200 dark:border-gray-700 hover:bg-surface-hover transition-colors text-primary"
onclick={(e) => {
e.stopPropagation()
// TODO: navigate to the resource page
}}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
e.stopPropagation()
}
}}
>
<ArrowUpRight class="w-3.5 h-3.5" />
Open
</span>
</button>
{/if}
</div>
@@ -0,0 +1,35 @@
// Shared state for the "open resource" modal triggered by clicking a Codex-style
// preview card under a tool call (see ToolExecutionDisplay.svelte). The modal itself
// is mounted at the page level (e.g. /design-sandbox) so it overlays the whole UI.
export type PreviewModalKind = 'schedule' | 'flow' | 'script' | 'app'
type State = {
kind: PreviewModalKind | null
path: string
title: string
isFlow: boolean
}
export const previewModalState = $state<State>({
kind: null,
path: '',
title: '',
isFlow: false
})
export function openPreviewModal(opts: {
kind: PreviewModalKind
path?: string
title?: string
isFlow?: boolean
}) {
previewModalState.kind = opts.kind
previewModalState.path = opts.path ?? ''
previewModalState.title = opts.title ?? ''
previewModalState.isFlow = opts.isFlow ?? false
}
export function closePreviewModal() {
previewModalState.kind = null
}
@@ -4,6 +4,7 @@
import FlowModuleSchemaMap from './map/FlowModuleSchemaMap.svelte'
import WindmillIcon from '../icons/WindmillIcon.svelte'
import { Skeleton } from '../common'
import { PanelRightClose, PanelRightOpen } from 'lucide-svelte'
import { getContext, onDestroy, onMount, setContext } from 'svelte'
import type { FlowEditorContext } from './types'
@@ -98,6 +99,10 @@
let flowModuleSchemaMap: FlowModuleSchemaMap | undefined = $state()
// The right-side settings/inspector panel can be collapsed to give the flow
// graph the full width. Collapsed by default — the panel is opt-in.
let panelCollapsed = $state(true)
export function isNodeVisible(nodeId: string): boolean {
return flowModuleSchemaMap?.isNodeVisible(nodeId) ?? false
}
@@ -143,8 +148,21 @@
}}
>
<Splitpanes>
<Pane size={50} minSize={15} class="h-full relative z-0">
<Pane size={panelCollapsed ? 100 : 50} minSize={15} class="h-full relative z-0">
<div class="grow overflow-hidden bg-gray h-full bg-surface-secondary relative">
<button
type="button"
title={panelCollapsed ? 'Show settings panel' : 'Hide settings panel'}
aria-label={panelCollapsed ? 'Show settings panel' : 'Hide settings panel'}
onclick={() => (panelCollapsed = !panelCollapsed)}
class="absolute top-2 right-2 z-20 inline-flex items-center justify-center w-7 h-7 rounded-md border border-gray-200 dark:border-gray-700 bg-surface hover:bg-surface-hover text-secondary hover:text-primary transition-colors"
>
{#if panelCollapsed}
<PanelRightOpen class="w-4 h-4" />
{:else}
<PanelRightClose class="w-4 h-4" />
{/if}
</button>
{#if loading}
<div class="p-2 pt-10">
{#each new Array(6) as _}
@@ -190,34 +208,36 @@
{/if}
</div>
</Pane>
<Pane class="relative z-10" size={50} minSize={20}>
{#if loading}
<div class="w-full h-full">
<div class="block m-auto pt-40 w-10">
<WindmillIcon height="40px" width="40px" spin="fast" />
{#if !panelCollapsed}
<Pane class="relative z-10" size={50} minSize={20}>
{#if loading}
<div class="w-full h-full">
<div class="block m-auto pt-40 w-10">
<WindmillIcon height="40px" width="40px" spin="fast" />
</div>
</div>
</div>
{:else}
<FlowEditorPanel
{disabledFlowInputs}
{newFlow}
{savedFlow}
enableAi={!disableAi}
on:applyArgs
on:testWithArgs
{onDeployTrigger}
{forceTestTab}
{highlightArg}
{onTestFlow}
{job}
{isOwner}
{suspendStatus}
onOpenDetails={onOpenPreview}
{previewOpen}
{flowModuleSchemaMap}
/>
{/if}
</Pane>
{:else}
<FlowEditorPanel
{disabledFlowInputs}
{newFlow}
{savedFlow}
enableAi={!disableAi}
on:applyArgs
on:testWithArgs
{onDeployTrigger}
{forceTestTab}
{highlightArg}
{onTestFlow}
{job}
{isOwner}
{suspendStatus}
onOpenDetails={onOpenPreview}
{previewOpen}
{flowModuleSchemaMap}
/>
{/if}
</Pane>
{/if}
{#if !disableAi}
<FlowAIChat {flowModuleSchemaMap} {onTestFlow} />
{/if}
@@ -1180,7 +1180,7 @@
</div>
{:else}
<Controls
position="top-right"
position="bottom-right"
orientation="horizontal"
showLock={false}
fitViewOptions={{ nodes: nodes.filter((n) => n.type !== 'note') }}
@@ -49,7 +49,7 @@
import { SUPERADMIN_SETTINGS_HASH, USER_SETTINGS_HASH } from '$lib/components/sidebar/settings'
import { isCloudHosted } from '$lib/cloud'
import { syncTutorialsTodos } from '$lib/tutorialUtils'
import { ArrowLeft, Search, WandSparkles } from 'lucide-svelte'
import { ArrowLeft, Plus, Search, WandSparkles } from 'lucide-svelte'
import { getUserExt } from '$lib/user'
import { deepEqual } from 'fast-equals'
import { twMerge } from 'tailwind-merge'
@@ -463,6 +463,49 @@
globalDbManagerDrawer.val = useDbManagerUriState()
</script>
{#snippet chatHistorySection({ isCollapsed: collapsed }: { isCollapsed: boolean })}
<div class="pt-3 flex flex-col">
<div class="flex flex-row items-center justify-between px-2 mb-1">
<div
class="text-secondary text-[0.5rem] uppercase transition-opacity"
class:opacity-0={collapsed}
>
AI chat
</div>
<button
type="button"
title="New chat"
class="p-0.5 rounded text-secondary hover:bg-surface-hover hover:text-primary transition-colors"
onclick={() => {
aiChatManager.saveAndClear()
goto('/design-sandbox')
}}
>
<Plus class="w-3.5 h-3.5" />
</button>
</div>
{#if !collapsed}
<div class="flex flex-col gap-1.5 max-h-48 overflow-y-auto">
{#each aiChatManager.historyManager.getPastChats() as chat (chat.id)}
<button
type="button"
class="text-left text-2xs font-normal px-2 py-1.5 rounded hover:bg-surface-hover transition-colors truncate"
title={chat.title}
onclick={() => {
aiChatManager.loadPastChat(chat.id)
goto('/design-sandbox')
}}
>
{chat.title}
</button>
{:else}
<div class="text-2xs font-normal text-secondary px-2 py-1.5 italic">No past chats</div>
{/each}
</div>
{/if}
</div>
{/snippet}
<svelte:window bind:innerWidth />
<UserSettings bind:this={userSettings} showMcpMode={true} />
@@ -576,6 +619,7 @@
iconClasses="!text-ai"
shortcut={`${getModifierKey()}L`}
/>
{@render chatHistorySection({ isCollapsed: false })}
</div>
<SidebarContent
@@ -655,6 +699,7 @@
iconClasses="!text-ai"
shortcut={`${getModifierKey()}L`}
/>
{@render chatHistorySection({ isCollapsed })}
</div>
<SidebarContent
@@ -768,6 +813,7 @@
iconClasses="!text-ai"
shortcut={`${getModifierKey()}L`}
/>
{@render chatHistorySection({ isCollapsed })}
</div>
<SidebarContent
@@ -0,0 +1,288 @@
<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'
import Modal2 from '$lib/components/common/modal/Modal2.svelte'
import ScheduleEditorInner from '$lib/components/triggers/schedules/ScheduleEditorInner.svelte'
import {
previewModalState,
closePreviewModal
} from '$lib/components/copilot/chat/previewModalState.svelte'
import { MessageSquare } from 'lucide-svelte'
// 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
}
]
// Realistic-sounding past-chat titles for the sidebar history. Just titles + ids
// because the sidebar snippet only reads those two fields.
const fakePastChats = [
{ id: 'mock_1', title: 'Daily user re-engagement flow' },
{ id: 'mock_2', title: 'Sync Stripe customers to Postgres' },
{ id: 'mock_3', title: 'Weekly usage report email' },
{ id: 'mock_4', title: 'Migrate S3 bucket to GCS' },
{ id: 'mock_5', title: 'Slack alert on failed deploys' },
{ id: 'mock_6', title: 'Parse CSV uploads into Postgres' },
{ id: 'mock_7', title: 'Generate signed download URLs' },
{ id: 'mock_8', title: 'Onboarding webhook handler' }
]
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
// Patch getPastChats so the sidebar's "AI chat" history shows the mock titles
// without writing anything to IndexedDB (keeps the real workspace history clean).
// Cast to any because savedChats / pastChats are private fields on HistoryManager.
;(aiChatManager.historyManager as any).getPastChats = () => fakePastChats
}
// 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)
})
// Real schedule used by the schedule preview modal. The path must exist in the
// workspace — ScheduleEditorInner.openEdit fetches it via ScheduleService.getSchedule.
// Synthesizing a `defaultCfg` instead leaves too many fields undefined and crashes
// downstream sub-components (Path, ErrorOrRecoveryHandler, etc.).
const SANDBOX_SCHEDULE_PATH = 'u/guilhempw/fake_flow_schedule'
const SANDBOX_SCHEDULE_IS_FLOW = true
let scheduleEditor: ScheduleEditorInner | undefined = $state(undefined)
// When the schedule modal opens (kind = 'schedule'), load the real schedule so
// the editor populates fully (cron, timezone, runnable, handlers, etc.).
$effect(() => {
if (previewModalState.kind === 'schedule' && scheduleEditor) {
scheduleEditor.openEdit(SANDBOX_SCHEDULE_PATH, SANDBOX_SCHEDULE_IS_FLOW)
}
})
// 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
}
// Conversation title shown in the chat topbar. Derived from the first user
// message in the current chat, truncated. Falls back to a placeholder while
// the chat is empty.
const conversationTitle = $derived.by(() => {
const firstUser = aiChatManager.displayMessages.find((m) => m.role === 'user')
if (!firstUser) return 'New conversation'
const text = firstUser.content.trim()
const truncated = text.length > 60 ? text.slice(0, 60).trimEnd() + '…' : text
return truncated
})
</script>
<div class="h-full w-full flex flex-col min-h-0">
<Splitpanes horizontal={false} class="design-sandbox-splitpanes flex-1 min-h-0">
<Pane size={50} minSize={20} class="flex flex-col min-h-0">
<div class="flex flex-row items-center gap-2 px-3 h-12 bg-surface shrink-0">
<MessageSquare class="w-3.5 h-3.5 text-secondary shrink-0" />
<span class="text-xs font-medium text-primary truncate" title={conversationTitle}>
{conversationTitle}
</span>
</div>
<div class="flex-1 min-h-0">
<AIChat />
</div>
</Pane>
<!-- The right pane is a contained, padded card to make it visually
clear that the flow view "belongs to" the chat on the left rather
than being its own root view. -->
<Pane size={50} minSize={30} class="flex flex-col min-h-0 p-2 pl-0">
<div
class="flex-1 min-h-0 rounded-md border border-light bg-surface overflow-hidden shadow-sm"
>
<FlowWrapper
disableAi
pathStoreInit="u/admin/sandbox_flow"
{customUi}
selectedId={undefined}
newFlow
{flowStore}
{flowStateStore}
/>
</div>
</Pane>
</Splitpanes>
</div>
<!-- Centered modal triggered by clicking a schedule preview card. Hosts
ScheduleEditorInner inline (no Drawer) so the user sees the same form
fields as on the schedule page. Two-way bind on isOpen syncs the modal's
internal close (X / esc / outside-click) back to the shared state. -->
<Modal2
title={`Edit schedule ${SANDBOX_SCHEDULE_PATH}`}
fixedWidth="lg"
fixedHeight="xl"
contentClasses="overflow-y-auto"
target="body"
bind:isOpen={
() => previewModalState.kind === 'schedule',
(v) => {
if (!v) closePreviewModal()
}
}
>
<div class="w-full">
<ScheduleEditorInner bind:this={scheduleEditor} useDrawer={false} />
</div>
</Modal2>
<style>
/* Hide the visible splitpanes divider while keeping the hit area so the
panel is still resizable. Scoped to this page via the wrapper class. */
:global(.design-sandbox-splitpanes > .splitpanes__splitter) {
background-color: transparent !important;
background-image: none !important;
border: none !important;
}
:global(.design-sandbox-splitpanes > .splitpanes__splitter::before),
:global(.design-sandbox-splitpanes > .splitpanes__splitter::after) {
display: none !important;
}
</style>