mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-15 00:02:32 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0e89472fe | ||
|
|
05f3e2845e | ||
|
|
c750bdebe9 | ||
|
|
b28b814e95 | ||
|
|
63197f682d | ||
|
|
9afc108c75 | ||
|
|
2ab9d35c85 | ||
|
|
0da682bd7c |
@@ -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>
|
||||
Reference in New Issue
Block a user