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) <noreply@anthropic.com>

* feat: unify session composer edits/artifacts/jobs into a status line

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: add an artifacts section to the session preview picker

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: share markdown prose presets and restyle the artifact viewer

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor: unify session status popovers into one keyboard-navigable shell

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: reset first-block top margin in all markdown prose presets

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: open the preview picker on the artifacts branch for an active artifact

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: keep artifact picker scope independent of branch hydration state

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Guilhem Lemouel <guilhemlemouel@gmail.com>
This commit is contained in:
AlexRV12
2026-07-16 11:17:20 +02:00
committed by GitHub
parent a935d06c8e
commit 0ea570570e
34 changed files with 1981 additions and 202 deletions
@@ -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<string, Record<string, unknown>>();
let seq = 0;
const store = {
create: async (sessionId: string, input: Record<string, any>) => {
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<string, any>,
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;
+24
View File
@@ -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
+2
View File
@@ -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 {
+1
View File
@@ -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,
@@ -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 @@
<AlertTriangle class="w-3.5 h-3.5 text-red-500" />
{/if}
{/if}
<div class="prose prose-sm dark:prose-invert break-words prose-headings:!text-base">
<div class={markdownProse.sm}>
<Markdown
md={content}
plugins={[
@@ -110,6 +110,7 @@ import {
import { scopedKey, onUserChange, migrateLegacyLocalStorage } from '$lib/userScopedStorage'
import { getLocalSetting, storeLocalSetting } from '$lib/utils'
import { AttachedFilesStore } from './files/attachedFiles.svelte'
import { SessionArtifactsStore } from './artifacts/artifactsState.svelte'
import { appendAttachedFilesRoster } from './files/fileTools'
// SSR and users who prefer reduced motion get no typewriter pacing.
@@ -272,6 +273,8 @@ export class AIChatManager {
historyManager = new HistoryManager()
/** Files the user attached to the current GLOBAL-mode conversation. */
attachedFiles = new AttachedFilesStore()
/** Markdown artifacts the copilot created for the current session. */
artifacts = new SessionArtifactsStore()
abortController: AbortController | undefined = undefined
inlineAbortController: AbortController | undefined = undefined
// Flag to skip Responses API if it's not available (e.g., Azure region doesn't support it)
@@ -317,6 +320,8 @@ export class AIChatManager {
* undefined in the global side-panel chat, where the tray falls back to opening
* the run in a new browser tab. */
openRunInPreview?: (a: { jobId: string; workspace: string; label: string }) => void
openArtifact?: (artifactId: string, name: string) => void
closeArtifact?: (artifactId: string) => void
loading = $state<boolean>(false)
currentReply = $state<string>('')
currentReasoning = $state<string>('')
@@ -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<string, any>) => 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
}
@@ -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}
<div
transition:slide={{ duration: 150 }}
class="p-2 bg-surface text-secondary break-words prose prose-sm dark:prose-invert max-w-full leading-snug
prose-p:text-2xs prose-li:text-2xs prose-code:text-2xs prose-pre:text-2xs prose-ul:!pl-5
prose-headings:font-medium prose-headings:text-secondary prose-headings:mt-2 prose-headings:mb-1
prose-h1:text-2xs prose-h2:text-2xs prose-h3:text-2xs prose-h4:text-2xs prose-h5:text-2xs prose-h6:text-2xs
prose-strong:text-secondary"
class="p-2 bg-surface text-secondary {markdownProse.xs}"
>
<Markdown md={reasoning} plugins={[gfmPlugin()]} />
</div>
@@ -110,14 +107,7 @@
{/if}
{#if message.content}
<div
class="prose prose-sm dark:prose-invert w-full max-w-full leading-snug space-y-2 prose-ul:!pl-6
prose-p:text-xs prose-li:text-xs prose-code:text-xs prose-pre:text-xs
prose-code:break-words prose-a:break-words
prose-headings:font-medium prose-headings:text-emphasis prose-headings:mt-3 prose-headings:mb-1
prose-h1:text-sm prose-h2:text-xs prose-h3:text-xs prose-h4:text-xs prose-h5:text-xs prose-h6:text-xs
prose-table:block prose-table:max-w-full prose-table:overflow-x-auto prose-table:text-xs"
>
<div class="w-full space-y-2 {markdownProse.sm}">
<Markdown md={message.content} {plugins} />
</div>
{/if}
@@ -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". -->
<div class="sr-only" role="status">{announcement}</div>
<Popover
bind:this={popover}
bind:isOpen={open}
placement="top-end"
enableFlyTransition
<SessionStatusPopover
bind:open
label="Jobs"
{ariaLabel}
title="Jobs this session"
items={sortedJobs}
itemKey={(job) => 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()}
<span class="flex min-w-0 items-center gap-2 text-xs">
<span class="shrink-0 font-normal text-primary">Jobs</span>
{#if standalone}
<span class="shrink-0 font-normal text-primary">Jobs</span>
{/if}
<span
class={`size-[7px] shrink-0 rounded-full ${segment.dot} ${segment.pulse ? 'motion-safe:animate-pulse' : ''}`}
></span>
<span
class={`min-w-0 truncate font-normal ${runningJob ? 'text-2xs' : 'text-xs'} ${segment.danger ? 'text-red-500' : 'text-primary'}`}
class={`min-w-0 truncate font-normal ${runningJob ? 'text-2xs' : 'text-xs'} ${segment.danger ? 'text-red-500' : standalone ? 'text-primary' : 'text-secondary'}`}
title={runningJob?.label ?? undefined}
dir={runningJob ? 'rtl' : undefined}
>
@@ -294,62 +316,42 @@
{/if}
<ChevronUp
size={14}
class={`shrink-0 text-secondary transition-transform duration-150 ${open ? 'rotate-180' : ''}`}
class={`shrink-0 ${standalone ? 'text-secondary' : 'text-tertiary'} transition-transform duration-150 ${open ? 'rotate-180' : ''}`}
/>
</span>
{/snippet}
{#snippet content()}
<div class="flex max-h-[50vh] w-80 flex-col text-xs">
<div class="border-b px-3 py-2 text-tertiary">Jobs this session</div>
<div class="min-h-0 flex-1 overflow-y-auto py-1">
{#each sortedJobs as job (job.jobId)}
<div class="flex items-center gap-2.5 px-3 py-1.5">
{#if job.status === 'queued' || !job.job}
<!-- Queued: match the job detail page's orange badge (JobStatusIcon's
default queued badge is gray). Also covers the pre-first-fetch state. -->
<Badge color="orange" baseClass="!px-1.5" title="Queued"
><Hourglass size={13} /></Badge
>
{:else}
<JobStatusIcon job={job.job} />
{/if}
<span class="min-w-0 grow truncate text-secondary" title={job.label}>{job.label}</span
>
<span class="shrink-0 tabular-nums text-tertiary">{elapsedLabel(job)}</span>
<div class="flex shrink-0 items-center gap-1.5">
{#if job.status === 'suspended'}
<Button
unifiedSize="xs"
variant="accent"
startIcon={{ icon: ThumbsUp }}
on:click={() => openApproval(job)}>Approve</Button
>
{/if}
{#if !isTerminal(job.status)}
<Button
unifiedSize="xs"
variant="accent"
destructive
startIcon={{ icon: TimerOff }}
on:click={() => aiChatManager.cancelJob(job.jobId)}>Cancel</Button
>
{/if}
<Button
iconOnly
unifiedSize="xs"
variant="subtle"
startIcon={{ icon: ExternalLink }}
title="Open the run"
on:click={() => openRun(job)}
/>
</div>
</div>
{/each}
</div>
</div>
{#snippet row(job)}
{#if job.status === 'queued' || !job.job}
<!-- Queued: match the job detail page's orange badge (JobStatusIcon's
default queued badge is gray). Also covers the pre-first-fetch state. -->
<Badge color="orange" baseClass="!px-1.5" title="Queued"><Hourglass size={13} /></Badge>
{:else}
<JobStatusIcon job={job.job} />
{/if}
<span class="min-w-0 grow truncate text-primary">{job.label}</span>
<span class="shrink-0 tabular-nums text-tertiary">{elapsedLabel(job)}</span>
{/snippet}
</Popover>
{#snippet actions(job)}
{#if job.status === 'suspended'}
<Button
unifiedSize="xs"
variant="accent"
startIcon={{ icon: ThumbsUp }}
on:click={() => openApproval(job)}>Approve</Button
>
{/if}
{#if !isTerminal(job.status)}
<Button
unifiedSize="xs"
variant="accent"
destructive
startIcon={{ icon: TimerOff }}
on:click={() => aiChatManager.cancelJob(job.jobId)}>Cancel</Button
>
{/if}
{/snippet}
</SessionStatusPopover>
<!-- Portal to <body> + a z-index above the editor: opened from deep in the
sessions chat column, the modal would otherwise be trapped below the
@@ -0,0 +1,97 @@
<script lang="ts">
import Markdown from 'svelte-exmarkdown'
import { gfmPlugin } from 'svelte-exmarkdown/gfm'
import { Code, Eye, FileText, Copy, Check, Download } from 'lucide-svelte'
import { Button } from '$lib/components/common'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import SimpleEditor from '$lib/components/SimpleEditor.svelte'
import { copyToClipboard, download } from '$lib/utils'
import CodeDisplay from '../script/CodeDisplay.svelte'
import LinkRenderer from '../LinkRenderer.svelte'
import { artifactFilename, artifactMimeType, type PersistedArtifact } from './artifactsDB'
import { markdownProse } from '$lib/components/markdownProse'
interface Props {
artifact: PersistedArtifact
}
let { artifact }: Props = $props()
// Markdown is the only rendered kind in v1; anything else shows source only.
const canPreview = $derived(artifact.kind === 'md')
let showSource = $state(false)
const source = $derived(!canPreview || showSource)
let copied = $state(false)
async function copyRaw() {
if (!(await copyToClipboard(artifact.content))) return
copied = true
setTimeout(() => (copied = false), 1500)
}
function downloadFile() {
download(artifactFilename(artifact), artifact.content, artifactMimeType(artifact.kind))
}
const plugins = [gfmPlugin(), { renderer: { pre: CodeDisplay, a: LinkRenderer } }]
</script>
<div class="flex flex-col h-full bg-surface-tertiary">
<div class="flex items-center justify-between gap-2 px-8 py-2">
<div class="flex items-center gap-1.5 min-w-0 flex-1">
<FileText size={14} class="shrink-0 text-secondary" />
<span class="truncate text-xs font-normal text-emphasis" title={artifact.name}>
{artifact.name}
</span>
</div>
<div class="flex items-center gap-2 shrink-0">
<!-- Copy raw markdown, with a dropdown for the download-as-file variant. -->
<Button
unifiedSize="sm"
variant="default"
startIcon={{ icon: copied ? Check : Copy }}
onClick={copyRaw}
title="Copy raw markdown"
dropdownItems={[{ label: 'Download as .md', icon: Download, onClick: downloadFile }]}
>
{copied ? 'Copied' : 'Copy'}
</Button>
{#if canPreview}
<ToggleButtonGroup
noWFull
selected={showSource ? 'source' : 'preview'}
onSelected={(v) => (showSource = v === 'source')}
>
{#snippet children({ item })}
<ToggleButton {item} value="preview" icon={Eye} iconOnly tooltip="Preview" size="sm" />
<ToggleButton
{item}
value="source"
icon={Code}
iconOnly
tooltip="View source"
size="sm"
/>
{/snippet}
</ToggleButtonGroup>
{/if}
</div>
</div>
<div class="flex-1 min-h-0 overflow-auto px-8">
{#if source}
<!-- key: SimpleEditor reads `code` only on init; remount on id or content change. -->
{#key `${artifact.id}:${artifact.updatedAt}`}
<SimpleEditor lang="markdown" code={artifact.content} readOnly class="h-full" />
{/key}
{:else}
<!-- Pinned under the header, fades scrolled-under content instead of hard-clipping it.
The negative margin cancels its flow height so it overlays instead of pushing. -->
<div class="sticky top-0 z-10 h-4 -mb-4 bg-gradient-to-b from-surface-tertiary to-transparent"
></div>
<div class="pb-4 pt-2 {markdownProse.doc}">
<Markdown md={artifact.content} {plugins} />
</div>
{/if}
</div>
</div>
@@ -0,0 +1,64 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import TimeAgo from '$lib/components/TimeAgo.svelte'
import SessionStatusPopover from '$lib/components/sessions/SessionStatusPopover.svelte'
import { Download, Trash2 } from 'lucide-svelte'
import { download, displayDate } from '$lib/utils'
import { getAiChatManager } from '../aiChatManagerContext'
import { artifactFilename, artifactMimeType, type PersistedArtifact } from './artifactsDB'
const aiChatManager = getAiChatManager()
const artifacts = $derived(aiChatManager.artifacts.artifacts)
const label = $derived(`${artifacts.length} artifact${artifacts.length === 1 ? '' : 's'}`)
// Empty-at-0 gating is owned by the parent (SessionChangesBar) so the status
// line's separators stay correct; this renders unconditionally.
let open = $state(false)
</script>
<SessionStatusPopover
bind:open
{label}
title="Artifacts this session"
items={artifacts}
itemKey={(a) => a.id}
rowTitle={(a) => a.name}
onPick={(a: PersistedArtifact) => aiChatManager.openArtifact?.(a.id, a.name)}
>
{#snippet row(a)}
<span class="min-w-0 flex-1 truncate font-normal text-primary">{a.name}</span>
<span
class="shrink-0 rounded bg-surface-secondary px-1 py-0.5 text-2xs font-normal uppercase text-tertiary"
>
{a.kind}
</span>
<span
class="min-w-[4.5rem] shrink-0 text-right text-2xs font-normal text-hint"
title={displayDate(new Date(a.updatedAt))}
>
<TimeAgo date={new Date(a.updatedAt).toISOString()} noSeconds />
</span>
{/snippet}
{#snippet actions(a)}
<Button
unifiedSize="xs"
variant="subtle"
iconOnly
title="Download"
startIcon={{ icon: Download }}
onClick={() => download(artifactFilename(a), a.content, artifactMimeType(a.kind))}
/>
<Button
unifiedSize="xs"
destructive
variant="subtle"
iconOnly
title="Delete"
startIcon={{ icon: Trash2 }}
onClick={() => {
aiChatManager.closeArtifact?.(a.id)
void aiChatManager.artifacts.remove(a.id)
}}
/>
{/snippet}
</SessionStatusPopover>
@@ -0,0 +1,162 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { IDBFactory } from 'fake-indexeddb'
import type { Tool } from '../shared'
// The real ../shared pulls the whole component/monaco graph, which the node test env can't
// load. artifactTools only needs createToolDef to stamp the function name (as datatableTools.test).
vi.mock('../shared', () => ({
createToolDef: (_schema: unknown, name: string, description: string) => ({
type: 'function',
function: { name, description, parameters: {} }
})
}))
// The user-scoping subscription is BROWSER-gated; the node test env reports false.
vi.mock('esm-env', async (orig) => ({
...(await orig<typeof import('esm-env')>()),
BROWSER: true
}))
// Stub $lib/stores + $lib/utils (userScopedStorage's only deps here) to keep their heavy
// svelte/app-store graphs out of the per-test cold transform.
vi.mock('$lib/stores', async () => {
const { writable } = await import('svelte/store')
return { userStore: writable(undefined) }
})
vi.mock('$lib/utils', () => ({ getLocalSetting: () => undefined, storeLocalSetting: () => {} }))
// Reset the memoised DB handle and install a fresh store per test (see artifactsDB tests).
// sessionId has no default: passing undefined must stay undefined (the no-session case), not
// fall back to 's1' as a defaulted parameter would. The DB is namespaced by email, so seed a user.
async function fresh(sessionId: string | undefined) {
vi.resetModules()
;(globalThis as any).indexedDB = new IDBFactory()
;(await import('$lib/stores')).userStore.set({ email: 'a@x.com' } as never)
const { SessionArtifactsStore } = await import('./artifactsState.svelte')
const { artifactTools } = await import('./artifactTools')
const dbMod = await import('./artifactsDB')
const store = new SessionArtifactsStore()
const statuses: any[] = []
const opened: Array<[string, string]> = []
const helpers = {
artifacts: store,
sessionId,
getChatId: () => 'c1',
openArtifact: (id: string, name: string) => opened.push([id, name])
}
const byName = Object.fromEntries(artifactTools.map((t) => [t.def.function.name, t])) as Record<
string,
Tool<{}>
>
const call = (name: string, args: any) =>
byName[name].fn({
args,
workspace: 'w',
helpers,
toolId: 't',
toolCallbacks: { setToolStatus: (_id: string, m: any) => statuses.push(m) } as any
})
return { call, store, dbMod, statuses, opened }
}
let ctx: Awaited<ReturnType<typeof fresh>>
beforeEach(async () => {
ctx = await fresh('s1')
})
describe('artifact tools', () => {
it('create_artifact persists (session-scoped, chat provenance) and opens the preview', async () => {
const res = JSON.parse(await ctx.call('create_artifact', { name: 'Plan', content: '# hi' }))
expect(res.success).toBe(true)
expect(res.name).toBe('Plan')
const stored = await ctx.dbMod.getArtifact(res.id)
expect(stored).toMatchObject({
name: 'Plan',
content: '# hi',
kind: 'md',
sessionId: 's1',
chatId: 'c1'
})
expect(ctx.opened).toEqual([[res.id, 'Plan']])
})
it('list_artifacts returns id/name/kind without content', async () => {
const a = JSON.parse(await ctx.call('create_artifact', { name: 'A', content: 'a' }))
const b = JSON.parse(await ctx.call('create_artifact', { name: 'B', content: 'b' }))
const list = JSON.parse(await ctx.call('list_artifacts', {}))
expect(list.map((x: any) => x.id).sort()).toEqual([a.id, b.id].sort())
expect(list.find((x: any) => x.id === b.id)).toEqual({ id: b.id, name: 'B', kind: 'md' })
expect(list[0]).not.toHaveProperty('content')
})
it('read_artifact returns the full content', async () => {
const a = JSON.parse(await ctx.call('create_artifact', { name: 'A', content: 'body' }))
const read = JSON.parse(await ctx.call('read_artifact', { id: a.id }))
expect(read).toMatchObject({ id: a.id, name: 'A', kind: 'md', content: 'body' })
})
it('list_artifacts still returns a create whose persist was swallowed', async () => {
await ctx.store.setSession('s1') // load the session so create reflects in the in-memory list
const created = JSON.parse(await ctx.call('create_artifact', { name: 'Ghost', content: 'x' }))
await ctx.dbMod.deleteArtifact(created.id) // mimic a quota-swallowed persist: gone from the DB
expect(await ctx.dbMod.getArtifact(created.id)).toBeUndefined()
// read and list both stay consistent via the in-memory fallback.
const list = JSON.parse(await ctx.call('list_artifacts', {}))
expect(list.map((x: any) => x.id)).toContain(created.id)
})
it('update_artifact overwrites content and persists', async () => {
const a = JSON.parse(await ctx.call('create_artifact', { name: 'A', content: 'v1' }))
const res = JSON.parse(await ctx.call('update_artifact', { id: a.id, content: 'v2' }))
expect(res.success).toBe(true)
expect((await ctx.dbMod.getArtifact(a.id))?.content).toBe('v2')
})
it('update_artifact reports a missing id', async () => {
const res = JSON.parse(await ctx.call('update_artifact', { id: 'nope', content: 'x' }))
expect(res.success).toBe(false)
expect(res.error).toMatch(/No artifact/)
})
it('read_artifact reports a missing id', async () => {
const res = JSON.parse(await ctx.call('read_artifact', { id: 'nope' }))
expect(res.success).toBe(false)
})
it('rejects content over the size cap without persisting', async () => {
const huge = 'x'.repeat(256 * 1024 + 1)
const res = JSON.parse(await ctx.call('create_artifact', { name: 'Big', content: huge }))
expect(res.success).toBe(false)
expect(res.error).toMatch(/too large/)
expect(await ctx.dbMod.listArtifactsForSession('s1')).toEqual([])
})
it('reports unavailable when there is no session', async () => {
const noSession = await fresh(undefined)
for (const name of ['create_artifact', 'list_artifacts', 'update_artifact', 'read_artifact']) {
const res = JSON.parse(await noSession.call(name, { id: 'x', name: 'A', content: 'a' }))
expect(res.success).toBe(false)
expect(res.error).toMatch(/inside an AI session/)
}
})
it('update_artifact and read_artifact ignore ids from another session', async () => {
// Belongs to s2; the tools resolve session 's1'.
await ctx.dbMod.putArtifact({
id: 'other',
sessionId: 's2',
chatId: 'c2',
kind: 'md',
name: 'Other',
content: 'secret',
createdAt: 0,
updatedAt: 0
})
const read = JSON.parse(await ctx.call('read_artifact', { id: 'other' }))
expect(read.success).toBe(false)
const updated = JSON.parse(await ctx.call('update_artifact', { id: 'other', content: 'x' }))
expect(updated.success).toBe(false)
// The other session's content is untouched.
expect((await ctx.dbMod.getArtifact('other'))?.content).toBe('secret')
})
})
@@ -0,0 +1,163 @@
import { z } from 'zod'
import { createToolDef, type Tool } from '../shared'
import type { SessionArtifactsStore } from './artifactsState.svelte'
// The subset of GlobalToolHelpers these tools read. Kept local (not imported from
// global/core) so the tools don't pull the whole global tool module — which would be a
// circular import, since global/core registers these tools.
type ArtifactToolHelpers = {
artifacts?: SessionArtifactsStore
sessionId?: string
getChatId?: () => string | undefined
openArtifact?: (artifactId: string, name: string) => void
}
const MAX_ARTIFACT_BYTES = 256 * 1024
const createArtifactSchema = z.object({
name: z.string().describe('Short display title for the artifact.'),
content: z.string().describe('Full markdown content of the artifact.')
})
const updateArtifactSchema = z.object({
id: z.string().describe('Id of the artifact to update, from create_artifact or list_artifacts.'),
content: z.string().describe('New full markdown content, replacing the previous content.'),
name: z.string().optional().describe('New display title. Omit to keep the current one.')
})
const listArtifactsSchema = z.object({})
const readArtifactSchema = z.object({
id: z.string().describe('Id of the artifact to read.')
})
function tooLarge(content: string): string | undefined {
const bytes = new TextEncoder().encode(content).length
if (bytes <= MAX_ARTIFACT_BYTES) return undefined
return `Content is too large (${bytes} bytes, limit ${MAX_ARTIFACT_BYTES}). Shorten or split it.`
}
const UNAVAILABLE = 'Artifacts are only available inside an AI session.'
export const artifactTools: Tool<{}>[] = [
{
def: createToolDef(
createArtifactSchema,
'create_artifact',
'Create a markdown artifact in the current session.'
),
showDetails: true,
fn: async ({ args, toolId, toolCallbacks, helpers }) => {
const parsed = createArtifactSchema.parse(args)
const h = helpers as ArtifactToolHelpers
const sessionId = h.sessionId
if (!h.artifacts || !sessionId) {
toolCallbacks.setToolStatus(toolId, { content: UNAVAILABLE, error: UNAVAILABLE })
return JSON.stringify({ success: false, error: UNAVAILABLE })
}
const sizeError = tooLarge(parsed.content)
if (sizeError) {
toolCallbacks.setToolStatus(toolId, { content: sizeError, error: sizeError })
return JSON.stringify({ success: false, error: sizeError })
}
const artifact = await h.artifacts.create(sessionId, {
name: parsed.name,
content: parsed.content,
kind: 'md',
chatId: h.getChatId?.()
})
h.openArtifact?.(artifact.id, artifact.name)
toolCallbacks.setToolStatus(toolId, { content: `Created artifact "${artifact.name}"` })
return JSON.stringify({ success: true, id: artifact.id, name: artifact.name })
}
},
{
def: createToolDef(
updateArtifactSchema,
'update_artifact',
'Overwrite an existing markdown artifact by id.'
),
showDetails: true,
fn: async ({ args, toolId, toolCallbacks, helpers }) => {
const parsed = updateArtifactSchema.parse(args)
const h = helpers as ArtifactToolHelpers
const sessionId = h.sessionId
if (!h.artifacts || !sessionId) {
toolCallbacks.setToolStatus(toolId, { content: UNAVAILABLE, error: UNAVAILABLE })
return JSON.stringify({ success: false, error: UNAVAILABLE })
}
const sizeError = tooLarge(parsed.content)
if (sizeError) {
toolCallbacks.setToolStatus(toolId, { content: sizeError, error: sizeError })
return JSON.stringify({ success: false, error: sizeError })
}
const updated = await h.artifacts.update(
parsed.id,
{ content: parsed.content, name: parsed.name },
{ sessionId }
)
if (!updated) {
const error = `No artifact found with id "${parsed.id}".`
toolCallbacks.setToolStatus(toolId, { content: error, error })
return JSON.stringify({ success: false, error })
}
h.openArtifact?.(updated.id, updated.name)
toolCallbacks.setToolStatus(toolId, { content: `Updated artifact "${updated.name}"` })
return JSON.stringify({ success: true, id: updated.id, name: updated.name })
}
},
{
def: createToolDef(
listArtifactsSchema,
'list_artifacts',
"List the current session's artifacts (id, name, kind)."
),
fn: async ({ toolId, toolCallbacks, helpers }) => {
const h = helpers as ArtifactToolHelpers
const sessionId = h.sessionId
if (!h.artifacts || !sessionId) {
toolCallbacks.setToolStatus(toolId, { content: UNAVAILABLE, error: UNAVAILABLE })
return JSON.stringify({ success: false, error: UNAVAILABLE })
}
const items = await h.artifacts.listForSession(sessionId)
toolCallbacks.setToolStatus(toolId, {
content: `Listed ${items.length} artifact${items.length === 1 ? '' : 's'}`
})
return JSON.stringify(
items
.sort((a, b) => b.updatedAt - a.updatedAt)
.map((a) => ({ id: a.id, name: a.name, kind: a.kind }))
)
}
},
{
def: createToolDef(
readArtifactSchema,
'read_artifact',
"Read an artifact's full markdown content by id."
),
fn: async ({ args, toolId, toolCallbacks, helpers }) => {
const parsed = readArtifactSchema.parse(args)
const h = helpers as ArtifactToolHelpers
const sessionId = h.sessionId
if (!h.artifacts || !sessionId) {
toolCallbacks.setToolStatus(toolId, { content: UNAVAILABLE, error: UNAVAILABLE })
return JSON.stringify({ success: false, error: UNAVAILABLE })
}
const artifact = await h.artifacts.get(parsed.id)
// An id from another session reads as absent — list_artifacts is session-scoped.
if (!artifact || artifact.sessionId !== sessionId) {
const error = `No artifact found with id "${parsed.id}".`
toolCallbacks.setToolStatus(toolId, { content: error, error })
return JSON.stringify({ success: false, error })
}
toolCallbacks.setToolStatus(toolId, { content: `Read artifact "${artifact.name}"` })
return JSON.stringify({
id: artifact.id,
name: artifact.name,
kind: artifact.kind,
content: artifact.content
})
}
}
]
@@ -0,0 +1,145 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { IDBFactory } from 'fake-indexeddb'
import type { PersistedArtifact } from './artifactsDB'
// The user-scoping subscription is BROWSER-gated; the node test env reports false.
vi.mock('esm-env', async (orig) => ({
...(await orig<typeof import('esm-env')>()),
BROWSER: true
}))
// Stub $lib/stores + $lib/utils (userScopedStorage's only deps here) to keep their heavy
// svelte/app-store graphs out of the per-test cold transform.
vi.mock('$lib/stores', async () => {
const { writable } = await import('svelte/store')
return { userStore: writable(undefined) }
})
vi.mock('$lib/utils', () => ({ getLocalSetting: () => undefined, storeLocalSetting: () => {} }))
function artifact(over: Partial<PersistedArtifact> = {}): PersistedArtifact {
return {
id: 'a1',
sessionId: 's1',
chatId: 'c1',
kind: 'md',
name: 'Doc',
content: '# hi',
createdAt: 0,
updatedAt: 0,
...over
}
}
// artifactsDB memoises its DB handle at module scope. Reset the module and install a
// fresh IDBFactory before each test, then import through this helper so every test
// opens its own empty database. The DB is namespaced by email, so seed a user.
let userStore: { set: (v: unknown) => void }
async function freshDb() {
vi.resetModules()
;(globalThis as any).indexedDB = new IDBFactory()
userStore = (await import('$lib/stores')).userStore as never
userStore.set({ email: 'a@x.com' })
return await import('./artifactsDB')
}
let db: Awaited<ReturnType<typeof freshDb>>
beforeEach(async () => {
db = await freshDb()
})
describe('artifactsDB', () => {
it('derives filename and mime type from the artifact kind', () => {
expect(db.artifactFilename({ name: 'Plan', kind: 'md' })).toBe('Plan.md')
expect(db.artifactFilename({ name: 'Page', kind: 'html' })).toBe('Page.html')
expect(db.artifactMimeType('md')).toBe('text/markdown')
expect(db.artifactMimeType('html')).toBe('text/html')
})
it('round-trips an artifact through put/get', async () => {
await db.putArtifact(artifact({ id: 'x', name: 'Plan', content: 'body' }))
expect(await db.getArtifact('x')).toMatchObject({ id: 'x', name: 'Plan', content: 'body' })
})
it('get returns undefined for a missing id', async () => {
expect(await db.getArtifact('nope')).toBeUndefined()
})
it('put overwrites an existing record by id', async () => {
await db.putArtifact(artifact({ id: 'x', content: 'v1', updatedAt: 1 }))
await db.putArtifact(artifact({ id: 'x', content: 'v2', updatedAt: 2 }))
expect(await db.getArtifact('x')).toMatchObject({ content: 'v2', updatedAt: 2 })
expect(await db.listArtifactsForSession('s1')).toHaveLength(1)
})
it('lists only the requested session, and returns [] for an unknown one', async () => {
await db.putArtifact(artifact({ id: 'a', sessionId: 's1' }))
await db.putArtifact(artifact({ id: 'b', sessionId: 's1' }))
await db.putArtifact(artifact({ id: 'c', sessionId: 's2' }))
expect((await db.listArtifactsForSession('s1')).map((a) => a.id).sort()).toEqual(['a', 'b'])
expect(await db.listArtifactsForSession('missing')).toEqual([])
})
it('deletes a single artifact', async () => {
await db.putArtifact(artifact({ id: 'a' }))
await db.deleteArtifact('a')
expect(await db.getArtifact('a')).toBeUndefined()
})
it('deletes every artifact for a session, leaving others intact', async () => {
await db.putArtifact(artifact({ id: 'a', sessionId: 's1' }))
await db.putArtifact(artifact({ id: 'b', sessionId: 's1' }))
await db.putArtifact(artifact({ id: 'c', sessionId: 's2' }))
await db.deleteArtifactsForSession('s1')
expect(await db.listArtifactsForSession('s1')).toEqual([])
expect((await db.listArtifactsForSession('s2')).map((a) => a.id)).toEqual(['c'])
})
it('isolates artifacts between users on the same browser', async () => {
await db.putArtifact(artifact({ id: 'a', sessionId: 's1' }))
// A different user sees an empty, separate database...
userStore.set({ email: 'b@x.com' })
expect(await db.listArtifactsForSession('s1')).toEqual([])
await db.putArtifact(artifact({ id: 'b', sessionId: 's1' }))
// ...and switching back reveals only the first user's artifact.
userStore.set({ email: 'a@x.com' })
expect((await db.listArtifactsForSession('s1')).map((x) => x.id)).toEqual(['a'])
})
it('degrades gracefully when IndexedDB is unavailable', async () => {
vi.resetModules()
delete (globalThis as any).indexedDB
;(await import('$lib/stores')).userStore.set({ email: 'a@x.com' } as never)
const noDb = await import('./artifactsDB')
// Reads return empty; writes/deletes are no-ops rather than throwing.
await expect(noDb.putArtifact(artifact())).resolves.toBeUndefined()
expect(await noDb.getArtifact('a1')).toBeUndefined()
expect(await noDb.listArtifactsForSession('s1')).toEqual([])
await expect(noDb.deleteArtifact('a1')).resolves.toBeUndefined()
await expect(noDb.deleteArtifactsForSession('s1')).resolves.toBeUndefined()
})
it('swallows a write failure when the DB handle exists but the op rejects', async () => {
// The handle is present, but put/delete reject — the QuotaExceededError-shaped failure
// the plain no-handle test can't reach. Must not throw at the caller.
vi.resetModules()
vi.doMock('idb', () => ({
openDB: async () => ({
put: async () => {
throw new DOMException('quota', 'QuotaExceededError')
},
delete: async () => {
throw new DOMException('quota', 'QuotaExceededError')
}
}),
deleteDB: async () => {}
}))
try {
;(await import('$lib/stores')).userStore.set({ email: 'a@x.com' } as never)
const failing = await import('./artifactsDB')
await expect(failing.putArtifact(artifact())).resolves.toBeUndefined()
await expect(failing.deleteArtifact('a1')).resolves.toBeUndefined()
} finally {
vi.doUnmock('idb')
}
})
})
@@ -0,0 +1,108 @@
// Scoped by sessionId (fixed for the session's life), not chatId: a session follows its
// active chat's rotation, so chatId-keying would drop artifacts on each new conversation.
import { type DBSchema as IDBSchema } from 'idb'
import { userScopedDb } from '$lib/userScopedDb'
export type ArtifactKind = 'md' | 'html'
export interface PersistedArtifact {
id: string
sessionId: string
chatId?: string
kind: ArtifactKind
name: string
content: string
createdAt: number
updatedAt: number
}
export function artifactFilename(a: Pick<PersistedArtifact, 'name' | 'kind'>): string {
return `${a.name}.${a.kind === 'html' ? 'html' : 'md'}`
}
export function artifactMimeType(kind: ArtifactKind): string {
return kind === 'html' ? 'text/html' : 'text/markdown'
}
interface ArtifactsSchema extends IDBSchema {
items: {
key: string
value: PersistedArtifact
indexes: { 'by-session': string }
}
}
// User-scoped like the chat-history store these are keyed against: no cross-user
// co-residency on a shared browser.
const dbh = userScopedDb<ArtifactsSchema>('copilot-artifacts', {
version: 1,
upgrade(db) {
const store = db.createObjectStore('items', { keyPath: 'id' })
store.createIndex('by-session', 'sessionId')
}
})
function getDB() {
return dbh.whenReady()
}
export async function putArtifact(artifact: PersistedArtifact): Promise<void> {
const db = await getDB()
if (!db) return
try {
// A rejected write (most likely QuotaExceededError) leaves the artifact usable for the
// session but unpersisted — degrade like the reads rather than throwing at the caller.
await db.put('items', artifact)
} catch (err) {
console.error('Could not persist artifact', err)
}
}
export async function getArtifact(id: string): Promise<PersistedArtifact | undefined> {
const db = await getDB()
if (!db) return undefined
try {
return await db.get('items', id)
} catch (err) {
console.error('Could not read artifact', err)
return undefined
}
}
export async function listArtifactsForSession(sessionId: string): Promise<PersistedArtifact[]> {
const db = await getDB()
if (!db) return []
try {
return await db.getAllFromIndex('items', 'by-session', sessionId)
} catch (err) {
console.error('Could not read artifacts', err)
return []
}
}
export async function deleteArtifact(id: string): Promise<void> {
const db = await getDB()
if (!db) return
try {
await db.delete('items', id)
} catch (err) {
console.error('Could not delete artifact', err)
}
}
export async function deleteArtifactsForSession(sessionId: string): Promise<void> {
const db = await getDB()
if (!db) return
try {
const tx = db.transaction('items', 'readwrite')
const index = tx.store.index('by-session')
let cursor = await index.openCursor(sessionId)
while (cursor) {
await cursor.delete()
cursor = await cursor.continue()
}
await tx.done
} catch (err) {
console.error('Could not delete artifacts for session', err)
}
}
@@ -0,0 +1,134 @@
import { randomUUID } from '$lib/utils/uuid'
import {
deleteArtifact,
getArtifact,
listArtifactsForSession,
putArtifact,
type ArtifactKind,
type PersistedArtifact
} from './artifactsDB'
export interface CreateArtifactInput {
name: string
content: string
kind?: ArtifactKind
chatId?: string
}
export interface UpdateArtifactInput {
name?: string
content?: string
}
/**
* Reactive view of the active session's artifacts, owned by AIChatManager (like
* AttachedFilesStore). The consumer drives which session is loaded via setSession(); the
* write tools mutate through create/update/remove, which persist and update the in-memory
* list in one step.
*/
export class SessionArtifactsStore {
artifacts = $state<PersistedArtifact[]>([])
loading = $state(false)
#sessionId: string | undefined
// A later load always wins, even if an earlier DB read resolves after it.
#seq = 0
/** Load the given session's artifacts into the reactive list, if it changed. */
async setSession(sessionId: string | undefined): Promise<void> {
// Skip same-id resyncs: in-memory owns the loaded session, so a DB reload would
// drop artifacts whose best-effort persist failed.
if (sessionId === this.#sessionId) return
this.#sessionId = sessionId
await this.#load()
}
async #load(): Promise<void> {
const token = ++this.#seq
const id = this.#sessionId
if (!id) {
this.artifacts = []
this.loading = false
return
}
this.loading = true
const items = await listArtifactsForSession(id)
if (token !== this.#seq) return
this.artifacts = sortByUpdatedDesc(items)
this.loading = false
}
// Bump #seq so an in-flight #load (snapshot taken before this write) can't clobber it;
// that load early-returns without clearing loading, so clear it here.
#applyWrite(next: PersistedArtifact[]): void {
this.#seq++
this.artifacts = next
this.loading = false
}
async get(id: string): Promise<PersistedArtifact | undefined> {
// In-memory first: a write whose persist silently failed (quota) is still readable here.
return this.artifacts.find((a) => a.id === id) ?? (await getArtifact(id))
}
async listForSession(sessionId: string): Promise<PersistedArtifact[]> {
if (sessionId === this.#sessionId) return [...this.artifacts]
return sortByUpdatedDesc(await listArtifactsForSession(sessionId))
}
/** Persist a new artifact for `sessionId` and reflect it in the list if that session is loaded. */
async create(sessionId: string, input: CreateArtifactInput): Promise<PersistedArtifact> {
const now = Date.now()
const artifact: PersistedArtifact = {
id: randomUUID(),
sessionId,
chatId: input.chatId,
kind: input.kind ?? 'md',
name: input.name,
content: input.content,
createdAt: now,
updatedAt: now
}
await putArtifact(artifact)
if (sessionId === this.#sessionId) {
this.#applyWrite(sortByUpdatedDesc([artifact, ...this.artifacts]))
}
return artifact
}
/**
* Merge changes into an existing artifact. Returns undefined if `id` is unknown, or if
* `opts.sessionId` is given and the artifact belongs to a different session.
*/
async update(
id: string,
input: UpdateArtifactInput,
opts?: { sessionId?: string }
): Promise<PersistedArtifact | undefined> {
const existing = this.artifacts.find((a) => a.id === id) ?? (await getArtifact(id))
if (!existing) return undefined
if (opts?.sessionId !== undefined && existing.sessionId !== opts.sessionId) return undefined
const updated: PersistedArtifact = {
...existing,
name: input.name ?? existing.name,
content: input.content ?? existing.content,
updatedAt: Date.now()
}
await putArtifact(updated)
if (updated.sessionId === this.#sessionId) {
this.#applyWrite(sortByUpdatedDesc(this.artifacts.map((a) => (a.id === id ? updated : a))))
}
return updated
}
async remove(id: string): Promise<void> {
await deleteArtifact(id)
// Guard on presence: a no-op remove must not invalidate an in-flight load.
const next = this.artifacts.filter((a) => a.id !== id)
if (next.length !== this.artifacts.length) this.#applyWrite(next)
}
}
function sortByUpdatedDesc(items: PersistedArtifact[]): PersistedArtifact[] {
return [...items].sort((a, b) => b.updatedAt - a.updatedAt)
}
@@ -0,0 +1,216 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { IDBFactory } from 'fake-indexeddb'
import { SessionArtifactsStore } from './artifactsState.svelte'
import * as db from './artifactsDB'
// The user-scoping subscription is BROWSER-gated; the node test env reports false.
vi.mock('esm-env', async (orig) => ({
...(await orig<typeof import('esm-env')>()),
BROWSER: true
}))
// Stub $lib/stores + $lib/utils (userScopedStorage's only deps here) to keep their heavy
// svelte/app-store graphs out of the per-test cold transform.
vi.mock('$lib/stores', async () => {
const { writable } = await import('svelte/store')
return { userStore: writable(undefined) }
})
vi.mock('$lib/utils', () => ({ getLocalSetting: () => undefined, storeLocalSetting: () => {} }))
// The DB module memoises its handle at module scope. A fresh IDBFactory per test only
// isolates data once the handle is reset, so reset modules and re-import both together.
// The DB is namespaced by email, so seed a user.
async function fresh() {
vi.resetModules()
;(globalThis as any).indexedDB = new IDBFactory()
;(await import('$lib/stores')).userStore.set({ email: 'a@x.com' } as never)
const dbMod = await import('./artifactsDB')
const { SessionArtifactsStore: Store } = await import('./artifactsState.svelte')
return { dbMod, store: new Store() }
}
let store: SessionArtifactsStore
let dbMod: typeof db
beforeEach(async () => {
;({ store, dbMod } = await fresh())
})
describe('SessionArtifactsStore', () => {
it('loads the current session, newest-updated first', async () => {
await dbMod.putArtifact(mk({ id: 'old', sessionId: 's1', updatedAt: 1 }))
await dbMod.putArtifact(mk({ id: 'new', sessionId: 's1', updatedAt: 2 }))
await dbMod.putArtifact(mk({ id: 'other', sessionId: 's2', updatedAt: 3 }))
await store.setSession('s1')
expect(store.artifacts.map((a) => a.id)).toEqual(['new', 'old'])
expect(store.loading).toBe(false)
})
it('empties the list for an undefined session id', async () => {
await dbMod.putArtifact(mk({ id: 'a', sessionId: 's1' }))
await store.setSession('s1')
expect(store.artifacts).toHaveLength(1)
await store.setSession(undefined)
expect(store.artifacts).toEqual([])
})
it('a later setSession wins over an earlier in-flight load', async () => {
await dbMod.putArtifact(mk({ id: 'a', sessionId: 's1' }))
await dbMod.putArtifact(mk({ id: 'b', sessionId: 's2' }))
// Start both loads without awaiting the first; the last-started must win.
const first = store.setSession('s1')
const second = store.setSession('s2')
await Promise.all([first, second])
expect(store.artifacts.map((a) => a.id)).toEqual(['b'])
})
it('a create is not clobbered by an in-flight load with a stale snapshot', async () => {
// Hold the load open with a stale (empty) snapshot until after the create lands.
let releaseLoad!: (items: db.PersistedArtifact[]) => void
const held = new Promise<db.PersistedArtifact[]>((r) => (releaseLoad = r))
const spy = vi.spyOn(dbMod, 'listArtifactsForSession').mockReturnValueOnce(held)
const loading = store.setSession('s1') // #load starts, hangs on `held`
const created = await store.create('s1', { name: 'X', content: 'x' })
releaseLoad([]) // the load resolves late, snapshot predates the create
await loading
spy.mockRestore()
expect(store.artifacts.map((a) => a.id)).toEqual([created.id])
// The superseded load early-returns; the create must have cleared `loading`.
expect(store.loading).toBe(false)
})
it('create persists and prepends when the session is loaded', async () => {
await store.setSession('s1')
const created = await store.create('s1', { name: 'Plan', content: '# hi' })
expect(created.kind).toBe('md')
expect(store.artifacts.map((a) => a.id)).toEqual([created.id])
// Persisted: switching away and back reloads it from the DB.
await store.setSession('other')
await store.setSession('s1')
expect(store.artifacts.map((a) => a.name)).toEqual(['Plan'])
})
it('a same-session resync keeps an artifact whose persist failed', async () => {
await store.setSession('s1')
const created = await store.create('s1', { name: 'A', content: 'x' })
// Simulate a persist that never landed: drop it from the DB, keep it in memory.
await dbMod.deleteArtifact(created.id)
// A routine resync (chat rotation / global-mode reconfig) must not reload it away.
await store.setSession('s1')
expect(store.artifacts.map((a) => a.id)).toEqual([created.id])
})
it('create stamps the provenance chatId when supplied', async () => {
await store.setSession('s1')
const created = await store.create('s1', { name: 'Plan', content: 'x', chatId: 'c9' })
expect(created.chatId).toBe('c9')
})
it('create persists to another session without touching the loaded list', async () => {
await store.setSession('s1')
const created = await store.create('s2', { name: 'Elsewhere', content: 'x' })
expect(store.artifacts).toEqual([])
expect((await dbMod.listArtifactsForSession('s2')).map((a) => a.id)).toEqual([created.id])
})
it('update merges changes, bumps updatedAt, and persists', async () => {
await store.setSession('s1')
const created = await store.create('s1', { name: 'Plan', content: 'v1' })
const updated = await store.update(created.id, { content: 'v2' })
expect(updated?.content).toBe('v2')
expect(updated?.name).toBe('Plan')
expect(updated!.updatedAt).toBeGreaterThanOrEqual(created.updatedAt)
expect((await dbMod.getArtifact(created.id))?.content).toBe('v2')
})
it('update falls back to the DB when the target is not in the loaded list', async () => {
const created = await store.create('s2', { name: 'Off', content: 'v1' })
await store.setSession('s1') // s2 is not loaded
const updated = await store.update(created.id, { name: 'Renamed' })
expect(updated?.name).toBe('Renamed')
expect(updated?.content).toBe('v1')
})
it('update returns undefined for an unknown id', async () => {
await store.setSession('s1')
expect(await store.update('nope', { content: 'x' })).toBeUndefined()
})
it('get resolves from the in-memory list even when the DB lacks the record', async () => {
await store.setSession('s1')
const created = await store.create('s1', { name: 'A', content: 'x' })
// Simulate a persist that never landed: drop it from the DB, keep it in memory.
await dbMod.deleteArtifact(created.id)
expect((await store.get(created.id))?.id).toBe(created.id)
})
it('get falls back to the DB for an artifact outside the loaded list', async () => {
const created = await store.create('s2', { name: 'B', content: 'y' })
await store.setSession('s1') // s2 not loaded
expect((await store.get(created.id))?.id).toBe(created.id)
expect(await store.get('nope')).toBeUndefined()
})
it('listForSession returns the in-memory list for the loaded session, even when the DB lacks it', async () => {
await store.setSession('s1')
const created = await store.create('s1', { name: 'A', content: 'x' })
await dbMod.deleteArtifact(created.id) // persist "failed": gone from DB, kept in memory
expect((await store.listForSession('s1')).map((a) => a.id)).toEqual([created.id])
})
it('listForSession reads the DB for a session that is not loaded', async () => {
await store.create('s2', { name: 'B', content: 'y' })
await store.setSession('s1') // s2 not loaded
expect((await store.listForSession('s2')).map((a) => a.name)).toEqual(['B'])
expect(await store.listForSession('s1')).toEqual([])
})
it('update refuses an artifact from a different session when scoped by sessionId', async () => {
const created = await store.create('s2', { name: 'Off', content: 'v1' })
expect(await store.update(created.id, { content: 'v2' }, { sessionId: 's1' })).toBeUndefined()
// Unchanged in the DB.
expect((await dbMod.getArtifact(created.id))?.content).toBe('v1')
// Correct scope still updates.
expect(await store.update(created.id, { content: 'v2' }, { sessionId: 's2' })).toBeDefined()
})
it('update distinguishes an omitted field from an explicit empty string', async () => {
await store.setSession('s1')
const created = await store.create('s1', { name: 'Plan', content: 'body' })
// Omitting content leaves it untouched...
expect((await store.update(created.id, { name: 'Renamed' }))?.content).toBe('body')
// ...but an explicit empty string blanks it.
expect((await store.update(created.id, { content: '' }))?.content).toBe('')
})
it('remove deletes from the DB and the loaded list', async () => {
await store.setSession('s1')
const created = await store.create('s1', { name: 'Plan', content: 'x' })
await store.remove(created.id)
expect(store.artifacts).toEqual([])
expect(await dbMod.getArtifact(created.id)).toBeUndefined()
})
})
function mk(over: Partial<db.PersistedArtifact> = {}): db.PersistedArtifact {
return {
id: 'a1',
sessionId: 's1',
chatId: 'c1',
kind: 'md',
name: 'Doc',
content: '# hi',
createdAt: 0,
updatedAt: 0,
...over
}
}
@@ -82,6 +82,8 @@ import type { ContextElement } from '../context'
import { getDatatableTools } from '../datatableTools'
import { fileTools } from '../files/fileTools'
import type { AttachedFilesStore } from '../files/attachedFiles.svelte'
import { artifactTools } from '../artifacts/artifactTools'
import type { SessionArtifactsStore } from '../artifacts/artifactsState.svelte'
import { UserDraft } from '$lib/userDraft.svelte'
import { emptySchema } from '$lib/utils'
import { inferArgs } from '$lib/infer'
@@ -928,7 +930,8 @@ Rules:
- Building a data pipeline: call open_preview(kind="pipeline", path="<folder>") as the FIRST step, before creating any node — this opens the pipeline editor the user reviews in. path is the folder, not an item; an empty or not-yet-created folder is fine (create_folder first if needed, then open it). Opening it registers build_pipeline_node / edit_pipeline_node — use ONLY those to add or change pipeline nodes, never write_script for a pipeline node — they apply directly as unsaved drafts on the canvas (no separate accept/reject step) that the user reviews and deploys. Do not write pipeline scripts without first opening the editor.
- When debugging a running raw app, call get_app_runtime_logs to read the live preview's browser console output. It needs the raw app preview open (open_preview kind="raw_app").
- get_app_runtime_logs only shows the app's browser console. For the server-side logs of a backend runnable the app invoked (a backend.<id> call), call list_app_runs to get that run's job_id from the live preview, then get_job_logs with it. Use this when a backend call errors or returns something unexpected.
- open_page opens its page as a tab in the side-panel preview next to the chat — the only way to show one of these pages there (open_preview only handles editable items). Changing filters on a page already open updates that same tab; only pass new_tab when the user explicitly asks for a separate tab.`
- open_page opens its page as a tab in the side-panel preview next to the chat — the only way to show one of these pages there (open_preview only handles editable items). Changing filters on a page already open updates that same tab; only pass new_tab when the user explicitly asks for a separate tab.
- create_artifact saves a persistent markdown document (a planning doc, design write-up, spec, or other longer structured output) shown in the session preview panel. Prefer it over a long inline reply for content the user will revisit; keep brief answers inline. To revise one, call list_artifacts then read_artifact for the current content, then update_artifact to overwrite it — never create a second artifact for the same document.`
: ''
}
@@ -2835,6 +2838,7 @@ export const globalTools: Tool<{}>[] = [
return deleteAppRunnable(parsed, ctx)
}
},
...artifactTools,
{
def: createToolDef(
openPreviewSchema,
@@ -2916,7 +2920,11 @@ export const SESSION_PREVIEW_TOOL_NAMES = new Set([
'get_preview_status',
'close_page',
'get_app_runtime_logs',
'list_app_runs'
'list_app_runs',
'create_artifact',
'update_artifact',
'list_artifacts',
'read_artifact'
])
/**
@@ -2964,6 +2972,10 @@ export type GlobalToolHelpers = SessionToolHelpers & {
// (possibly forked) workspace while $workspaceStore stays on the navigation workspace,
// so permission gating (open_page) must read this, not the global store.
operatingWorkspace?: string
// Wired only for session chats (see AIChatManager): the artifact tools are session-gated.
artifacts?: SessionArtifactsStore
getChatId?: () => string | undefined
openArtifact?: (artifactId: string, name: string) => void
}
function sessionIdFromCtx(ctx: { helpers?: unknown }): string | undefined {
@@ -0,0 +1,36 @@
/**
* Shared prose (Tailwind typography) stacks for markdown renders, one source of
* truth so surfaces don't each roll their own and drift apart. Compose at the
* call site with layout-only classes (padding, width, bg); anything typographic
* belongs here.
*
* - 'xs': micro scale for dense secondary panes (chat reasoning blocks)
* - 'sm': compact chat-bubble scale (assistant messages, flow/app chat, settings)
* - 'doc': same rhythm and body size as 'sm', with a taller heading ramp
* (lg/base/sm) and semibold headings for document-like surfaces (artifacts)
*/
// Kept as literal template parts: Tailwind's scanner reads class names verbatim
// from this file, so every token must appear as plain text.
// content-none strips the typography plugin's decorative backticks around
// inline code (code::before/::after), which read as literal ` characters.
// [&>:first-child]:mt-0 re-applies the plugin's first-block reset, which our
// explicit prose-headings:mt-* would otherwise override (note: the composed
// variant prose-headings:first: attaches :first-child to the wrapper — wrong).
const base =
'prose dark:prose-invert max-w-full break-words [&>:first-child]:mt-0 prose-a:break-words prose-code:break-words prose-code:before:content-none prose-code:after:content-none prose-code:bg-surface-secondary/50 prose-code:rounded prose-code:px-1 prose-code:py-0.5 prose-code:font-normal prose-table:block prose-table:max-w-full prose-table:overflow-x-auto'
// One vertical rhythm for sm/doc; heading margins stay per-preset (fixed, not
// the plugin's em-based ones) so 'doc' can breathe more between sections.
const rhythm = 'prose-sm leading-snug prose-ul:!pl-6'
const bodyXs =
'text-primary prose-p:text-primary prose-li:text-primary prose-p:text-xs prose-li:text-xs prose-code:text-xs prose-pre:text-xs prose-table:text-xs'
export const markdownProse = {
xs: `${base} prose-sm leading-snug prose-ul:!pl-5 prose-p:text-2xs prose-li:text-2xs prose-code:text-2xs prose-pre:text-2xs prose-headings:font-medium prose-headings:text-secondary prose-headings:mt-2 prose-headings:mb-1 prose-h1:text-2xs prose-h2:text-2xs prose-h3:text-2xs prose-h4:text-2xs prose-h5:text-2xs prose-h6:text-2xs prose-strong:text-secondary`,
sm: `${base} ${rhythm} ${bodyXs} prose-headings:mt-3 prose-headings:mb-1 prose-headings:font-medium prose-headings:text-emphasis prose-h1:text-sm prose-h2:text-xs prose-h3:text-xs prose-h4:text-xs prose-h5:text-xs prose-h6:text-xs`,
doc: `${base} ${rhythm} ${bodyXs} prose-headings:mt-8 prose-headings:mb-2 prose-headings:font-semibold prose-headings:text-emphasis prose-h1:text-lg prose-h2:text-base prose-h3:text-sm prose-h4:text-xs prose-h5:text-xs prose-h6:text-xs prose-pre:bg-transparent prose-pre:p-0`
} as const
export type MarkdownProseSize = keyof typeof markdownProse
@@ -14,7 +14,7 @@ only ever navigates between items) doesn't grow a Pages section.
<script lang="ts">
import { untrack } from 'svelte'
import { Compass } from 'lucide-svelte'
import { Compass, FileText } from 'lucide-svelte'
import { resource } from 'runed'
import { workspaceStore } from '$lib/stores'
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
@@ -29,7 +29,15 @@ only ever navigates between items) doesn't grow a Pages section.
} from '$lib/components/workspaceTree'
import { listGlobalDrafts } from '$lib/components/copilot/chat/global/userDraftAdapter'
import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate'
import { PREVIEW_PAGES, pageHref, pageKey, type PreviewTarget } from './previewRouter'
import type { PersistedArtifact } from '$lib/components/copilot/chat/artifacts/artifactsDB'
import {
PREVIEW_PAGES,
artifactKey,
isArtifactKey,
pageHref,
pageKey,
type PreviewTarget
} from './previewRouter'
type Kind = WorkspaceItemKind
type DrillPickerHandle = {
@@ -52,6 +60,9 @@ only ever navigates between items) doesn't grow a Pages section.
// list root items and miss fork-only ones. Falls back to $workspaceStore
// for non-session consumers.
workspaceId?: string
// Session artifacts to surface as an "Artifacts" branch; omitted or empty
// hides the branch (non-session consumers, sessions without artifacts).
artifacts?: PersistedArtifact[]
}
let {
@@ -62,7 +73,8 @@ only ever navigates between items) doesn't grow a Pages section.
externalFilter,
autoFocus = true,
flush = false,
workspaceId
workspaceId,
artifacts
}: Props = $props()
const kinds: Kind[] = ['flow', 'script', 'app']
@@ -137,8 +149,30 @@ only ever navigates between items) doesn't grow a Pages section.
children: PREVIEW_PAGES.filter((p) => p.path !== '/').map(pageLeaf)
})
// Session artifacts, when present, get their own branch between Pages and the
// workspace items so they're pickable (and searchable) like any destination.
const artifactsBranch = $derived<DrillBranch<PreviewTarget> | undefined>(
artifacts && artifacts.length > 0
? {
type: 'branch',
key: 'artifacts',
label: 'Artifacts',
icon: FileText,
searchGroup: true,
children: artifacts.map((a) => ({
type: 'leaf' as const,
key: artifactKey(a.id),
label: a.name,
icon: FileText,
data: { type: 'artifact', id: a.id, name: a.name }
}))
}
: undefined
)
const tree = $derived<DrillNode<PreviewTarget>[]>([
pagesBranch,
...(artifactsBranch ? [artifactsBranch] : []),
...tagItems(
buildWorkspaceTree({
loaded: loader.loaded,
@@ -150,7 +184,17 @@ only ever navigates between items) doesn't grow a Pages section.
)
])
const computedInitialScope = untrack(() => legacyScopeToPath(initialScope, kinds))
// An artifact highlight lives under the 'artifacts' branch, which the legacy
// {kind, dir} scope can't express — open the picker inside that branch so the
// active artifact is actually visible and highlighted (not the first root row).
// Keyed on the highlight's shape alone: the branch itself may not have
// materialized yet (artifacts hydrate from IndexedDB after tabs restore), and
// the drill entries fill in reactively once it does.
const computedInitialScope = untrack(() =>
initialHighlight && isArtifactKey(initialHighlight)
? ['artifacts']
: legacyScopeToPath(initialScope, kinds)
)
</script>
{#snippet leafIcon(leaf: DrillLeaf<PreviewTarget>)}
@@ -14,6 +14,7 @@
import FlowEditorView from './FlowEditorView.svelte'
import RawAppEditorView from './RawAppEditorView.svelte'
import PipelineEditorView from './PipelineEditorView.svelte'
import ArtifactViewer from '../copilot/chat/artifacts/ArtifactViewer.svelte'
let {
tab,
@@ -57,6 +58,13 @@
)
const isActiveSession = $derived(!!session && sessionState.currentSessionId === session.id)
// Resolved live from the session's store so an update_artifact re-renders the panel.
const artifact = $derived(
slot.kind === 'artifact'
? runtime?.manager.artifacts.artifacts.find((a) => a.id === slot.id)
: undefined
)
let frame: HTMLIFrameElement | undefined = $state()
// Pages whose theme we mirror on live toggles. Regular apps are the only item
@@ -105,6 +113,21 @@
const visibility = $derived(
active ? 'z-10 opacity-100 pointer-events-auto' : 'z-0 opacity-0 pointer-events-none'
)
let flashing = $state(false)
let flashTimer: ReturnType<typeof setTimeout> | undefined
// Guard against the effect's non-pulse reruns (tab/runtime changes) firing a flash.
let lastPulseNonce = -1
$effect(() => {
const pulse = runtime?.previewTabs.focusPulse
if (!pulse || pulse.nonce === lastPulseNonce) return
lastPulseNonce = pulse.nonce
if (pulse.id !== tab.id) return
flashing = true
clearTimeout(flashTimer)
flashTimer = setTimeout(() => (flashing = false), 800)
})
$effect(() => () => clearTimeout(flashTimer))
</script>
{#if slot.kind === 'editor' && mounted && runtime}
@@ -141,6 +164,21 @@
/>
{/if}
</div>
{:else if slot.kind === 'artifact' && mounted}
<div class="absolute inset-0 flex flex-col min-h-0 bg-surface {visibility}" aria-hidden={!active}>
{#if artifact}
<ArtifactViewer {artifact} />
{:else if !runtime?.manager.artifacts.loading}
<div class="p-4 text-sm text-tertiary">This artifact is no longer available.</div>
{/if}
<!-- Overlay: the source editor's opaque bg would cover a ring on the container. -->
<div
class="pointer-events-none absolute inset-0 z-30 ring-2 ring-inset ring-border-accent transition-opacity duration-300 {flashing
? 'opacity-100'
: 'opacity-0'}"
aria-hidden="true"
></div>
</div>
{:else if mounted}
<iframe
bind:this={frame}
@@ -1,14 +1,8 @@
<script lang="ts">
import {
Archive,
ExternalLink,
GitPullRequestClosed,
MoveRight,
Pencil,
Trash2
} from 'lucide-svelte'
import { Archive, ExternalLink, GitPullRequestClosed, MoveRight, Trash2 } from 'lucide-svelte'
import { Button } from '$lib/components/common'
import Badge from '$lib/components/common/badge/Badge.svelte'
import SessionStatusPopover from './SessionStatusPopover.svelte'
import WorkspaceFamilyPicker from './WorkspaceFamilyPicker.svelte'
import { isPremiumStore, userStore, userWorkspaces, workspaceStore } from '$lib/stores'
import { canCreateFork } from '$lib/utils/editInFork'
@@ -16,10 +10,12 @@
import { sessionState, type Session } from './sessionState.svelte'
import { getRuntime } from './sessionRuntime.svelte'
import SessionDiffDrawer from './SessionDiffDrawer.svelte'
import { TOKEN_TRIGGER_CLASS } from './SessionStatusToken.svelte'
import { useWorkspaceDrafts } from '$lib/workspaceDrafts.svelte'
import { badgeCounts, buildDeployItems } from './sessionDeployModel'
import { badgeCounts, badgeOf, buildDeployItems } from './sessionDeployModel'
import { useExistingMaskKeys } from './sessionDeployModel.svelte'
import JobsSegment from '$lib/components/copilot/chat/JobsSegment.svelte'
import ArtifactsSegment from '$lib/components/copilot/chat/artifacts/ArtifactsSegment.svelte'
// Unified session bar: surfaces what the CURRENT chat changed — pending
// drafts and deployed items — as one count badge per status that opens the
@@ -123,9 +119,9 @@
// Opening the drawer re-fetches its own data, so it can show fresher state
// than the badge just clicked (e.g. "1 draft" that was deployed elsewhere in
// the meantime). Re-sync the dock alongside so the two never disagree.
function openDrawer() {
function openDrawer(focusKey?: string) {
refreshDock()
diffDrawer?.open()
diffDrawer?.open(focusKey)
}
// The dock counts are computed from the same pure model over this chat's
@@ -175,25 +171,20 @@
// Background jobs the chat started (rendered as the Jobs segment). The session
// bar shows if there are edits OR jobs; each segment hides when its side is empty.
const hasJobs = $derived((runtime?.manager.backgroundJobs.length ?? 0) > 0)
</script>
const hasArtifacts = $derived((runtime?.manager.artifacts.artifacts.length ?? 0) > 0)
{#snippet dock()}
<!-- One count badge per row status, same vocabulary/colors as the drawer's
badges (draft/deployed) so the bar reads at a glance. Display-only: the
enclosing Edits segment button opens the drawer. -->
<div class="flex items-center gap-1 shrink-0">
{#if dockCounts.draft > 0}
<Badge small color="indigo">
{dockCounts.draft} draft{dockCounts.draft === 1 ? '' : 's'}
</Badge>
{/if}
{#if dockCounts.deployed > 0}
<Badge small color="green">
{dockCounts.deployed} deployed
</Badge>
{/if}
</div>
{/snippet}
const editsCount = $derived(dockCounts.draft + dockCounts.deployed)
const editsLabel = $derived(`${editsCount} edit${editsCount === 1 ? '' : 's'}`)
let editsOpen = $state(false)
// Only draft-vs-deployed drives the color: stale/failed live in the drawer's
// deploy model, not here.
const editsColorClass = $derived(
dockCounts.draft > 0
? '!bg-indigo-100 !text-indigo-800 hover:!bg-indigo-200 dark:!bg-indigo-700/40 dark:!text-indigo-100 dark:hover:!bg-indigo-600/40'
: '!bg-green-100 !text-green-700 hover:!bg-green-200 dark:!bg-green-700/40 dark:!text-green-100 dark:hover:!bg-green-600/40'
)
</script>
{#if committedId && isUnavailable}
<!-- Committed workspace is no longer in the user's list (deleted, archived,
@@ -240,57 +231,55 @@
</Button>
</div>
</div>
{:else if committedId && (showBar || hasJobs)}
<!-- Segmented session bar: an Edits segment (what the AI changed this session)
and a Jobs segment (background jobs it started), sharing one border box.
The Edits segment (or, in its absence, a flex-1 spacer) fills the left so the
Jobs segment stays pinned to the right whether or not there are edits. Fork
identity / sync status lives inside the modal. -->
<div
class="flex h-[38px] items-stretch overflow-hidden rounded-md border bg-surface-tertiary text-xs"
>
{:else if committedId && (showBar || hasArtifacts || hasJobs)}
<div class="flex items-center gap-3 rounded-md border bg-surface-tertiary px-2 py-2 text-xs">
{#if showBar}
{#if deletionOnly && compareHref}
<!-- Deleted items have no drawer row; the pending fork→parent removal is
reviewed on the compare page, so the segment links there instead. -->
<!-- Deleted items have no drawer row, so this token links to the compare
page (where the fork→parent removal is reviewed) rather than opening
a popover. -->
<a
href={compareHref}
target="_blank"
rel="noopener noreferrer"
title="This chat's edits were deletions — review and promote them on the compare page"
class="flex min-w-0 flex-1 items-center gap-1.5 px-3 hover:bg-surface-hover"
class={TOKEN_TRIGGER_CLASS}
>
<Pencil class="h-3.5 w-3.5 shrink-0 text-secondary" />
<span class="shrink-0 font-normal text-primary">Edits</span>
<span
class="ml-auto inline-flex items-center gap-1 truncate text-2xs font-medium text-accent"
>
Review deletions <ExternalLink class="h-3 w-3 shrink-0" />
</span>
<span class="truncate font-normal text-secondary">Edits</span>
<ExternalLink class="h-3 w-3 shrink-0 text-tertiary" />
</a>
{:else}
<!-- Raw <button> (like the other clickable session rows/segments, e.g.
SessionPicker/WorkspaceFamilyPicker): a full-width bar segment, not a
discrete design-system action control. aria-haspopup marks the drawer. -->
<button
type="button"
class="flex min-w-0 flex-1 items-center gap-1.5 px-3 hover:bg-surface-hover"
title="Edited by the chat during this session"
aria-haspopup="dialog"
onclick={openDrawer}
<SessionStatusPopover
bind:open={editsOpen}
label={editsLabel}
title="Edited this session"
items={dockItems}
itemKey={(item) => item.key}
rowTitle={(item) => item.displayPath}
onPick={(item) => openDrawer(item.key)}
triggerClass={`${TOKEN_TRIGGER_CLASS} ${editsColorClass}`}
widthClass="w-96"
maxHeightClass="max-h-[min(9rem,50vh)]"
>
<Pencil class="h-3.5 w-3.5 shrink-0 text-secondary" />
<span class="shrink-0 font-normal text-primary">Edits</span>
<span class="ml-auto">{@render dock()}</span>
</button>
{#snippet row(item)}
<span class="min-w-0 flex-1 truncate font-mono font-normal text-primary">
{item.displayPath}
</span>
{#if badgeOf(item) === 'draft'}
<Badge small color="indigo">
{item.draftOnly ? 'Draft only' : 'Draft'}
</Badge>
{:else}
<Badge small color="green">Deployed</Badge>
{/if}
{/snippet}
</SessionStatusPopover>
{/if}
{:else}
<!-- No edits: a spacer fills the left so the Jobs segment stays right-aligned. -->
<div class="flex-1"></div>
{/if}
{#if hasArtifacts}
<ArtifactsSegment />
{/if}
{#if hasJobs}
<!-- standalone={false}: renders just the chip trigger as a full-height
segment; the border box above provides the chrome. -->
<JobsSegment />
{/if}
</div>
@@ -74,8 +74,8 @@
(chatId ? `&from_session=${encodeURIComponent(chatId)}` : '')
)
export function open() {
inner?.open()
export function open(focusKey?: string) {
inner?.open(focusKey)
}
</script>
@@ -0,0 +1,145 @@
<script lang="ts" generics="T">
import type { Snippet } from 'svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import SessionStatusToken, { TOKEN_TRIGGER_CLASS } from './SessionStatusToken.svelte'
// Shared shell for the session status-line popovers (Edits / Artifacts / Jobs):
// one trigger token, one header, one keyboard-navigable row list — so styling
// and behavior improvements land on all three segments at once.
let {
open = $bindable(),
label,
title,
items,
itemKey,
onPick,
rowTitle,
row,
actions,
customTrigger,
ariaLabel,
triggerClass = TOKEN_TRIGGER_CLASS,
placement = 'top-start',
closeOnOtherPopoverOpen = true,
usePointerDownOutside = false,
widthClass = 'w-80',
maxHeightClass = 'max-h-[min(10rem,50vh)]'
}: {
open: boolean
/** Trigger token text; also the default aria-label. */
label: string
/** Header line above the list ("… this session"). */
title: string
items: T[]
itemKey: (item: T) => string
/** Primary row activation; the popover closes itself first. */
onPick: (item: T) => void
rowTitle?: (item: T) => string
/** Content of the row's primary button. */
row: Snippet<[T]>
/** Trailing per-row action buttons (outside the primary button). */
actions?: Snippet<[T]>
/** Replaces the default SessionStatusToken trigger. */
customTrigger?: Snippet
ariaLabel?: string
/** Full class of the popover trigger (defaults to the token chrome). */
triggerClass?: string
placement?: 'top-start' | 'top-end'
closeOnOtherPopoverOpen?: boolean
usePointerDownOutside?: boolean
widthClass?: string
maxHeightClass?: string
} = $props()
function pick(item: T) {
open = false
onPick(item)
}
// Roving focus over the rows' primary buttons.
let listRoot = $state<HTMLDivElement | undefined>(undefined)
function rowButtons(): HTMLButtonElement[] {
return listRoot
? Array.from(listRoot.querySelectorAll<HTMLButtonElement>('button[data-status-row]'))
: []
}
function focusAt(index: number) {
const buttons = rowButtons()
if (buttons.length === 0) return
const wrapped = ((index % buttons.length) + buttons.length) % buttons.length
buttons[wrapped]?.focus()
}
function handleListKeydown(e: KeyboardEvent) {
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp' && e.key !== 'Home' && e.key !== 'End') {
return
}
const buttons = rowButtons()
if (buttons.length === 0) return
// The active element may be a row's trailing action button: resolve to its
// row so vertical navigation moves rows, not focusables.
const active = document.activeElement as HTMLElement | null
const current = buttons.findIndex((b) => b === active || b.parentElement?.contains(active))
e.preventDefault()
if (e.key === 'ArrowDown') focusAt(current < 0 ? 0 : current + 1)
else if (e.key === 'ArrowUp') focusAt(current < 0 ? buttons.length - 1 : current - 1)
else if (e.key === 'Home') focusAt(0)
else if (e.key === 'End') focusAt(buttons.length - 1)
}
</script>
<Popover
bind:isOpen={open}
{placement}
enableFlyTransition
{closeOnOtherPopoverOpen}
{usePointerDownOutside}
class={triggerClass}
contentClasses="!bg-surface"
triggerAttrs={{ 'aria-label': ariaLabel ?? label, 'aria-haspopup': 'dialog' }}
>
{#snippet trigger()}
{#if customTrigger}
{@render customTrigger()}
{:else}
<SessionStatusToken {label} expanded={open} />
{/if}
{/snippet}
{#snippet content()}
<div class="flex {widthClass} flex-col text-xs">
<div class="px-3 pt-2 pb-0.5 text-2xs text-hint">{title}</div>
<!-- svelte-ignore a11y_no_noninteractive_element_interactions (keydown only routes arrows to the row buttons) -->
<div
role="list"
class="{maxHeightClass} overflow-y-auto py-1"
bind:this={listRoot}
onkeydown={handleListKeydown}
>
{#each items as item (itemKey(item))}
<div
class="flex items-center gap-2 py-1 pl-3 pr-2 hover:bg-surface-hover focus-within:bg-surface-hover"
role="listitem"
>
<button
type="button"
data-status-row
class="flex min-w-0 flex-1 items-center gap-2 text-left font-normal focus:outline-none"
title={rowTitle?.(item)}
onclick={() => pick(item)}
>
{@render row(item)}
</button>
{#if actions}
<div class="flex shrink-0 items-center gap-1.5">
{@render actions(item)}
</div>
{/if}
</div>
{/each}
</div>
</div>
{/snippet}
</Popover>
@@ -0,0 +1,29 @@
<script module lang="ts">
// Shared trigger styling so every status-line token (Edits, Artifacts, Jobs)
// stays visually uniform from one source.
export const TOKEN_TRIGGER_CLASS =
'inline-flex min-w-0 items-center gap-1.5 rounded px-1.5 py-0.5 text-xs text-secondary hover:bg-surface-hover'
</script>
<script lang="ts">
import { ChevronUp } from 'lucide-svelte'
// No <button>/onclick — the enclosing melt Popover owns the trigger and its click.
let {
label,
expanded = false
}: {
label: string
expanded?: boolean
} = $props()
</script>
<span class="inline-flex min-w-0 items-center gap-1.5">
<span class="min-w-0 truncate font-normal">
{label}
</span>
<ChevronUp
size={14}
class={`shrink-0 transition-transform duration-150 ${expanded ? 'rotate-180' : ''}`}
/>
</span>
@@ -143,7 +143,7 @@
)
}
export function open() {
export function open(focusKey?: string) {
loadedDiffs = {}
mountedRows = {}
folderOpen = {}
@@ -151,6 +151,34 @@
model.load()
drawer?.openDrawer()
setTimeout(() => searchInputEl?.focus(), 50)
if (focusKey) void focusItem(focusKey)
}
// model.load()'s draft fetch is async, so poll real frames (not ticks — the
// fetch is wall-clock) until the item lands in model.items, then force-mount its
// row so scrollToDiff has an anchor even below the fold on a cold drawer.
async function focusItem(focusKey: string) {
let frames = 0
let item = model.items.find((i) => i.key === focusKey)
while (!item && frames++ < 120) {
await new Promise<void>((r) => requestAnimationFrame(() => r()))
item = model.items.find((i) => i.key === focusKey)
}
if (!item) return
await tick()
// Force-mount bypasses use:lazyMount, which is what fetches the diff values.
mountedRows[item.key] = true
if (item.deployKind === 'raw_app') {
// Loading rekeys the app leaf to a folder at its displayPath, so highlight
// that key once loaded (as expandApp does), not the now-stale item.key.
await loadDiffFor(item)
await tick()
revealDiff(item, folderKeyFor(item.displayPath))
} else {
void loadDiffFor(item)
await tick()
revealDiff(item, item.key)
}
}
// ── Badge presentation ─────────────────────────────────────────────────────
@@ -1,5 +1,12 @@
import { describe, it, expect } from 'vitest'
import { draftFriendlyLeaf, parsePreviewItemRoute, resolvePreviewTab } from './previewRouter'
import {
artifactUrl,
draftFriendlyLeaf,
parseArtifactRoute,
parsePreviewItemRoute,
previewLocationLabel,
resolvePreviewTab
} from './previewRouter'
describe('parsePreviewItemRoute', () => {
it('maps edit/get routes to item kinds', () => {
@@ -97,4 +104,39 @@ describe('resolvePreviewTab', () => {
it('routes the bare pipeline list page to the iframe fallback', () => {
expect(resolvePreviewTab('/pipeline')).toEqual({ kind: 'iframe' })
})
it('routes an artifact url to the artifact slot by id (ignoring the name hash)', () => {
expect(resolvePreviewTab('artifact:abc%20123#My%20Doc')).toEqual({
kind: 'artifact',
id: 'abc 123'
})
})
})
describe('artifact route', () => {
it('round-trips id and name through artifactUrl → parseArtifactRoute, including special chars', () => {
for (const [id, name] of [
['abc', 'Onboarding plan'],
['id-with-dash', 'weird # % / name'],
['x', 'artifact:not-an-id#nope'],
['y', '']
] as const) {
expect(parseArtifactRoute(artifactUrl(id, name))).toEqual({ id, name })
}
})
it('parses a hash-less artifact url to an empty name', () => {
expect(parseArtifactRoute('artifact:abc')).toEqual({ id: 'abc', name: '' })
})
it('returns null for non-artifact urls', () => {
expect(parseArtifactRoute('/scripts/edit/f/foo/bar')).toBeNull()
expect(parseArtifactRoute('/runs')).toBeNull()
expect(parseArtifactRoute('artifactx:abc')).toBeNull()
})
it('labels an artifact tab by its name, falling back to "Artifact" when unnamed', () => {
expect(previewLocationLabel(artifactUrl('abc', 'My Doc'))).toBe('My Doc')
expect(previewLocationLabel('artifact:abc')).toBe('Artifact')
})
})
@@ -21,6 +21,7 @@ import type { SessionTargetKind } from './sessionRuntime.svelte'
export type PreviewTarget =
| { type: 'page'; href: string; label: string }
| { type: 'item'; item: WorkspaceItem }
| { type: 'artifact'; id: string; name: string }
export type PreviewPage = { label: string; path: string; icon: DrillIcon }
@@ -97,6 +98,8 @@ export function matchPreviewPage(path: string): PreviewPage | undefined {
* page, run detail, or item path. Shared by the sessions tab strip and the
* close_page matcher so both name a tab the same way. */
export function previewLocationLabel(url: string): string {
const artifact = parseArtifactRoute(url)
if (artifact) return artifact.name || 'Artifact'
const page = matchPreviewPage(url)
if (page) return page.label
const trigger = triggerLabelForPath(url)
@@ -150,6 +153,24 @@ export function parsePipelineRoute(fullPath: string): string | null {
return m ? decodeURIComponent(m[1]) : null
}
// The id (before the hash) is the artifact's stable routing identity; the name rides in
// the hash so the tab strip labels it without a store lookup.
export function parseArtifactRoute(url: string): { id: string; name: string } | null {
const m = url.match(/^artifact:([^#]+)(?:#(.*))?$/)
if (!m) return null
return { id: decodeURIComponent(m[1]), name: m[2] ? decodeURIComponent(m[2]) : '' }
}
export function artifactUrl(id: string, name: string): string {
return `artifact:${encodeURIComponent(id)}#${encodeURIComponent(name)}`
}
/** Drill-picker leaf key for an artifact, shared by the picker tree and the
* active-tab highlight so a pick and a highlight agree on identity. */
export const artifactKey = (id: string) => `artifact:${id}`
export const isArtifactKey = (key: string) => key.startsWith('artifact:')
// How a preview tab should render: as an in-process live editor or an iframe
// fallback. Any editable item of a wrappable kind (script, flow, raw app) mounts
// its per-(kind,path) cell editor; a `/pipeline/<folder>` route mounts the
@@ -158,9 +179,12 @@ export function parsePipelineRoute(fullPath: string): string | null {
// other route) stays an iframe.
export type PreviewSlot =
| { kind: 'editor'; editorKind: SessionTargetKind | 'pipeline'; path: string }
| { kind: 'artifact'; id: string }
| { kind: 'iframe' }
export function resolvePreviewTab(url: string): PreviewSlot {
const artifact = parseArtifactRoute(url)
if (artifact) return { kind: 'artifact', id: artifact.id }
const pipelineFolder = parsePipelineRoute(url)
if (pipelineFolder) return { kind: 'editor', editorKind: 'pipeline', path: pipelineFolder }
const route = parsePreviewItemRoute(url)
@@ -2,7 +2,9 @@ import { base } from '$lib/base'
import { randomUUID } from '$lib/utils/uuid'
import { editPathFor, type WorkspaceItem } from '$lib/components/workspacePicker'
import {
artifactUrl,
matchPreviewPage,
parseArtifactRoute,
parsePipelineRoute,
parsePreviewItemRoute,
previewLocationLabel,
@@ -43,9 +45,11 @@ function isEditorTabFor(url: string, target: SessionTarget): boolean {
return slot.kind === 'editor' && slot.editorKind === target.kind && slot.path === target.path
}
// URL a tab should load for a destination: a page's href, or an item's edit route.
// URL a tab should load for a destination: a page's href, an item's edit route, or an artifact's scheme.
function targetUrl(target: PreviewTarget): string {
return target.type === 'page' ? target.href : `${base}${editPathFor(target.item)}`
if (target.type === 'page') return target.href
if (target.type === 'artifact') return artifactUrl(target.id, target.name)
return `${base}${editPathFor(target.item)}`
}
// Point a tab at a new destination. Clears `friendlyLabel` (bound to the previous
@@ -151,6 +155,8 @@ export class SessionPreviewTabs {
#activeId = $state('')
#collapsed = $state(false)
#previewSize = $state<number | undefined>(undefined)
// Ephemeral UI signal — not part of the persisted snapshot.
#focusPulse = $state({ id: '', nonce: 0 })
readonly #adapter: PreviewTabsAdapter
readonly #flushDelay: number
#flushHandle: ReturnType<typeof setTimeout> | undefined
@@ -183,6 +189,15 @@ export class SessionPreviewTabs {
get previewSize(): number | undefined {
return this.#previewSize
}
get focusPulse(): { id: string; nonce: number } {
return this.#focusPulse
}
// The nonce makes each call a fresh value, so re-clicking the same active tab
// still fires the flash.
pulseFocus(id: string): void {
this.#focusPulse = { id, nonce: this.#focusPulse.nonce + 1 }
}
setPreviewSize(size: number): void {
if (this.#previewSize === size) return
@@ -226,6 +241,18 @@ export class SessionPreviewTabs {
return { status: same ? 'focused' : 'opened' }
}
}
// Dedupe artifacts by id, not full url: an update may have changed the name the url carries.
if (target.type === 'artifact') {
const existing = this.#tabs.find((t) => parseArtifactRoute(t.url)?.id === target.id)
if (existing) {
const same = existing.url === url
existing.url = url
existing.loc = url
this.#activeId = existing.id
this.#flush()
return { status: same ? 'focused' : 'opened' }
}
}
// Focus the tab currently *showing* this destination instead of opening a
// duplicate. Matched on the observed `loc`, not `url`: a tab that was
// opened here but navigated away no longer counts as showing it.
@@ -274,6 +301,18 @@ export class SessionPreviewTabs {
return
}
}
// Same by-id artifact dedupe as open(): focus (and re-point, in case the
// name changed) the tab already viewing this artifact instead of turning
// the active tab into a duplicate viewer.
if (target.type === 'artifact') {
const existing = this.#tabs.find((x) => parseArtifactRoute(x.url)?.id === target.id)
if (existing && existing.id !== t.id) {
retargetTab(existing, url)
this.#activeId = existing.id
this.#flush()
return
}
}
retargetTab(t, url)
this.#flush()
}
@@ -323,6 +362,11 @@ export class SessionPreviewTabs {
this.#flush()
}
closeArtifact(artifactId: string): void {
const tab = this.#tabs.find((t) => parseArtifactRoute(t.url)?.id === artifactId)
if (tab) this.close(tab.id)
}
setCollapsed(collapsed: boolean): void {
if (this.#collapsed === collapsed) return
this.#collapsed = collapsed
@@ -414,16 +458,19 @@ export function describePreview(tabs: SessionPreviewTab[], activeId: string): st
if (tabs.length === 0) return 'No preview tabs are open in the side panel.'
const lines = tabs.map((t) => {
const where = t.loc || t.url
const artifact = parseArtifactRoute(where)
const page = matchPreviewPage(where)
const pipelineFolder = parsePipelineRoute(where)
const route = parsePreviewItemRoute(where)
const label = page
? `page "${page.label}"`
: pipelineFolder
? `pipeline "${pipelineFolder}"`
: route
? `${route.raw_app ? 'raw_app' : route.kind} "${route.itemPath}"`
: stripBase(where)
const label = artifact
? `artifact "${artifact.name || 'Artifact'}"`
: page
? `page "${page.label}"`
: pipelineFolder
? `pipeline "${pipelineFolder}"`
: route
? `${route.raw_app ? 'raw_app' : route.kind} "${route.itemPath}"`
: stripBase(where)
const live = resolvePreviewTab(t.url).kind === 'editor' ? ', live editor' : ''
const active = t.id === activeId ? ', active' : ''
return `- ${label}${live}${active}`
@@ -8,7 +8,7 @@ import {
type PreviewTabsAdapter,
type PreviewTabsSnapshot
} from './sessionPreviewTabs.svelte'
import type { PreviewTarget } from './previewRouter'
import { artifactUrl, type PreviewTarget } from './previewRouter'
import type { SessionPreviewTab } from './sessionState.svelte'
import { base } from '$lib/base'
@@ -53,6 +53,7 @@ const pipelineTarget2: PreviewTarget = {
href: `${base}/pipeline/sales`,
label: 'sales'
}
const artifactTarget: PreviewTarget = { type: 'artifact', id: 'art1', name: 'Plan' }
beforeEach(() => {
vi.useFakeTimers()
@@ -225,6 +226,44 @@ describe('SessionPreviewTabs.open', () => {
o.open(dndAppTarget)
expect(o.tabs[0].url).toBe('/apps/edit/u/me/legacy')
})
it('opens an artifact tab keyed by its synthetic url and reveals the panel', () => {
const o = owner({ collapsed: true })
const res = o.open(artifactTarget)
expect(res.status).toBe('opened')
expect(o.tabs).toHaveLength(1)
expect(o.tabs[0].url).toBe(artifactUrl('art1', 'Plan'))
expect(o.collapsed).toBe(false)
})
it('dedupes an artifact by id: re-opening focuses the same tab', () => {
const o = owner()
o.open(artifactTarget)
const id = o.tabs[0].id
const res = o.open(artifactTarget)
expect(res.status).toBe('focused')
expect(o.tabs).toHaveLength(1)
expect(o.activeId).toBe(id)
})
it('re-points the same tab (no duplicate) when the artifact was renamed', () => {
const o = owner()
o.open(artifactTarget)
const id = o.tabs[0].id
const res = o.open({ type: 'artifact', id: 'art1', name: 'Renamed plan' })
expect(o.tabs).toHaveLength(1)
expect(o.tabs[0].id).toBe(id)
expect(o.tabs[0].url).toBe(artifactUrl('art1', 'Renamed plan'))
// URL changed (name), so the tab content differs → 'opened', not 'focused'.
expect(res.status).toBe('opened')
})
it('opens separate tabs for different artifact ids', () => {
const o = owner()
o.open(artifactTarget)
o.open({ type: 'artifact', id: 'art2', name: 'Other' })
expect(o.tabs).toHaveLength(2)
})
})
describe('SessionPreviewTabs.navigate', () => {
@@ -291,6 +330,32 @@ describe('SessionPreviewTabs.navigate', () => {
expect(o.tabs[0].url).toBe(`${base}/pipeline/sales`)
})
it('focuses the tab already viewing the artifact instead of duplicating the viewer', () => {
const o = owner()
o.open(artifactTarget)
const artifactTabId = o.activeId
o.open(pageTarget)
const pageTabId = o.activeId
o.navigate({ type: 'artifact', id: 'art1', name: 'Renamed plan' })
expect(o.tabs).toHaveLength(2)
expect(o.activeId).toBe(artifactTabId)
// Focus moved and the viewer tab picked up the rename; the page tab kept its url.
expect(o.tabs.find((t) => t.id === artifactTabId)?.url).toBe(
artifactUrl('art1', 'Renamed plan')
)
expect(o.tabs.find((t) => t.id === pageTabId)?.url).toBe('/runs')
})
it('retargets the active tab in place to an artifact', () => {
const o = owner()
o.open(pageTarget)
const tabId = o.activeId
o.navigate(artifactTarget)
expect(o.tabs).toHaveLength(1)
expect(o.activeId).toBe(tabId)
expect(o.tabs[0].url).toBe(artifactUrl('art1', 'Plan'))
})
it('drops a stale friendly label when the tab is retargeted', () => {
const o = owner()
o.open(flowTarget)
@@ -340,6 +405,22 @@ describe('SessionPreviewTabs.select / close / setCollapsed', () => {
expect(o.activeId).toBe('')
})
it('closeArtifact closes the tab showing that artifact, leaving others', () => {
const o = owner()
o.open(artifactTarget) // id 'art1'
o.open(scriptTarget)
expect(o.tabs).toHaveLength(2)
o.closeArtifact('art1')
expect(o.tabs.map((t) => t.url)).toEqual(['/scripts/edit/u/me/foo'])
})
it('closeArtifact is a no-op for an unknown artifact id', () => {
const o = owner()
o.open(artifactTarget)
o.closeArtifact('nope')
expect(o.tabs).toHaveLength(1)
})
it('toggles collapsed', () => {
const o = owner({ collapsed: false })
o.setCollapsed(true)
@@ -520,6 +601,14 @@ describe('describePreview', () => {
expect(out).toContain('page "Runs"')
expect(out).not.toContain('live editor')
})
it('labels an artifact tab by name, not the raw artifact url', () => {
const url = artifactUrl('uuid-1', 'My Plan')
const out = describePreview([{ id: 'a', url, loc: url }], 'a')
expect(out).toContain('artifact "My Plan"')
expect(out).not.toContain('artifact:uuid-1')
expect(out).not.toContain('live editor')
})
})
describe('selectPreviewTabsToClose', () => {
@@ -552,3 +641,16 @@ describe('selectPreviewTabsToClose', () => {
expect(selectPreviewTabsToClose(tabs, { all: false, match: 'nonexistent' })).toEqual([])
})
})
describe('SessionPreviewTabs.pulseFocus', () => {
it('sets the id and advances the nonce, re-firing for the same id', () => {
const o = owner()
expect(o.focusPulse).toEqual({ id: '', nonce: 0 })
o.pulseFocus('tab-a')
expect(o.focusPulse).toEqual({ id: 'tab-a', nonce: 1 })
o.pulseFocus('tab-a')
expect(o.focusPulse).toEqual({ id: 'tab-a', nonce: 2 })
o.pulseFocus('tab-b')
expect(o.focusPulse).toEqual({ id: 'tab-b', nonce: 3 })
})
})
@@ -425,6 +425,20 @@ function createRuntime(session: Session): SessionRuntime {
})
}
manager.openArtifact = (id, name) => {
// Capture before open() un-collapses / re-activates: flash only when the tab
// was already the displayed one (nothing else visibly changes).
const wasDisplayed = !previewTabs.collapsed
const prevActive = previewTabs.activeId
const { status } = previewTabs.open({ type: 'artifact', id, name })
if (status === 'focused' && wasDisplayed && previewTabs.activeId === prevActive) {
previewTabs.pulseFocus(previewTabs.activeId)
}
}
manager.closeArtifact = (id) => previewTabs.closeArtifact(id)
// Key the store before any configureGlobalMode runs, so a new session's first create shows at once.
void manager.artifacts.setSession(session.id)
// Pipeline target state lives on the runtime (not the PipelineEditorView
// component) so the in-session drafts survive hide/show of the editor pane —
// the pane unmounts on hide, and a component-local store would be discarded.
@@ -22,6 +22,7 @@ import { workspaceRootId } from './sessionScope.svelte'
import { type DBSchema, type IDBPDatabase } from 'idb'
import { userScopedDb } from '$lib/userScopedDb'
import { deleteItemsForSession } from '../copilot/chat/files/attachedFilesDB'
import { deleteArtifactsForSession } from '../copilot/chat/artifacts/artifactsDB'
// Switch the global workspace iff the target differs from the active one
// and is non-empty. Centralises the "session needs its workspace in focus"
@@ -488,6 +489,7 @@ export async function reconcileSessionsLifecycle(): Promise<void> {
// GC linked files too, matching deleteSession — a record-only delete
// here would orphan the session's attached-file blobs/handles.
void deleteItemsForSession(s.id)
void deleteArtifactsForSession(s.id)
deletedIds.add(s.id)
continue
}
@@ -579,6 +581,7 @@ export async function deleteSessionsForWorkspace(workspaceId: string): Promise<v
// GC linked files too (matches deleteSession) so a workspace teardown
// doesn't leave the sessions' attached-file blobs/handles orphaned.
void deleteItemsForSession(id)
void deleteArtifactsForSession(id)
}
sessionState.sessions = sessionState.sessions.filter((s) => !ids.has(s.id))
if (sessionState.currentSessionId && ids.has(sessionState.currentSessionId)) {
@@ -966,8 +969,9 @@ export function deleteSession(id: string) {
sessionState.currentSessionId = sessionState.sessions[0]?.id
}
void deleteSessionRecord(id)
// GC any linked files persisted for this session.
// GC any linked files and artifacts persisted for this session.
void deleteItemsForSession(id)
void deleteArtifactsForSession(id)
}
export function setSessionChatId(sessionId: string, chatId: string) {
@@ -15,6 +15,14 @@ vi.mock('../copilot/chat/files/attachedFilesDB', async (orig) => ({
deleteItemsForSession: deleteItemsForSessionMock
}))
const { deleteArtifactsForSessionMock } = vi.hoisted(() => ({
deleteArtifactsForSessionMock: vi.fn()
}))
vi.mock('../copilot/chat/artifacts/artifactsDB', async (orig) => ({
...(await orig<typeof import('../copilot/chat/artifacts/artifactsDB')>()),
deleteArtifactsForSession: deleteArtifactsForSessionMock
}))
// sessionState imports WorkspaceService; these tests don't touch the network.
vi.mock('$lib/gen', async (orig) => {
const actual = await orig<typeof import('$lib/gen')>()
@@ -398,11 +406,14 @@ describe('sessionState IndexedDB persistence', () => {
await vi.waitFor(() => expect(sessionState.sessions.length).toBe(2))
deleteItemsForSessionMock.mockClear()
deleteArtifactsForSessionMock.mockClear()
await deleteSessionsForWorkspace('wsX')
// Both deleted sessions' linked files must be GC'd, not just their records.
// Both deleted sessions' linked files and artifacts must be GC'd, not just their records.
const cleaned = deleteItemsForSessionMock.mock.calls.map((c) => c[0]).sort()
expect(cleaned).toEqual(['f1', 'f2'])
const cleanedArtifacts = deleteArtifactsForSessionMock.mock.calls.map((c) => c[0]).sort()
expect(cleanedArtifacts).toEqual(['f1', 'f2'])
})
it('does not persist a per-session unarchive when the workspace is gone (resurrection guard)', async () => {
@@ -9,6 +9,7 @@
import Checkbox from '../common/checkbox/Checkbox.svelte'
import Markdown from 'svelte-exmarkdown'
import { gfmPlugin } from 'svelte-exmarkdown/gfm'
import { markdownProse } from '$lib/components/markdownProse'
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
import SettingCard from '../instanceSettings/SettingCard.svelte'
@@ -563,14 +564,7 @@
{#if viewParsed.description}
<p class="text-xs text-secondary">{viewParsed.description}</p>
{/if}
<div
class="border rounded-md p-3 overflow-auto max-h-[60vh] prose prose-sm dark:prose-invert max-w-full leading-snug space-y-2 prose-ul:!pl-6
prose-p:text-xs prose-li:text-xs prose-code:text-xs prose-pre:text-xs
prose-code:break-words prose-a:break-words
prose-headings:font-medium prose-headings:text-emphasis prose-headings:mt-3 prose-headings:mb-1
prose-h1:text-sm prose-h2:text-xs prose-h3:text-xs prose-h4:text-xs prose-h5:text-xs prose-h6:text-xs
prose-table:block prose-table:max-w-full prose-table:overflow-x-auto prose-table:text-xs"
>
<div class="border rounded-md p-3 overflow-auto max-h-[60vh] space-y-2 {markdownProse.sm}">
<Markdown md={viewParsed.instructions} plugins={[gfmPlugin()]} />
</div>
</div>
@@ -46,8 +46,10 @@
import { setToolCompletionListener } from '$lib/components/copilot/chat/shared'
import { base } from '$lib/base'
import {
artifactKey,
matchPreviewPage,
pageKey,
parseArtifactRoute,
parsePreviewItemRoute,
previewLocationLabel,
type PreviewTarget
@@ -343,6 +345,12 @@
// Page path shown after the workspace breadcrumb — the active tab's observed
// location, so the breadcrumb tracks where the user browses inside the tab.
const displayPath = $derived(owner?.activeTab?.loc ?? owner?.activeTab?.url ?? `${base}/`)
// Artifacts have no workspace page, so "Open in workspace" can't resolve for them.
const activeArtifact = $derived(owner?.activeTab ? parseArtifactRoute(owner.activeTab.url) : null)
const activeTabIsArtifact = $derived(activeArtifact != null)
// The active session's artifacts, surfaced as an "Artifacts" branch in the
// preview pickers.
const sessionArtifacts = $derived(activeRuntime?.manager.artifacts.artifacts ?? [])
// Writes to the tab's own session model: a hidden warm session's iframe can
// finish loading while another session is shown, and its location must not
// land on the visible session's tabs.
@@ -456,7 +464,9 @@
? leafKeyFor(parsedRoute.kind, parsedRoute.itemPath)
: currentPage
? pageKey(currentPage.path)
: undefined
: activeArtifact
? artifactKey(activeArtifact.id)
: undefined
)
let activeTabPickerOpen = $state(false)
@@ -636,17 +646,19 @@
<!-- Open-in-full-page + full-screen toggle, floating over the top-right
corner to mirror the collapse control. -->
<div class="absolute top-1 right-1 z-30 flex items-center gap-0.5">
<a
href={withWorkspaceParam(
owner?.activeTab?.loc || owner?.activeTab?.url || `${base}/`,
previewWorkspace
)}
title="Open in workspace"
aria-label="Open in workspace"
class="inline-flex items-center justify-center w-6 h-6 rounded text-tertiary hover:text-primary hover:bg-surface-hover"
>
<ExternalLink size={14} />
</a>
{#if !activeTabIsArtifact}
<a
href={withWorkspaceParam(
owner?.activeTab?.loc || owner?.activeTab?.url || `${base}/`,
previewWorkspace
)}
title="Open in workspace"
aria-label="Open in workspace"
class="inline-flex items-center justify-center w-6 h-6 rounded text-tertiary hover:text-primary hover:bg-surface-hover"
>
<ExternalLink size={14} />
</a>
{/if}
<button
type="button"
onclick={() => (fullscreen = !fullscreen)}
@@ -699,6 +711,7 @@
initialHighlight={activePickerHighlight}
{currentItem}
workspaceId={previewWorkspace}
artifacts={sessionArtifacts}
onPick={(t) => {
activeTabPickerOpen = false
navigatePreviewTo(t)
@@ -727,6 +740,7 @@
{#snippet content()}
<PreviewRouterPicker
workspaceId={previewWorkspace}
artifacts={sessionArtifacts}
onPick={(t) => {
newTabOpen = false
openInNewTab(t)
@@ -801,6 +815,7 @@
{#snippet content()}
<PreviewRouterPicker
workspaceId={previewWorkspace}
artifacts={sessionArtifacts}
onPick={(t) => {
emptyStateNewTabOpen = false
openInNewTab(t)