mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 08:01:26 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user