diff --git a/frontend/src/lib/components/ResourceEditorDrawer.svelte b/frontend/src/lib/components/ResourceEditorDrawer.svelte index ed80717a32..b854e0c169 100644 --- a/frontend/src/lib/components/ResourceEditorDrawer.svelte +++ b/frontend/src/lib/components/ResourceEditorDrawer.svelte @@ -31,6 +31,10 @@ drawer?.openDrawer?.() } + export function closeDrawer(): void { + drawer?.closeDrawer?.() + } + export async function initNew( resourceType: string, nDefaultValues?: Record @@ -45,10 +49,10 @@ let mode: 'edit' | 'new' = $derived(!path ? 'new' : 'edit') - + drawer?.closeDrawer()} > {#await import('./ResourceEditor.svelte')} diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte index addbfc095f..56cb16def8 100644 --- a/frontend/src/lib/components/VariableEditor.svelte +++ b/frontend/src/lib/components/VariableEditor.svelte @@ -136,6 +136,10 @@ drawer?.openDrawer() } + export function closeDrawer(): void { + drawer?.closeDrawer() + } + async function loadSecret(): Promise { if (!editPath || !selected) return const getV = await VariableService.getVariable({ @@ -196,10 +200,10 @@ } - + drawer?.closeDrawer()} >
{#if !can_write} diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 18c50fa67f..5988992d24 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -146,6 +146,53 @@ class AIChatManager { /** Cached datatables for app context (fetched asynchronously) */ cachedDatatables = $state([]) + /** + * Inline-drawer request for a workspace item referenced from a chat message. + * + * Consumed by the drawer host mounted in AiChatLayout, which calls the matching + * editor's open method. + * + * `version` increments on every request so re-clicking after the drawer was closed + * via the X button (which doesn't reset this state) still re-triggers the effect. + */ + workspaceItemDrawer = $state< + | { + kind: WorkspaceItemEntry['kind'] + path: string + version: number + open: boolean + } + | undefined + >(undefined) + + /** + * Toggle the inline drawer for a workspace item. + * + * Behavior: + * - If a drawer for the same kind+path is currently open, close it. + * - Otherwise, set up state to open (or re-open) the drawer. + */ + toggleWorkspaceItemDrawer(target: { kind: WorkspaceItemEntry['kind']; path: string }): void { + const cur = this.workspaceItemDrawer + const sameTarget = cur?.kind === target.kind && cur.path === target.path + if (cur?.open && sameTarget) { + this.workspaceItemDrawer = { ...cur, open: false, version: cur.version + 1 } + return + } + this.workspaceItemDrawer = { + kind: target.kind, + path: target.path, + version: (cur?.version ?? 0) + 1, + open: true + } + } + + /** Mark the inline drawer as closed (called by the host when the drawer's own X is hit). */ + markWorkspaceItemDrawerClosed(): void { + if (!this.workspaceItemDrawer?.open) return + this.workspaceItemDrawer = { ...this.workspaceItemDrawer, open: false } + } + private confirmationCallback = $state<((value: boolean) => void) | undefined>(undefined) private appDatatablesRefreshTimeout: ReturnType | undefined = undefined diff --git a/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte b/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte index aa14cf1aa9..b6679753dc 100644 --- a/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte +++ b/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte @@ -11,6 +11,7 @@ import Button from '$lib/components/common/button/Button.svelte' import { Menu } from 'lucide-svelte' import CreatedResourceActionDrawers from './CreatedResourceActionDrawers.svelte' + import WorkspaceItemDrawerHost from './WorkspaceItemDrawerHost.svelte' interface Props { noPadding?: boolean @@ -50,6 +51,7 @@ {#if !disableAi} +
import type { Snippet } from 'svelte' - import { Code2, ExternalLink, LayoutDashboard } from 'lucide-svelte' - import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte' - import type { WindmillItemKind } from './workspaceItems.svelte' + import { ExternalLink, PanelRight } from 'lucide-svelte' + import RowIcon from '$lib/components/common/table/RowIcon.svelte' + import { aiChatManager } from './AIChatManager.svelte' + import { hasInlineDrawer, type WindmillItemKind } from './workspaceItems.svelte' type Props = { href?: string @@ -11,10 +12,10 @@ 'data-wm-path'?: string title?: string } - let { href, children, 'data-wm-kind': wmKind, title }: Props = $props() + let { href, children, 'data-wm-kind': wmKind, 'data-wm-path': wmPath, title }: Props = $props() - // Fallback to URL-based detection if the data attribute didn't make it through - // (hast/rehype can rename custom properties on some pipelines). + // Fallback to URL-based detection for scripts/flows/apps. Other kinds rely on the + // data attribute set by the remark plugin. const kind = $derived.by((): WindmillItemKind | undefined => { if (wmKind) return wmKind if (!href) return undefined @@ -23,33 +24,41 @@ if (href.startsWith('/apps/get/')) return 'app' return undefined }) + const drawerable = $derived(kind ? hasInlineDrawer(kind) : false) {#if href} {#if kind} - - - {#if kind === 'script'} - - {:else if kind === 'flow'} - - {:else if kind === 'app'} - - {/if} - - {@render children?.()} - + - - - + + + + {@render children?.()} + + + + + {#if drawerable && wmPath} + + {/if} + {:else} {@render children?.()} diff --git a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte index 9753cf3b59..015d0c717d 100644 --- a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte @@ -5,9 +5,8 @@ ChevronRight, XCircle, Play, - Code2, - LayoutDashboard, - ExternalLink + ExternalLink, + PanelRight } from 'lucide-svelte' import { Button } from '$lib/components/common' import { aiChatManager } from './AIChatManager.svelte' @@ -15,10 +14,11 @@ import { twMerge } from 'tailwind-merge' import ToolContentDisplay from './ToolContentDisplay.svelte' import ToolMessageActions from './ToolMessageActions.svelte' - import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte' + import RowIcon from '$lib/components/common/table/RowIcon.svelte' import { workspaceStore } from '$lib/stores' import { extractCandidatePaths, + hasInlineDrawer, itemHref, workspaceItemRegistry, type WorkspaceItemEntry @@ -100,15 +100,17 @@ class="bg-surface border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden font-mono text-xs" > - + {#if referencedItems.length > 0} + +
+ {#each referencedItems as item (item.path)} + + + + + + {item.path} + + + {#if hasInlineDrawer(item.kind)} +
- {/if} + + + {/if} + + {/each}
-
- + {/if} + {#if isExpanded} diff --git a/frontend/src/lib/components/copilot/chat/WorkspaceItemDrawerHost.svelte b/frontend/src/lib/components/copilot/chat/WorkspaceItemDrawerHost.svelte new file mode 100644 index 0000000000..171792ee93 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/WorkspaceItemDrawerHost.svelte @@ -0,0 +1,51 @@ + + + aiChatManager.markWorkspaceItemDrawerClosed()} +/> + aiChatManager.markWorkspaceItemDrawerClosed()} +/> diff --git a/frontend/src/lib/components/copilot/chat/workspaceItems.svelte.ts b/frontend/src/lib/components/copilot/chat/workspaceItems.svelte.ts index 29cdaa1759..4ccdaeac8a 100644 --- a/frontend/src/lib/components/copilot/chat/workspaceItems.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/workspaceItems.svelte.ts @@ -1,9 +1,42 @@ -import { AppService, FlowService, ScriptService } from '$lib/gen' +import { + AppService, + AzureTriggerService, + EmailTriggerService, + FlowService, + GcpTriggerService, + HttpTriggerService, + KafkaTriggerService, + MqttTriggerService, + NatsTriggerService, + PostgresTriggerService, + ResourceService, + ScheduleService, + ScriptService, + SqsTriggerService, + VariableService, + WebsocketTriggerService +} from '$lib/gen' import { findAndReplace } from 'mdast-util-find-and-replace' import { visit } from 'unist-util-visit' import type { Root, InlineCode, Link } from 'mdast' -export type WindmillItemKind = 'script' | 'flow' | 'app' +export type WindmillItemKind = + | 'script' + | 'flow' + | 'app' + | 'variable' + | 'resource' + | 'schedule' + | 'http_trigger' + | 'websocket_trigger' + | 'kafka_trigger' + | 'nats_trigger' + | 'postgres_trigger' + | 'mqtt_trigger' + | 'sqs_trigger' + | 'gcp_trigger' + | 'azure_trigger' + | 'email_trigger' export interface WorkspaceItemEntry { kind: WindmillItemKind @@ -30,16 +63,60 @@ export const WINDMILL_PATH_REGEX = */ const WINDMILL_PATH_EXACT_REGEX = /^[uf]\/[A-Za-z0-9_.\-]+\/[A-Za-z0-9_./\-]*[A-Za-z0-9_\-]$/ -const itemKindToRoute: Record = { - script: '/scripts/get', - flow: '/flows/get', - app: '/apps/get' +/** + * URL builder per kind. + * + * - For scripts/flows/apps we link to the dedicated `/get/{path}` page. + * - For everything else, link to the relevant list page with a hash fragment that the + * list page reads on mount to pop the editor drawer open. Hash format mirrors what + * each list page already expects (`#` for most, `#/resource/` for the + * resources page). + */ +const itemKindToHref: Record string> = { + script: (p) => `/scripts/get/${p}`, + flow: (p) => `/flows/get/${p}`, + app: (p) => `/apps/get/${p}`, + variable: (p) => `/variables#${p}`, + resource: (p) => `/resources#/resource/${p}`, + schedule: (p) => `/schedules#${p}`, + http_trigger: (p) => `/routes#${p}`, + websocket_trigger: (p) => `/websocket_triggers/#${p}`, + kafka_trigger: (p) => `/kafka_triggers/#${p}`, + nats_trigger: (p) => `/nats_triggers/#${p}`, + postgres_trigger: (p) => `/postgres_triggers/#${p}`, + mqtt_trigger: (p) => `/mqtt_triggers/#${p}`, + sqs_trigger: (p) => `/sqs_triggers/#${p}`, + gcp_trigger: (p) => `/gcp_triggers/#${p}`, + azure_trigger: (p) => `/azure_triggers/#${p}`, + email_trigger: (p) => `/email_triggers/#${p}` } -/** Build the in-app URL for a resolved workspace item. */ +/** Kinds whose items can be opened in an inline drawer from the chat. */ +const DRAWERABLE_KINDS = new Set(['variable', 'resource']) + +/** Whether the chat pill should expose an "open in drawer" affordance for this kind. */ +export function hasInlineDrawer(kind: WindmillItemKind): boolean { + return DRAWERABLE_KINDS.has(kind) +} + +/** + * Build the in-app URL for a resolved workspace item. + * + * The workspace query param goes before the hash so the SvelteKit router still applies + * it; the hash fragment is consumed client-side by the list page on mount to open the + * matching drawer. + */ export function itemHref(entry: WorkspaceItemEntry, workspace?: string): string { - const base = `${itemKindToRoute[entry.kind]}/${entry.path}` - return workspace ? `${base}?workspace=${workspace}` : base + const raw = itemKindToHref[entry.kind](entry.path) + if (!workspace) return raw + const hashIdx = raw.indexOf('#') + if (hashIdx === -1) { + return raw.includes('?') ? `${raw}&workspace=${workspace}` : `${raw}?workspace=${workspace}` + } + const pathPart = raw.slice(0, hashIdx) + const hashPart = raw.slice(hashIdx) + const sep = pathPart.includes('?') ? '&' : '?' + return `${pathPart}${sep}workspace=${workspace}${hashPart}` } /** @@ -54,22 +131,78 @@ class WorkspaceItemRegistry { #inflight: Map> = new Map() private async load(workspace: string): Promise { - const [scripts, flows, apps] = await Promise.all([ + // Fire every list endpoint in parallel. `.catch(() => [])` keeps a single failing + // endpoint from poisoning the whole snapshot — the registry just records empty + // for that kind, the rest still resolve. + const [ + scripts, + flows, + apps, + variables, + resources, + schedules, + httpTriggers, + wsTriggers, + kafkaTriggers, + natsTriggers, + pgTriggers, + mqttTriggers, + sqsTriggers, + gcpTriggers, + azureTriggers, + emailTriggers + ] = await Promise.all([ ScriptService.listScripts({ workspace }).catch(() => []), FlowService.listFlows({ workspace }).catch(() => []), - AppService.listApps({ workspace }).catch(() => []) + AppService.listApps({ workspace }).catch(() => []), + VariableService.listVariable({ workspace }).catch(() => []), + ResourceService.listResource({ workspace }).catch(() => []), + ScheduleService.listSchedules({ workspace }).catch(() => []), + HttpTriggerService.listHttpTriggers({ workspace }).catch(() => []), + WebsocketTriggerService.listWebsocketTriggers({ workspace }).catch(() => []), + KafkaTriggerService.listKafkaTriggers({ workspace }).catch(() => []), + NatsTriggerService.listNatsTriggers({ workspace }).catch(() => []), + PostgresTriggerService.listPostgresTriggers({ workspace }).catch(() => []), + MqttTriggerService.listMqttTriggers({ workspace }).catch(() => []), + SqsTriggerService.listSqsTriggers({ workspace }).catch(() => []), + GcpTriggerService.listGcpTriggers({ workspace }).catch(() => []), + AzureTriggerService.listAzureTriggers({ workspace }).catch(() => []), + EmailTriggerService.listEmailTriggers({ workspace }).catch(() => []) ]) const map = new Map() - for (const s of scripts) { - map.set(s.path, { kind: 'script', path: s.path, summary: s.summary }) - } - for (const f of flows) { - map.set(f.path, { kind: 'flow', path: f.path, summary: f.summary }) - } - for (const a of apps) { - map.set(a.path, { kind: 'app', path: a.path, summary: a.summary }) + const add = ( + items: Array<{ path: string; summary?: string | null }>, + kind: WindmillItemKind + ) => { + for (const it of items) { + // First writer wins — scripts/flows/apps are checked first so they win over + // triggers if a path collides (extremely unlikely but possible across kinds). + if (!map.has(it.path)) { + map.set(it.path, { kind, path: it.path, summary: it.summary ?? undefined }) + } + } } + // Order matters because of first-writer-wins on path collisions. Resources are + // added before variables: Windmill auto-creates a companion variable at the same + // path for every resource, and a path written by the user almost always means the + // resource, not the hidden variable. + add(scripts, 'script') + add(flows, 'flow') + add(apps, 'app') + add(resources, 'resource') + add(variables, 'variable') + add(schedules, 'schedule') + add(httpTriggers, 'http_trigger') + add(wsTriggers, 'websocket_trigger') + add(kafkaTriggers, 'kafka_trigger') + add(natsTriggers, 'nats_trigger') + add(pgTriggers, 'postgres_trigger') + add(mqttTriggers, 'mqtt_trigger') + add(sqsTriggers, 'sqs_trigger') + add(gcpTriggers, 'gcp_trigger') + add(azureTriggers, 'azure_trigger') + add(emailTriggers, 'email_trigger') // Build a new outer map to trigger reactivity on consumers using $derived. const next = new Map(this.#byWorkspace) diff --git a/frontend/src/lib/components/copilot/chat/workspaceItems.test.ts b/frontend/src/lib/components/copilot/chat/workspaceItems.test.ts index 656260cf91..533586ace4 100644 --- a/frontend/src/lib/components/copilot/chat/workspaceItems.test.ts +++ b/frontend/src/lib/components/copilot/chat/workspaceItems.test.ts @@ -7,7 +7,20 @@ import type { Root as MdastRoot, Link, Text } from 'mdast' vi.mock('$lib/gen', () => ({ ScriptService: { listScripts: vi.fn() }, FlowService: { listFlows: vi.fn() }, - AppService: { listApps: vi.fn() } + AppService: { listApps: vi.fn() }, + VariableService: { listVariable: vi.fn() }, + ResourceService: { listResource: vi.fn() }, + ScheduleService: { listSchedules: vi.fn() }, + HttpTriggerService: { listHttpTriggers: vi.fn() }, + WebsocketTriggerService: { listWebsocketTriggers: vi.fn() }, + KafkaTriggerService: { listKafkaTriggers: vi.fn() }, + NatsTriggerService: { listNatsTriggers: vi.fn() }, + PostgresTriggerService: { listPostgresTriggers: vi.fn() }, + MqttTriggerService: { listMqttTriggers: vi.fn() }, + SqsTriggerService: { listSqsTriggers: vi.fn() }, + GcpTriggerService: { listGcpTriggers: vi.fn() }, + AzureTriggerService: { listAzureTriggers: vi.fn() }, + EmailTriggerService: { listEmailTriggers: vi.fn() } })) import { @@ -62,7 +75,7 @@ describe('WINDMILL_PATH_REGEX', () => { }) describe('itemHref', () => { - it('routes by kind and appends ?workspace when provided', () => { + it('routes script/flow/app to /get/{path}', () => { expect(itemHref({ kind: 'script', path: 'f/a/b' })).toBe('/scripts/get/f/a/b') expect(itemHref({ kind: 'flow', path: 'f/a/b' }, 'admins')).toBe( '/flows/get/f/a/b?workspace=admins' @@ -71,6 +84,41 @@ describe('itemHref', () => { '/apps/get/u/me/dash?workspace=ws1' ) }) + + it('routes variable / resource / schedule to list page with hash to pop the drawer', () => { + expect(itemHref({ kind: 'variable', path: 'u/me/secret' })).toBe('/variables#u/me/secret') + expect(itemHref({ kind: 'resource', path: 'u/me/db' }, 'ws1')).toBe( + '/resources?workspace=ws1#/resource/u/me/db' + ) + expect(itemHref({ kind: 'schedule', path: 'f/etl/daily' })).toBe('/schedules#f/etl/daily') + }) + + it('routes each trigger kind with hash for drawer auto-open', () => { + const cases: Array<[WorkspaceItemEntry['kind'], string]> = [ + ['http_trigger', '/routes#f/a/b'], + ['websocket_trigger', '/websocket_triggers/#f/a/b'], + ['kafka_trigger', '/kafka_triggers/#f/a/b'], + ['nats_trigger', '/nats_triggers/#f/a/b'], + ['postgres_trigger', '/postgres_triggers/#f/a/b'], + ['mqtt_trigger', '/mqtt_triggers/#f/a/b'], + ['sqs_trigger', '/sqs_triggers/#f/a/b'], + ['gcp_trigger', '/gcp_triggers/#f/a/b'], + ['azure_trigger', '/azure_triggers/#f/a/b'], + ['email_trigger', '/email_triggers/#f/a/b'] + ] + for (const [kind, expected] of cases) { + expect(itemHref({ kind, path: 'f/a/b' })).toBe(expected) + } + }) + + it('puts workspace query param before the hash so the router applies it', () => { + expect(itemHref({ kind: 'variable', path: 'u/me/secret' }, 'ws1')).toBe( + '/variables?workspace=ws1#u/me/secret' + ) + expect(itemHref({ kind: 'http_trigger', path: 'f/a/b' }, 'ws1')).toBe( + '/routes?workspace=ws1#f/a/b' + ) + }) }) const SAMPLE_ENTRIES: Record = {