From 0ea570570e65abf64b3b171e592a8dd3eea0b105 Mon Sep 17 00:00:00 2001 From: AlexRV12 <71396855+AlexRV12@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:17:20 +0200 Subject: [PATCH] feat(ai-sessions): CRUD markdown artifacts in sessions (#10046) * feat: add IndexedDB persistence layer for AI-chat artifacts * feat: add reactive store for AI-chat artifacts * feat: add artifact chat tools and wire store lifecycle * feat: add markdown artifact viewer with source toggle * feat: surface session artifacts in the preview panel and chat list * feat: tell the copilot when to use artifacts in the session prompt * test(ai_evals): add artifact case and wire artifact helpers for session context * fix(copilot): keep in-memory artifacts across same-session resyncs Co-Authored-By: Claude Opus 4.8 (1M context) * feat: unify session composer edits/artifacts/jobs into a status line Co-Authored-By: Claude Opus 4.8 (1M context) * feat: add an artifacts section to the session preview picker Co-Authored-By: Claude Fable 5 * feat: share markdown prose presets and restyle the artifact viewer Co-Authored-By: Claude Fable 5 * refactor: unify session status popovers into one keyboard-navigable shell Co-Authored-By: Claude Fable 5 * fix: reset first-block top margin in all markdown prose presets Co-Authored-By: Claude Fable 5 * fix: open the preview picker on the artifacts branch for an active artifact Co-Authored-By: Claude Fable 5 * fix: keep artifact picker scope independent of branch hydration state Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Guilhem Lemouel --- .../frontend/core/global/globalEvalRunner.ts | 96 +++++++- ai_evals/cases/global.yaml | 24 ++ ai_evals/core/types.ts | 2 + ai_evals/modes/global.ts | 1 + .../lib/components/chat/ChatMessage.svelte | 3 +- .../copilot/chat/AIChatManager.svelte.ts | 20 +- .../copilot/chat/AssistantMessage.svelte | 16 +- .../copilot/chat/JobsSegment.svelte | 142 ++++++------ .../chat/artifacts/ArtifactViewer.svelte | 97 ++++++++ .../chat/artifacts/ArtifactsSegment.svelte | 64 ++++++ .../chat/artifacts/artifactTools.test.ts | 162 +++++++++++++ .../copilot/chat/artifacts/artifactTools.ts | 163 +++++++++++++ .../chat/artifacts/artifactsDB.test.ts | 145 ++++++++++++ .../copilot/chat/artifacts/artifactsDB.ts | 108 +++++++++ .../chat/artifacts/artifactsState.svelte.ts | 134 +++++++++++ .../chat/artifacts/artifactsState.test.ts | 216 ++++++++++++++++++ .../components/copilot/chat/global/core.ts | 16 +- frontend/src/lib/components/markdownProse.ts | 36 +++ .../sessions/PreviewRouterPicker.svelte | 52 ++++- .../components/sessions/PreviewTabHost.svelte | 38 +++ .../sessions/SessionChangesBar.svelte | 121 +++++----- .../sessions/SessionDiffDrawer.svelte | 4 +- .../sessions/SessionStatusPopover.svelte | 145 ++++++++++++ .../sessions/SessionStatusToken.svelte | 29 +++ .../sessions/WorkspaceDiffDrawer.svelte | 30 ++- .../components/sessions/previewRouter.test.ts | 44 +++- .../lib/components/sessions/previewRouter.ts | 24 ++ .../sessions/sessionPreviewTabs.svelte.ts | 65 +++++- .../sessions/sessionPreviewTabs.test.ts | 104 ++++++++- .../sessions/sessionRuntime.svelte.ts | 14 ++ .../sessions/sessionState.svelte.ts | 6 +- .../sessions/sessionStateIndexedDb.test.ts | 13 +- .../workspaceSettings/AiSkillsSettings.svelte | 10 +- .../(root)/(logged)/sessions/+page.svelte | 39 +++- 34 files changed, 1981 insertions(+), 202 deletions(-) create mode 100644 frontend/src/lib/components/copilot/chat/artifacts/ArtifactViewer.svelte create mode 100644 frontend/src/lib/components/copilot/chat/artifacts/ArtifactsSegment.svelte create mode 100644 frontend/src/lib/components/copilot/chat/artifacts/artifactTools.test.ts create mode 100644 frontend/src/lib/components/copilot/chat/artifacts/artifactTools.ts create mode 100644 frontend/src/lib/components/copilot/chat/artifacts/artifactsDB.test.ts create mode 100644 frontend/src/lib/components/copilot/chat/artifacts/artifactsDB.ts create mode 100644 frontend/src/lib/components/copilot/chat/artifacts/artifactsState.svelte.ts create mode 100644 frontend/src/lib/components/copilot/chat/artifacts/artifactsState.test.ts create mode 100644 frontend/src/lib/components/markdownProse.ts create mode 100644 frontend/src/lib/components/sessions/SessionStatusPopover.svelte create mode 100644 frontend/src/lib/components/sessions/SessionStatusToken.svelte diff --git a/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts b/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts index e553d15f56..8fe28d022a 100644 --- a/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts +++ b/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts @@ -3,7 +3,7 @@ import { tmpdir } from "os"; import { join } from "path"; import type { AIProvider } from "$lib/gen/types.gen"; import { - globalTools, + globalToolsFor, prepareGlobalSystemMessage, prepareGlobalUserMessage, } from "../../../../../frontend/src/lib/components/copilot/chat/global/core"; @@ -43,6 +43,67 @@ const LIVE_EDITOR_ITEM_KINDS = { app: "raw_app", } as const; +// SessionArtifactsStore can't run here (bun has no IndexedDB, nor the compiled $state runes), +// so mirror only its tool-facing shape; its own logic (scoping, race guard) is unit-tested. +const EVAL_SESSION_ID = "eval-session"; +function createEvalArtifactHelpers() { + const items = new Map>(); + let seq = 0; + const store = { + create: async (sessionId: string, input: Record) => { + const now = seq++; + const artifact = { + id: `eval-artifact-${now}`, + sessionId, + chatId: input.chatId, + kind: input.kind ?? "md", + name: input.name, + content: input.content, + createdAt: now, + updatedAt: now, + }; + items.set(artifact.id, artifact); + return artifact; + }, + get: async (id: string) => items.get(id), + update: async ( + id: string, + input: Record, + opts?: { sessionId?: string }, + ) => { + const existing = items.get(id); + if (!existing) return undefined; + if ( + opts?.sessionId !== undefined && + existing.sessionId !== opts.sessionId + ) + return undefined; + const updated = { + ...existing, + name: input.name ?? existing.name, + content: input.content ?? existing.content, + updatedAt: seq++, + }; + items.set(id, updated); + return updated; + }, + remove: async (id: string) => { + items.delete(id); + }, + listForSession: async (sessionId: string) => + [...items.values()].filter((a) => a.sessionId === sessionId), + }; + return { + helpers: { + artifacts: store, + sessionId: EVAL_SESSION_ID, + getChatId: () => "eval-chat", + openArtifact: () => {}, + }, + snapshot: () => [...items.values()], + }; +} + export interface GlobalLiveEditorDraftFixture { type: keyof typeof LIVE_EDITOR_ITEM_KINDS; storagePath?: string; @@ -80,6 +141,8 @@ export interface GlobalEvalOptions { workspaceFixtures?: BenchmarkWorkspaceRunnables; liveEditorDrafts?: GlobalLiveEditorDraftFixture[]; user?: GlobalUserFixture; + // Emulate a session chat (preview tools + session prompt); default false = standalone baseline. + sessionChat?: boolean; model?: string; maxIterations?: number; provider?: AIProvider; @@ -98,7 +161,10 @@ export async function runGlobalEval( (await mkdtemp(join(tmpdir(), "wmill-frontend-global-benchmark-"))); clearGlobalDrafts(workspaceRoot); - registerBenchmarkWorkspaceRunnables(workspaceRoot, options.workspaceFixtures ?? {}); + registerBenchmarkWorkspaceRunnables( + workspaceRoot, + options.workspaceFixtures ?? {}, + ); seedLiveEditorDrafts(workspaceRoot, options.liveEditorDrafts ?? []); try { @@ -107,18 +173,25 @@ export async function runGlobalEval( process.env[DISABLE_ACTIVE_EDITOR_CONTEXT_ENV] !== "1"; // Pass the seeded identity straight to the prompt builder rather than mutating // the process-global `userStore`, so concurrent cases never race on it. + const evalArtifacts = createEvalArtifactHelpers(); const rawResult = await runEval({ userPrompt, - systemMessage: prepareGlobalSystemMessage(undefined, { user: options.user }), + systemMessage: prepareGlobalSystemMessage(undefined, { + user: options.user, + previewTools: options.sessionChat ?? false, + }), userMessage: prepareGlobalUserMessage( userPrompt, [], injectActiveEditorContext ? { workspace: workspaceRoot } : {}, ), - tools: getGlobalEvalTools(), - helpers: {}, + tools: getGlobalEvalTools(options.sessionChat ?? false), + helpers: evalArtifacts.helpers, apiKey, - getOutput: () => collectGlobalDraftState(workspaceRoot), + getOutput: async () => ({ + ...(await collectGlobalDraftState(workspaceRoot)), + artifacts: evalArtifacts.snapshot(), + }), onAssistantMessageStart: options.runContext?.onAssistantMessageStart, onAssistantToken: options.runContext?.onAssistantChunk, onAssistantMessageEnd: options.runContext?.onAssistantMessageEnd, @@ -213,10 +286,15 @@ function clearLiveEditorDrafts( } } -function getGlobalEvalTools(): ProductionTool<{}>[] { +// Gate session-preview tools on sessionChat, as production's globalToolsFor does. +function getGlobalEvalTools(sessionChat: boolean): ProductionTool<{}>[] { const disableSearchApp = process.env[DISABLE_SEARCH_APP_ENV] === "1"; - return (globalTools as ProductionTool<{}>[]) - .filter((tool) => !(disableSearchApp && tool.def.function.name === "search_app")) + return ( + globalToolsFor({ sessionPreview: sessionChat }) as ProductionTool<{}>[] + ) + .filter( + (tool) => !(disableSearchApp && tool.def.function.name === "search_app"), + ) .map((tool) => { if (!MUTATING_GLOBAL_TOOLS.has(tool.def.function.name)) { return tool; diff --git a/ai_evals/cases/global.yaml b/ai_evals/cases/global.yaml index 0f7f2a4717..775cfefa48 100644 --- a/ai_evals/cases/global.yaml +++ b/ai_evals/cases/global.yaml @@ -1055,6 +1055,7 @@ You just opened the runs page for me in the side panel. Close that tab, I'm done looking at it. runtime: maxTurns: 6 + sessionChat: true validate: draftCountExactly: 0 toolExpect: @@ -1509,3 +1510,26 @@ - creates a new shared folder named "analytics" via create_folder - drafts a script placed in that folder (f/analytics/...) returning an ISO timestamp - leaves the script as a draft only + +- id: global-artifact-plan-create + prompt: |- + I'm about to build a customer onboarding flow, but first I want a short written plan I can review and iterate on before any code. + Draft a markdown plan with a title, a one-sentence summary, and three or four bullet steps. + Keep it as something I can reopen and revise later — don't build the flow itself yet. + initial: ai_evals/fixtures/frontend/global/initial/user_admin_empty.json + runtime: + maxTurns: 6 + sessionChat: true + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - create_artifact + forbiddenToolsUsed: + - write_flow + - write_script + - deploy_workspace_item + judgeChecklist: + - saves the plan as a markdown artifact via create_artifact rather than only replying inline + - the artifact content has a title, a one-line summary, and three or four bullet steps for onboarding + - does not create a flow or script draft yet diff --git a/ai_evals/core/types.ts b/ai_evals/core/types.ts index f142bc8d36..83e9a218f3 100644 --- a/ai_evals/core/types.ts +++ b/ai_evals/core/types.ts @@ -31,6 +31,8 @@ export interface EvalCaseRuntimeSpec { maxTurns?: number; backendPreview?: EvalCaseRuntimeBackendPreview; appContext?: EvalCaseRuntimeAppContextSpec; + // Global mode: run as a session chat (preview tools + session prompt) vs the standalone chat. + sessionChat?: boolean; } export interface FlowValidationSpec { diff --git a/ai_evals/modes/global.ts b/ai_evals/modes/global.ts index 050a4caad9..5628c21d5d 100644 --- a/ai_evals/modes/global.ts +++ b/ai_evals/modes/global.ts @@ -41,6 +41,7 @@ export function createGlobalModeRunner( workspaceFixtures: initial?.workspace, liveEditorDrafts: initial?.liveEditorDrafts, user: initial?.user, + sessionChat: context.evalCase?.runtime?.sessionChat, maxIterations: context.evalCase?.runtime?.maxTurns, provider: modelConfig.provider, model: modelConfig.model, diff --git a/frontend/src/lib/components/chat/ChatMessage.svelte b/frontend/src/lib/components/chat/ChatMessage.svelte index f811abd0a2..181ca68700 100644 --- a/frontend/src/lib/components/chat/ChatMessage.svelte +++ b/frontend/src/lib/components/chat/ChatMessage.svelte @@ -6,6 +6,7 @@ import LinkRenderer from '$lib/components/copilot/chat/LinkRenderer.svelte' import DisplayResult from '$lib/components/DisplayResult.svelte' import { workspaceStore } from '$lib/stores' + import { markdownProse } from '$lib/components/markdownProse' interface Props { role: 'user' | 'assistant' | 'tool' | 'system' @@ -103,7 +104,7 @@ {/if} {/if} -
+
void + openArtifact?: (artifactId: string, name: string) => void + closeArtifact?: (artifactId: string) => void loading = $state(false) currentReply = $state('') currentReasoning = $state('') @@ -1465,7 +1470,13 @@ export class AIChatManager { // permission gating. The global side-panel chat follows the live navigation // workspace instead, so leave it unset there — allowedOpenPages reads the store. ...(this.isSessionChat - ? { sessionId: this.sessionId, operatingWorkspace: this.operatingWorkspace } + ? { + sessionId: this.sessionId, + operatingWorkspace: this.operatingWorkspace, + artifacts: this.artifacts, + getChatId: () => this.historyManager.getCurrentChatId(), + openArtifact: this.openArtifact + } : {}), testActiveFlow: async (args?: Record) => this.flowAiChatHelpers?.testFlow(args), attachedFiles: this.attachedFiles, @@ -1491,6 +1502,7 @@ export class AIChatManager { this.helpers = baseHelpers } this.systemMessage = systemMessage + this.syncArtifactsSession() } refreshGlobalSkills = async (workspace = this.operatingWorkspace ?? '') => { @@ -2600,6 +2612,7 @@ export class AIChatManager { // session, so "New chat" must clear them — otherwise the next, unrelated conversation // would still get the previous file roster and could read/search it. if (!this.isSessionChat) this.attachedFiles.clear() + this.syncArtifactsSession() this.onChatRotated?.(this.historyManager.getCurrentChatId()) } @@ -2637,10 +2650,15 @@ export class AIChatManager { if (this.backgroundJobs.length > 0) this.backgroundJobs = [...this.backgroundJobs] this.#ensureJobPoller() this.#automaticScroll = true + this.syncArtifactsSession() this.onChatRotated?.(id) } } + private syncArtifactsSession = () => { + void this.artifacts.setSession(this.isSessionChat ? this.sessionId : undefined) + } + get automaticScroll() { return this.#automaticScroll } diff --git a/frontend/src/lib/components/copilot/chat/AssistantMessage.svelte b/frontend/src/lib/components/copilot/chat/AssistantMessage.svelte index 80cd446d13..0f3f261f26 100644 --- a/frontend/src/lib/components/copilot/chat/AssistantMessage.svelte +++ b/frontend/src/lib/components/copilot/chat/AssistantMessage.svelte @@ -13,6 +13,7 @@ remarkWindmillPaths, workspaceItemRegistry } from './workspaceItems.svelte' + import { markdownProse } from '$lib/components/markdownProse' interface Props { message: DisplayMessage @@ -97,11 +98,7 @@ {#if reasoningExpanded}
@@ -110,14 +107,7 @@ {/if} {#if message.content} -
+
{/if} diff --git a/frontend/src/lib/components/copilot/chat/JobsSegment.svelte b/frontend/src/lib/components/copilot/chat/JobsSegment.svelte index 0bcb315bda..9a6d505b8a 100644 --- a/frontend/src/lib/components/copilot/chat/JobsSegment.svelte +++ b/frontend/src/lib/components/copilot/chat/JobsSegment.svelte @@ -3,11 +3,11 @@ import Badge from '$lib/components/common/badge/Badge.svelte' import Modal from '$lib/components/common/modal/Modal.svelte' import Portal from '$lib/components/Portal.svelte' - import Popover from '$lib/components/meltComponents/Popover.svelte' + import SessionStatusPopover from '$lib/components/sessions/SessionStatusPopover.svelte' import { zIndexes } from '$lib/zIndexes' import JobStatusIcon from '$lib/components/runs/JobStatusIcon.svelte' import FlowStatusWaitingForEvents from '$lib/components/FlowStatusWaitingForEvents.svelte' - import { ChevronUp, ExternalLink, Hourglass, ThumbsUp, TimerOff } from 'lucide-svelte' + import { ChevronUp, Hourglass, ThumbsUp, TimerOff } from 'lucide-svelte' import { base } from '$lib/base' import { slide } from 'svelte/transition' import { JobService, type Job } from '$lib/gen' @@ -15,6 +15,7 @@ import { sendUserToast } from '$lib/toast' import { getAiChatManager } from './aiChatManagerContext' import { deriveChatJobStatus, type ChatJob, type ChatJobStatus } from './shared' + import { TOKEN_TRIGGER_CLASS } from '$lib/components/sessions/SessionStatusToken.svelte' // The "Jobs" segment of the session bar: a compact status chip that summarizes // the background jobs the chat started, opening a popover with the full list @@ -104,12 +105,27 @@ danger: false } if (failureCount > 0) - return { dot: dotClass('failure'), pulse: false, text: `${jobs.length}`, danger: true } + return { + dot: dotClass('failure'), + pulse: false, + text: `${failureCount} failed`, + danger: true + } // All terminal, none failed: green if anything actually succeeded, else gray // (only canceled jobs left — a cancel isn't a success, so don't show green). if (successCount > 0) - return { dot: dotClass('success'), pulse: false, text: `${jobs.length}`, danger: false } - return { dot: dotClass('canceled'), pulse: false, text: `${jobs.length}`, danger: false } + return { + dot: dotClass('success'), + pulse: false, + text: `${successCount} succeeded`, + danger: false + } + return { + dot: dotClass('canceled'), + pulse: false, + text: `${jobs.length} canceled`, + danger: false + } } ) @@ -191,7 +207,6 @@ } // --- Popover open state + auto-open on approval --- - let popover: Popover | undefined = $state() let open = $state(false) // A job entering the approval state needs attention, so open the popover to @@ -200,7 +215,7 @@ let prevApprovalCount = 0 $effect(() => { const count = approvalCount - if (count > prevApprovalCount) popover?.open() + if (count > prevApprovalCount) open = true prevApprovalCount = count }) @@ -256,26 +271,33 @@ even when the chip's visual change alone wouldn't be. role="status" already implies aria-live="polite". -->
{announcement}
- job.jobId} + rowTitle={(job) => job.label} + onPick={openRun} + placement={standalone ? 'top-end' : 'top-start'} usePointerDownOutside - class={standalone + closeOnOtherPopoverOpen={!standalone} + triggerClass={standalone ? 'flex h-[34px] w-full items-center rounded-md border bg-surface-tertiary px-3 hover:bg-surface-hover' - : 'flex h-full items-center px-3.5 hover:bg-surface-hover'} - triggerAttrs={{ 'aria-label': ariaLabel, 'aria-haspopup': 'dialog' }} - contentClasses="!bg-surface" + : TOKEN_TRIGGER_CLASS} + maxHeightClass={standalone ? 'max-h-[50vh]' : 'max-h-[min(12rem,50vh)]'} > - {#snippet trigger()} + {#snippet customTrigger()} - Jobs + {#if standalone} + Jobs + {/if} @@ -294,62 +316,42 @@ {/if} {/snippet} - {#snippet content()} -
-
Jobs this session
-
- {#each sortedJobs as job (job.jobId)} -
- {#if job.status === 'queued' || !job.job} - - - {:else} - - {/if} - {job.label} - {elapsedLabel(job)} -
- {#if job.status === 'suspended'} - - {/if} - {#if !isTerminal(job.status)} - - {/if} -
-
- {/each} -
-
+ {#snippet row(job)} + {#if job.status === 'queued' || !job.job} + + + {:else} + + {/if} + {job.label} + {elapsedLabel(job)} {/snippet} -
+ {#snippet actions(job)} + {#if job.status === 'suspended'} + + {/if} + {#if !isTerminal(job.status)} + + {/if} + {/snippet} + + + {#if canPreview} + (showSource = v === 'source')} + > + {#snippet children({ item })} + + + {/snippet} + + {/if} +
+
+ +
+ {#if source} + + {#key `${artifact.id}:${artifact.updatedAt}`} + + {/key} + {:else} + +
+
+ +
+ {/if} +
+
diff --git a/frontend/src/lib/components/copilot/chat/artifacts/ArtifactsSegment.svelte b/frontend/src/lib/components/copilot/chat/artifacts/ArtifactsSegment.svelte new file mode 100644 index 0000000000..96412d9b3c --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/artifacts/ArtifactsSegment.svelte @@ -0,0 +1,64 @@ + + + a.id} + rowTitle={(a) => a.name} + onPick={(a: PersistedArtifact) => aiChatManager.openArtifact?.(a.id, a.name)} +> + {#snippet row(a)} + {a.name} + + {a.kind} + + + + + {/snippet} + {#snippet actions(a)} +