feat(sessions): open deployed pages from the preview panel

The preview panel showed an item's editor and nothing else. It now shows the
deployed page too, as a tab of its own, and that page is where a deployed run
happens.

**Two sides, two tabs.** The editor and the deployed page are different things
to look at, so `isItemTabFor` keys on the side rather than the item: asking for
a side already open focuses it, asking for the other opens it alongside. Two
editors for one item still cannot coexist — they would race its single
(kind, path) cell — but an editor beside a viewer is safe, since the viewer
reads the deployed version over the API and holds no cell. The tab breadcrumb
keeps re-pointing in place; `Exit & see details` and the detail page's `Edit`
open the other side instead of consuming the one you are on.

**The picker opens what exists.** A row opens the deployed page, or the editor
when nothing is deployed there, and carries a Draft / Draft only badge in the
review dock's words. `WorkspaceItem` gained `draftOnly` and `hasDraft` from the
listers, which already returned both; deploys now invalidate the picker cache,
so a just-deployed item stops reading as a draft. Rows also carry a hover Edit
action, reachable from the keyboard with the pick modifier.

**Deployed runs adopt the page.** `run_script` and `run_flow` open the item's
own deployed page with the model's arguments in its run form, announced by the
strip that says the agent is waiting and declines the call. Run hands those
arguments to the parked call rather than starting a job, so the tool still owns
the run and the chat card still gets its logs; the tab then becomes that run.
Test runs keep the chat's own form — they execute the draft, which the deployed
page would not.

`open_preview` gained a `mode`, narrowed out of the advertised schema for a
session that cannot write drafts and re-checked per path in the handler, where
a refusal reports "couldn't check" apart from "denied".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-09-30 02:32:59 -07:00
co-authored by Claude Opus 5
parent bf31cb38fe
commit 21eb5d28bd
29 changed files with 786 additions and 127 deletions
+6
View File
@@ -35898,6 +35898,12 @@ components:
True when the authed user has a draft for this app — either no
deployed row exists at this path (draft-only) or the user has
saved a per-user draft on top of the deployed row.
draft_only:
type: boolean
description: |
Nothing is deployed at this path. Only ever true on the rows
`include_draft_only=true` synthesizes from a draft, so a caller
that did not ask for those never sees it.
draft_path:
type: string
description: |
+104 -40
View File
@@ -28,9 +28,13 @@ leaves and ignores the current scope.
resolveScope,
scopeChain,
type DrillBranch,
type DrillIcon,
type DrillLeaf,
type DrillNode
} from './drillPicker'
import Badge from '$lib/components/common/badge/Badge.svelte'
import type { BadgeColor } from '$lib/components/common/badge/model'
import Button from '$lib/components/common/button/Button.svelte'
interface Props {
tree: DrillNode<L>[]
@@ -72,6 +76,18 @@ leaves and ignores the current scope.
/** Full-text hover tooltip for a leaf row (rows truncate their text).
* Return `undefined` to show none for that leaf. */
rowTooltip?: (leaf: DrillLeaf<L>) => string | undefined
/** A second thing a leaf row can do, offered on hover and on ⌘/Ctrl-pick
* (the preview picker's "open the editor instead of the deployed page").
* Return `undefined` for a leaf that has only one. The button renders as a
* SIBLING of the row — a button inside a button is invalid markup — and
* stays out of the tab order, because focus lives in the search field and
* rows are reached with the arrow keys. */
leafAction?: (
leaf: DrillLeaf<L>
) => { icon: DrillIcon; label: string; title: string; run: () => void } | undefined
/** Status badge on the right of a leaf row. Returns `undefined` for a leaf with
* nothing to report. The hover action, when there is one, covers it. */
leafBadge?: (leaf: DrillLeaf<L>) => { label: string; color: BadgeColor } | undefined
}
let {
@@ -88,7 +104,9 @@ leaves and ignores the current scope.
onScopeChange,
onFilterChange,
rootLoading = false,
rowTooltip
rowTooltip,
leafAction,
leafBadge
}: Props = $props()
let searchInput: TextInput | undefined = $state()
@@ -277,8 +295,18 @@ leaves and ignores the current scope.
if (mouseActive) highlightedKey = key
}
function pick(leaf: DrillLeaf<L>) {
/** `alt` picks the row's second action (see `leafAction`) rather than the row
* itself — ⌘/Ctrl-click and ⌘/Ctrl-Enter, the keyboard reach for the button
* that only appears on hover. A leaf with no second action ignores it. */
function pick(leaf: DrillLeaf<L>, alt = false) {
if (leaf.current || leaf.disabled) return
if (alt) {
const action = leafAction?.(leaf)
if (action) {
action.run()
return
}
}
onPick(leaf)
}
@@ -316,23 +344,23 @@ leaves and ignores the current scope.
$effect(() => () => clearTimeout(tooltipTimer))
function activate(key: string | undefined) {
function activate(key: string | undefined, alt = false) {
if (!key) return
if (isSearching) {
const found = (searchedItems ?? []).find((r) => r.leaf.key === key)
if (found) pick(found.leaf)
if (found) pick(found.leaf, alt)
return
}
const entry = entryList.find((e) => e.key === key)
if (!entry) return
drill(entry)
drill(entry, alt)
}
function drill(entry: Entry) {
function drill(entry: Entry, alt = false) {
if (entry.type === 'branch') {
scope = [...scope, entry.key]
} else {
pick(entry.node)
pick(entry.node, alt)
}
}
@@ -362,7 +390,7 @@ leaves and ignores the current scope.
e.preventDefault()
e.stopPropagation()
mouseActive = false
activate(highlightedKey)
activate(highlightedKey, e.metaKey || e.ctrlKey)
} else if (
(e.key === 'ArrowLeft' || e.key === 'Backspace') &&
filter === '' &&
@@ -476,42 +504,78 @@ leaves and ignores the current scope.
{@const isHl = key === highlightedKey}
{@const isCur = !!leaf.current}
{@const tip = rowTooltip?.(leaf)}
<button
type="button"
id={idFor(key)}
role="option"
aria-selected={isHl}
data-nav-key={key}
aria-current={isCur ? 'true' : undefined}
aria-label={tip ? `${leaf.label} — ${tip}` : undefined}
class="w-full text-left flex items-center gap-2 px-3 transition-colors {baseClass} {isHl
{@const action = leaf.disabled ? undefined : leafAction?.(leaf)}
{@const badge = leafBadge?.(leaf)}
<!-- The row's action sits BESIDE the option rather than inside it: a button nested in
a button is invalid markup. The wrapper carries the row's padding, highlight and
hover group so the two still read as one row. -->
<div
class="group flex items-center transition-colors pr-3 {baseClass} {isHl
? 'bg-surface-hover'
: ''} {isCur ? 'cursor-default text-emphasis font-medium' : ''} {leaf.disabled
? 'opacity-50 cursor-not-allowed'
: ''}"
disabled={leaf.disabled}
onmousedown={(e) => e.preventDefault()}
onclick={() => pick(leaf)}
onmouseenter={(e) => {
setHoverHighlight(key)
tooltipEnter(e.currentTarget as HTMLElement, leaf)
}}
onmouseleave={tooltipLeave}
>
{@render defaultLeafIcon(leaf)}
<div class="min-w-0 flex-1">
{#if leaf.secondary}
<div class="text-xs text-primary font-normal truncate">{leaf.label}</div>
<div class="text-2xs text-hint font-normal font-mono truncate">
{secondary ?? leaf.secondary}
</div>
{:else}
<div class="text-xs text-primary font-normal font-mono truncate">
{secondary ?? leaf.label}
</div>
{/if}
</div>
</button>
<button
type="button"
id={idFor(key)}
role="option"
aria-selected={isHl}
data-nav-key={key}
aria-current={isCur ? 'true' : undefined}
aria-label={tip ? `${leaf.label} — ${tip}` : undefined}
class="min-w-0 flex-1 text-left flex items-center gap-2 pl-3 {isCur
? 'cursor-default text-emphasis font-medium'
: ''} {leaf.disabled ? 'opacity-50 cursor-not-allowed' : ''}"
disabled={leaf.disabled}
onmousedown={(e) => e.preventDefault()}
onclick={(e) => pick(leaf, e.metaKey || e.ctrlKey)}
onmouseenter={(e) => {
setHoverHighlight(key)
tooltipEnter(e.currentTarget as HTMLElement, leaf)
}}
>
{@render defaultLeafIcon(leaf)}
<div class="min-w-0 flex-1">
{#if leaf.secondary}
<div class="text-xs text-primary font-normal truncate">{leaf.label}</div>
<div class="text-2xs text-hint font-normal font-mono truncate">
{secondary ?? leaf.secondary}
</div>
{:else}
<div class="text-xs text-primary font-normal font-mono truncate">
{secondary ?? leaf.label}
</div>
{/if}
</div>
</button>
<!-- Keyed on the props rather than on this leaf's own badge or action, so a picker
that has either holds the column open on every row: paths then truncate at one
column and the button lands in the same place down the list. -->
{#if leafBadge || leafAction}
<div class="shrink-0 w-[4.5rem] flex justify-end pl-2">
{#if action}
<!-- Replaces the badge on hover. `hidden` rather than transparent so it is
not a tab stop while out of sight — the picker's focus belongs to its
search field, and rows are reached with the arrow keys. -->
<Button
unifiedSize="2xs"
variant="default"
title={action.title}
startIcon={{ icon: action.icon }}
onClick={action.run}
wrapperClasses="shrink-0 hidden group-hover:block"
>
{action.label}
</Button>
{/if}
{#if badge}
<div class={action ? 'group-hover:hidden' : ''}>
<Badge small color={badge.color}>{badge.label}</Badge>
</div>
{/if}
</div>
{/if}
</div>
{/snippet}
<!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -557,6 +557,17 @@ export class AIChatManager implements ChatViewHost {
* chat card holds. Unset outside a session: a chat-bound form has nowhere else to go,
* so the card hides the control rather than offering a tab that cannot run. */
openRunForm?: (a: { toolCallId: string; label: string }) => void
/** A DEPLOYED run instead shows the item's own deployed page, with these arguments in
* the run form that is already there — the reader runs it where they would have run it
* themselves, seeing its triggers and past runs. The page hands the arguments back to
* this call rather than starting a job, so the tool still owns the run. */
openRunOnDeployedPage?: (a: {
toolCallId: string
kind: 'script' | 'flow' | 'raw_app'
path: string
summary: string
args: Record<string, any>
}) => void
closeRunForm?: (toolCallId: string) => void
/** Hands that tab from the form to the run it just started, in place: the tab keeps its
* position in the strip and stays active if it was. */
@@ -373,25 +373,45 @@
// waiting on one, the run once a job exists. Neither, and there is nothing to open, so
// the button is not drawn at all — a form has nowhere to go outside a session, and a
// call cancelled before Run never became a run.
// A deployed run is shown on the item's own deployed page rather than a form of our own:
// the reader runs it where they would have run it themselves. A test run has no deployed
// page to show — it runs the draft being written — so it keeps the form.
const asDeployedPage = $derived(
pending && runForm?.kind === 'run' && !!path && !!aiChatManager.openRunOnDeployedPage
)
const previewTarget = $derived(
pending
? aiChatManager.openRunForm
? ('form' as const)
: undefined
? asDeployedPage
? ('deployed' as const)
: aiChatManager.openRunForm
? ('form' as const)
: undefined
: job
? ('run' as const)
: undefined
)
const previewTitle = $derived(
previewTarget === 'form'
? `Open this form in the preview panel: ${path}`
: aiChatManager.openRunInPreview
? `Open this run in the preview panel: ${path || runnableName}`
: `Open this run in a new tab: ${path || runnableName}`
previewTarget === 'deployed'
? `Open the deployed ${runnableKind === 'flow' ? 'flow' : 'script'} and its run form: ${path}`
: previewTarget === 'form'
? `Open this form in the preview panel: ${path}`
: aiChatManager.openRunInPreview
? `Open this run in the preview panel: ${path || runnableName}`
: `Open this run in a new tab: ${path || runnableName}`
)
function openPreview() {
const label = runnableName
if (previewTarget === 'deployed') {
aiChatManager.openRunOnDeployedPage?.({
toolCallId: message.tool_call_id,
kind: runnableKind === 'flow' ? 'flow' : 'script',
path,
summary: label,
args: aiChatManager.runFormDraft(message.tool_call_id, runForm!).args ?? {}
})
return
}
if (previewTarget === 'form') {
aiChatManager.openRunForm?.({ toolCallId: message.tool_call_id, label })
return
@@ -424,11 +444,14 @@
on its way in, the run on its way out. Not a toggle — pressing it again focuses the
tab it already opened. The row's only control, as on every other tool call. -->
{#snippet previewChip()}
<!-- Every destination here is something to look at rather than edit — a form to fill, a
deployed page, a run — so the chip carries the eye the preview tabs use. -->
<ToolPreviewCard
card={{ kind: runnableKind, path }}
title={previewTitle}
onOpen={openPreview}
kindIcon={false}
mode="view"
/>
{/snippet}
@@ -1,5 +1,5 @@
<script lang="ts">
import { PanelRight } from 'lucide-svelte'
import { Eye, Pen } from 'lucide-svelte'
import { Button } from '$lib/components/common'
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
import type { IconType } from '$lib/utils'
@@ -15,9 +15,13 @@
/** The kind icon says what the chip opens. A card that already names its own runnable
* in the row above has said it, and repeating it there reads as a second subject. */
kindIcon?: boolean
/** Which side of the item the chip opens, shown as the same pen/eye the preview
* tabs use. Defaults to the editor: a card sits on a write-tool result, and the
* draft it just wrote is what the reader is being offered. */
mode?: 'edit' | 'view'
}
let { card, onOpen, title, kindIcon = true }: Props = $props()
let { card, onOpen, title, kindIcon = true, mode = 'edit' }: Props = $props()
const kindLabel = $derived(card.kind === 'raw_app' ? 'app' : card.kind)
@@ -30,7 +34,7 @@
}
opening = true
try {
await runToolDisplayAction(openItemPreviewAction(card.kind, card.path))
await runToolDisplayAction(openItemPreviewAction(card.kind, card.path, mode))
} finally {
opening = false
}
@@ -41,12 +45,15 @@
variant="default"
unifiedSize="2xs"
disabled={opening}
title={title ?? `Open ${kindLabel} preview: ${card.path}`}
title={title ??
(mode === 'view'
? `Open the deployed ${kindLabel}: ${card.path}`
: `Open ${kindLabel} preview: ${card.path}`)}
onClick={open}
startIcon={kindIcon
? { icon: RowIcon as unknown as IconType, props: { kind: card.kind, size: 12 } }
: undefined}
endIcon={{ icon: PanelRight }}
endIcon={{ icon: mode === 'view' ? Eye : Pen }}
wrapperClasses="shrink-0"
>
<!-- The chip renders inside the tool row's font-mono scope; the label is UI text.
@@ -24,7 +24,8 @@ import {
} from '$lib/gen'
import { createTwoFilesPatch } from 'diff'
import { deepEqual } from 'fast-equals'
import type { ArtifactVersionTarget } from '$lib/components/sessions/previewRouter'
import { promptSafe, type ArtifactVersionTarget } from '$lib/components/sessions/previewRouter'
import { canWrite } from '$lib/utils'
import { $ScriptLang } from '$lib/gen/schemas.gen'
import type {
AppWithLastVersion,
@@ -1158,9 +1159,34 @@ const openPreviewSchema = z.object({
),
path: z
.string()
.describe('Workspace path of the item to preview, or the folder name when kind is "pipeline".')
.describe('Workspace path of the item to preview, or the folder name when kind is "pipeline".'),
mode: z
.enum(['edit', 'view'])
.optional()
.describe(
'Which side to show. Defaults to "edit", the editor for the draft you just wrote — which is what this tool is usually for. Pass "view" for the deployed page instead: its run form, triggers and past runs, for showing the user an item that already exists rather than your own changes to it. Ignored for kind="pipeline", which has only an editor.'
)
})
/** Advertised `mode` for a session that cannot write drafts: the editor is not a side
* it can open, so it is not offered. Per-PATH write permission is unknowable here —
* a workspace capability says nothing about `f/finance/*` — and is resolved in the
* handler instead. Mirrors `open_page` re-narrowing its page enum per user. */
const openPreviewViewOnlySchema = openPreviewSchema.extend({
mode: z
.literal('view')
.optional()
.describe(
'Only "view" — the deployed page with its run form and triggers. You cannot open editors in this workspace.'
)
})
const OPEN_PREVIEW_DESCRIPTION =
'Open the live preview / editor for a workspace item in the side panel next to the chat. ONLY works inside an AI session — call this after writing or editing a script, flow, or raw app to let the user see and interact with it. The path you pass is the path of the item; for code-based apps use kind="raw_app" (legacy drag-and-drop apps are not previewable). Returns an error if there is no active session.'
const OPEN_PREVIEW_VIEW_ONLY_DESCRIPTION =
'Show a workspace item in the side panel next to the chat, as its deployed page — the run form, triggers and past runs the user can act on. ONLY works inside an AI session. The path you pass is the path of the item; for code-based apps use kind="raw_app" (legacy drag-and-drop apps are not previewable). You cannot open item editors in this workspace, so use this to show the user something that is already deployed. Returns an error if there is no active session.'
const getPreviewStatusSchema = z.object({})
const closePageSchema = z.object({
@@ -1460,6 +1486,10 @@ ${pipelineBullet}`
- Keep context targeted.${
previewTools
? `${when(
!canWriteDraft,
`
- open_preview(kind, path) shows a deployed script / flow / app in the side panel next to the chat — its run form, triggers and past runs, which the user can act on there. Use it when you surface an item they will want to run or inspect. You cannot open item editors in this workspace, so it only ever opens the deployed page.`
)}${when(
canWriteDraft,
`
- After writing or substantially editing a script / flow / app draft, show it via open_preview(kind, path) so the user sees the editor and live preview right next to the chat. First check whether it is already shown: if unsure, call get_preview_status. Only call open_preview (or offer to) when no preview is open or it is showing a different item — don't re-open a preview already showing the item you just edited.
@@ -4409,14 +4439,27 @@ export const globalTools: SessionTool<{}>[] = [
...artifactTools,
{
requires: NONE,
def: createToolDef(
openPreviewSchema,
'open_preview',
'Open the live preview / editor for a workspace item in the side panel next to the chat. ONLY works inside an AI session — call this after writing or editing a script, flow, or raw app to let the user see and interact with it. The path you pass is the path of the item; for code-based apps use kind="raw_app" (legacy drag-and-drop apps are not previewable). Returns an error if there is no active session.'
),
def: createToolDef(openPreviewSchema, 'open_preview', OPEN_PREVIEW_DESCRIPTION),
// Withhold the editor side from a session that cannot write drafts, so the model
// never offers the user a panel it would not get.
schemaFor: async (helpers) => {
const access = (helpers as GlobalToolHelpers | undefined)?.access
return access && !access.has('write_draft')
? createToolDef(
openPreviewViewOnlySchema,
'open_preview',
OPEN_PREVIEW_VIEW_ONLY_DESCRIPTION
)
: createToolDef(openPreviewSchema, 'open_preview', OPEN_PREVIEW_DESCRIPTION)
},
fn: async (ctx) => {
const parsed = openPreviewSchema.parse(ctx.args)
return openSessionPreview(parsed, sessionIdFromCtx(ctx))
return openSessionPreview(
parsed,
sessionIdFromCtx(ctx),
operatingWorkspaceFromHelpers(ctx.helpers),
(ctx.helpers as GlobalToolHelpers | undefined)?.access
)
}
},
{
@@ -4718,6 +4761,9 @@ export type OpenPreviewHandler = (req: {
sessionId: string | undefined
kind: 'script' | 'flow' | 'raw_app' | 'pipeline'
path: string
/** Which side to open. Omitted lets the session decide from what it holds:
* the editor for an item it has edits for, the deployed page otherwise. */
mode?: 'edit' | 'view'
}) => string | Promise<string>
let openPreviewHandler: OpenPreviewHandler | undefined
@@ -4727,18 +4773,59 @@ export function setOpenPreviewHandler(handler: OpenPreviewHandler | undefined):
}
async function openSessionPreview(
args: { kind: 'script' | 'flow' | 'raw_app' | 'pipeline'; path: string },
sessionId: string | undefined
args: { kind: 'script' | 'flow' | 'raw_app' | 'pipeline'; path: string; mode?: 'edit' | 'view' },
sessionId: string | undefined,
workspace: string | undefined,
access: SessionAccess | undefined
): Promise<string> {
if (!openPreviewHandler) {
return 'Error: open_preview is only available inside an AI session. Tell the user to switch to a session to view the preview, or describe the item textually.'
}
// open_preview only exists in sessions, so no sessionId check is needed here.
// A pipeline has only an editor, and its own capability gates already cover it.
if (args.kind === 'pipeline') {
return await openPreviewHandler({ ...args, mode: undefined, sessionId })
}
// The advertised schema already withholds 'edit' from a session that cannot write
// drafts; re-check so a model asking outside the enum can't act outside it either.
if (args.mode === 'edit' && access && !access.has('write_draft')) {
const opened = await openPreviewHandler({ ...args, mode: 'view', sessionId })
return `${opened}\nOpened the deployed page instead: you cannot open item editors in this workspace.`
}
// `write_draft` is a workspace capability and says nothing about this path, so an
// edit that survived it is still checked against the item's own permissions.
if (args.mode === 'edit' && workspace) {
const verdict = await canEditItemPath(workspace, args.path)
if (verdict !== 'allowed') {
const opened = await openPreviewHandler({ ...args, mode: 'view', sessionId })
// "Couldn't check" is not "denied" — reporting the lookup failure as a denial
// states something false about the user's permissions.
return verdict === 'denied'
? `${opened}\nOpened the deployed page instead: the user does not have edit rights on ${promptSafe(args.path)}.`
: `${opened}\nOpened the deployed page: their edit rights on ${promptSafe(args.path)} couldn't be checked just now.`
}
}
// For a pipeline the handler awaits the editor's tool registration, so the
// model's next build_pipeline_node call can't race the async canvas mount.
return await openPreviewHandler({ ...args, sessionId })
}
/** Whether the user may edit `path` in `workspace`. `'unverified'` when their role
* there could not be read — distinct from a denial, which `canWrite` cannot express
* (it answers false for an unknown user). */
async function canEditItemPath(
workspace: string,
path: string
): Promise<'allowed' | 'denied' | 'unverified'> {
const role = await roleForWorkspace(workspace)
if (role.kind === 'not_a_member') return 'denied'
if (role.kind !== 'resolved' || !role.user) return 'unverified'
if (role.user.operator) return 'denied'
// Folder and ownership rules are all `canWrite` needs for a path-shaped check; an
// item's own `extra_perms` can only widen this, and fetching it would cost a request
// per call on the path that is meant to be free.
return canWrite(path, {}, role.user) ? 'allowed' : 'denied'
}
// Opens a workspace *page* (Runs, Schedules, …) as a page tab in the session's
// side-panel preview, so open_page can show it next to the chat instead of
// navigating the whole browser. Registered by the session runtime. Returns a
@@ -16,15 +16,31 @@ export type OpenItemPreviewAction = {
label: string
previewKind: PreviewCardKind
path: string
/** Which side of the item to show. A card on a write-tool result means the
* draft the tool just wrote, so it asks for the editor; a path the model
* mentioned in prose means the item as it stands, so it asks for the
* deployed page. Declared per call site because only the call site knows
* which of the two it is. */
mode: 'edit' | 'view'
}
/** Build the action a preview card or path link dispatches from its (kind, path). */
export function openItemPreviewAction(kind: PreviewCardKind, path: string): OpenItemPreviewAction {
export function openItemPreviewAction(
kind: PreviewCardKind,
path: string,
mode: 'edit' | 'view' = 'edit'
): OpenItemPreviewAction {
return {
id: `open-item-preview:${kind}:${path}`,
// The side is part of the identity: the same path can be offered both ways
// in one transcript, and a shared id would collapse them into one action.
id: `open-item-preview:${kind}:${mode}:${path}`,
type: 'open_item_preview',
label: `Open ${kind === 'raw_app' ? 'app' : kind} preview`,
label:
mode === 'view'
? `Open ${kind === 'raw_app' ? 'app' : kind}`
: `Open ${kind === 'raw_app' ? 'app' : kind} preview`,
previewKind: kind,
path
path,
mode
}
}
@@ -1850,14 +1850,25 @@ describe('openItemPreviewAction', () => {
// to previewTargetForSessionTarget.
it('carries the kind and path through to the dispatch action', () => {
expect(openItemPreviewAction('flow', 'f/team/etl')).toEqual({
id: 'open-item-preview:flow:f/team/etl',
id: 'open-item-preview:flow:edit:f/team/etl',
type: 'open_item_preview',
label: 'Open flow preview',
previewKind: 'flow',
path: 'f/team/etl'
path: 'f/team/etl',
mode: 'edit'
})
})
// The side is part of the id: a transcript can offer the same path both ways —
// a write card opening its draft's editor, a prose mention opening the deployed
// page — and one id would collapse them into a single action.
it('distinguishes the two sides of the same item', () => {
const edit = openItemPreviewAction('flow', 'f/team/etl')
const view = openItemPreviewAction('flow', 'f/team/etl', 'view')
expect(view.mode).toBe('view')
expect(view.id).not.toBe(edit.id)
})
// raw_app is the internal kind; the user-facing label says "app".
it('labels raw_app as "app"', () => {
expect(openItemPreviewAction('raw_app', 'u/me/dash').label).toBe('Open app preview')
@@ -234,14 +234,18 @@ export function workspaceItemAction(
): ToolDisplayAction | undefined {
if (!kind || !path) return undefined
// 'view' throughout: this registry is loaded without `include_draft_only`, so a
// path only resolves here once something is deployed at it. A mention in prose
// names the item as it stands, which is that deployed version — the model shows
// its own fresh drafts through `open_preview` and the write-result card instead.
if (kind === 'script' || kind === 'flow') {
return openItemPreviewAction(kind, path)
return openItemPreviewAction(kind, path, 'view')
}
// Raw apps only. A legacy drag-and-drop app has no editor the panel can host, and
// legacy items are not extended onto new surfaces — its link stays outbound.
if (kind === 'app') {
return rawApp ? openItemPreviewAction('raw_app', path) : undefined
return rawApp ? openItemPreviewAction('raw_app', path, 'view') : undefined
}
const base = {
@@ -27,6 +27,8 @@
import { Badge as HeaderBadge, Alert } from '$lib/components/common'
import MoveDrawer from '$lib/components/MoveDrawer.svelte'
import RunForm from '$lib/components/RunForm.svelte'
import { argsForSchema, type PendingRun } from '$lib/components/details/pendingRun'
import type { Schema } from '$lib/common'
import ShareModal from '$lib/components/ShareModal.svelte'
import { enterpriseLicense, userStore, userWorkspaces } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
@@ -95,6 +97,7 @@
onNavigate = goto,
active = true,
syncArgsToUrl = true,
pendingRun,
onLoadState
}: {
/** The `[...path]` route segment: the flow's path. */
@@ -110,6 +113,9 @@
/** Whether the run form may mirror its args into the page URL's fragment. False
* wherever this is embedded in a page that is not the flow's own. */
syncArgsToUrl?: boolean
/** A chat tool call waiting on this form. Seeds the arguments it proposed and takes
* over Run; see {@link PendingRun} for why the page must not run it itself. */
pendingRun?: PendingRun
/** How the load ended, for a host that renders its own state around this page. */
onLoadState?: (state: 'loaded' | 'not_found') => void
} = $props()
@@ -323,6 +329,22 @@
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
let runForm: RunForm | undefined = $state()
// Run hands the arguments to the waiting call instead of starting a job: the tool that
// parked on this form starts one itself when it resumes.
const runAction = $derived(
pendingRun ? (_scheduledForStr, a: Record<string, any>) => pendingRun.submit(a) : runFlow
)
// Seeded once the flow is loaded, because narrowing the model's arguments needs its
// schema. The strip above the form is what says where they came from.
let seededCallId: string | undefined = undefined
$effect(() => {
if (!pendingRun || !flow || !runForm) return
if (seededCallId === pendingRun.toolCallId) return
seededCallId = pendingRun.toolCallId
runForm.setArgs(argsForSchema(pendingRun.args, flow.schema as Schema | undefined))
})
function getMainButtons(flow: Flow | undefined, args: object | undefined) {
const buttons: any = []
@@ -519,6 +541,14 @@
}
}
break
case 'Escape':
// Only while the strip is up, and only if nothing nearer has claimed the key:
// a popover or drawer that handles Escape stops it before it reaches here.
if (pendingRun && !event.defaultPrevented) {
event.preventDefault()
pendingRun.decline()
}
break
}
}
let stepDetail: FlowModule | string | undefined = $state(undefined)
@@ -809,6 +839,9 @@
/>
{/if}
{#if pendingRun}
<InputSelectedBadge inputSelected="pending_run" onReject={pendingRun.decline} />
{/if}
<RunForm
bind:scheduledForStr
bind:invisible_to_owner
@@ -821,7 +854,7 @@
detailed={false}
bind:isValid
runnable={flow}
runAction={runFlow}
{runAction}
bind:args
bind:this={runForm}
{jsonView}
@@ -46,6 +46,8 @@
} from '$lib/components/common'
import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte'
import RunForm from '$lib/components/RunForm.svelte'
import { argsForSchema, type PendingRun } from '$lib/components/details/pendingRun'
import type { Schema } from '$lib/common'
import DbtRunGraph from '$lib/components/dbt/DbtRunGraph.svelte'
import { goto } from '$lib/navigation'
import MoveDrawer from '$lib/components/MoveDrawer.svelte'
@@ -121,6 +123,7 @@
onNavigate = goto,
active = true,
syncArgsToUrl = true,
pendingRun,
onLoadState
}: {
/** The `[...hash]` route segment: either a script hash or a script path. */
@@ -136,6 +139,9 @@
/** Whether the run form may mirror its args into the page URL's fragment. False
* wherever this is embedded in a page that is not the script's own. */
syncArgsToUrl?: boolean
/** A chat tool call waiting on this form. Seeds the arguments it proposed and takes
* over Run; see {@link PendingRun} for why the page must not run it itself. */
pendingRun?: PendingRun
/** How the load ended, for a host that renders its own state around this page.
* Providing it also suppresses the "could not load" toast: a 404 here is the normal
* state of a not-yet-deployed path, which the host explains in place instead. */
@@ -390,6 +396,23 @@
}
let args: Record<string, any> | undefined = $state(undefined)
// Run hands the arguments to the waiting call instead of starting a job: the tool that
// parked on this form starts one itself when it resumes.
const runAction = $derived(
pendingRun ? (_scheduledForStr, a: Record<string, any>) => pendingRun.submit(a) : runScript
)
// Seeded once the script is loaded, because narrowing the model's arguments needs its
// schema. The strip above the form is what says where they came from.
let seededCallId: string | undefined = undefined
$effect(() => {
if (!pendingRun || !script || !runForm) return
if (seededCallId === pendingRun.toolCallId) return
seededCallId = pendingRun.toolCallId
runForm.setArgs(argsForSchema(pendingRun.args, script.schema as Schema | undefined))
})
// Read once on purpose: these args seed the form, so tracking the fragment would
// overwrite what the user has typed whenever it changes.
let hash = untrack(() => locationHash)
@@ -734,6 +757,14 @@
}
}
break
case 'Escape':
// Only while the strip is up, and only if nothing nearer has claimed the key:
// a popover or drawer that handles Escape stops it before it reaches here.
if (pendingRun && !event.defaultPrevented) {
event.preventDefault()
pendingRun.decline()
}
break
}
}
@@ -1043,6 +1074,9 @@
/>
{/if}
{#if pendingRun}
<InputSelectedBadge inputSelected="pending_run" onReject={pendingRun.decline} />
{/if}
<RunForm
bind:scheduledForStr
bind:invisible_to_owner
@@ -1055,7 +1089,7 @@
detailed={false}
bind:isValid
runnable={script}
runAction={runScript}
{runAction}
bind:args
schedulable={true}
bind:this={runForm}
@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest'
import { argsForSchema } from './pendingRun'
import type { Schema } from '$lib/common'
const schemaOf = (...names: string[]): Schema =>
({
properties: Object.fromEntries(names.map((n) => [n, { type: 'string' }]))
}) as unknown as Schema
describe('argsForSchema', () => {
// The model writes its arguments against the item it has been editing, which can carry
// parameters the deployed version does not. Dropping those leaves a form the reader can
// still fill, where passing them whole would send arguments it never declared.
it('keeps only the arguments the deployed schema declares', () => {
expect(argsForSchema({ path: '/x', retries: 3 }, schemaOf('path', 'verbose'))).toEqual({
path: '/x'
})
})
// A falsy value is still an answer the model gave; filtering on the value rather than the
// key would silently drop `false` and `0`.
it('keeps falsy values', () => {
expect(argsForSchema({ verbose: false, count: 0 }, schemaOf('verbose', 'count'))).toEqual({
verbose: false,
count: 0
})
})
// Seeding wholesale against a schema that has not loaded would be a guess, and the form
// would raise the "AI generated" badge over arguments nobody can check.
it('seeds nothing when there is no schema to narrow against', () => {
expect(argsForSchema({ path: '/x' }, undefined)).toEqual({})
})
})
@@ -0,0 +1,42 @@
import type { Schema } from '$lib/common'
/**
* A chat tool call parked on a deployed item's run form: the model proposed these
* arguments and is waiting for the reader to run them, or to decline.
*
* The page seeds its form from `args` and routes Run to `submit` rather than starting a
* job of its own — the tool starts the job when the call resumes, so a page that also ran
* would start a second one for the same request.
*/
export type PendingRun = {
/** Which call is waiting. The page seeds its form once per call rather than once per
* mount: a tab already showing this item is reused for the next request, so a latch that
* only remembers "seeded" would leave the previous call's arguments on screen. */
toolCallId: string
/** What the model proposed, before the page narrows it to its own schema. */
args: Record<string, any>
/** Hand the form's current arguments to the waiting call. */
submit: (args: Record<string, any>) => void
/** Refuse the call, as the chat card's Cancel does. Not an undo of the prefill: there is
* nothing to restore it to, and leaving the call parked with an emptied form would strand
* the turn. */
decline: () => void
}
/**
* The proposed arguments, less the ones this schema has no field for. The model writes
* them against the item it has been editing, whose parameters can differ from what is
* deployed — dropping the strays leaves a form the reader can still fill and run, where
* passing them whole would send arguments the deployed version never declared.
*/
export function argsForSchema(
args: Record<string, any> | undefined,
schema: Schema | undefined
): Record<string, any> {
if (!args) return {}
const declared = schema?.properties
// No schema yet (still loading, or a runnable that takes none) means nothing to narrow
// against; seeding wholesale here would be a guess, so seed nothing.
if (!declared) return {}
return Object.fromEntries(Object.entries(args).filter(([k]) => k in declared))
}
@@ -6,7 +6,10 @@
import type { Snippet } from 'svelte'
interface Props {
inputSelected: 'history' | 'captures' | 'saved' | 'ai' | undefined
/** `pending_run` is the odd one: the others report where the arguments in the form
* came from and undo that substitution on reject, while this one reports a chat tool
* call waiting on the form and declines it. */
inputSelected: 'history' | 'captures' | 'saved' | 'ai' | 'pending_run' | undefined
labelColor?: string
className?: string
acceptButton?: Snippet
@@ -16,7 +19,11 @@
let { inputSelected, className = '', acceptButton, onReject, labelColor = '' }: Props = $props()
</script>
<div class="min-h-[38px]">
<!-- The reserved slot keeps the form from jumping as the badge toggles with the input
picker's selection. A `pending_run` badge does not toggle — it stands for as long as
the call is parked — so it holds no space open, which in a short pane is enough to
push the form's footer out of sight. -->
<div class={inputSelected === 'pending_run' ? '' : 'min-h-[38px]'}>
<div
class={twMerge(
'rounded-md flex flex-row gap-2 items-center py-1 px-2 w-fit',
@@ -26,13 +33,17 @@
)}
>
<p class={twMerge(classes['info'].descriptionClass, 'text-xs px-2', labelColor)}>
Using {inputSelected === 'history'
? 'historic'
: inputSelected === 'captures'
? 'captures'
: inputSelected === 'ai'
? 'AI generated'
: 'saved'} input arguments
{#if inputSelected === 'pending_run'}
The agent wants to run this
{:else}
Using {inputSelected === 'history'
? 'historic'
: inputSelected === 'captures'
? 'captures'
: inputSelected === 'ai'
? 'AI generated'
: 'saved'} input arguments
{/if}
</p>
{#if acceptButton}
{@render acceptButton()}
@@ -1,7 +1,10 @@
<script lang="ts">
import FlowBuilder from '$lib/components/FlowBuilder.svelte'
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
import type { WorkspaceItem } from '$lib/components/workspacePicker'
import {
invalidate as invalidateWorkspaceItems,
type WorkspaceItem
} from '$lib/components/workspacePicker'
import type { Flow } from '$lib/gen'
import type { SessionRuntime } from './sessionRuntime.svelte'
import SessionEditorTarget from './SessionEditorTarget.svelte'
@@ -138,6 +141,10 @@
runtime.syncPreviewWithDeployed(workspaceId, 'flow', path)
// Deploying clears the item's pending draft — refresh the Draft Count.
invalidateWorkspaceDrafts(workspaceId)
// And drop the picker's cached row, whose `draftOnly` now decides which
// side a pick opens: stale, it would send the reader back to the editor
// of the thing they just deployed.
invalidateWorkspaceItems(workspaceId, 'flow')
}}
/>
{/snippet}
@@ -64,6 +64,10 @@
const searchParams = $derived(version ? new URLSearchParams({ version }) : undefined)
// A chat tool call parked on this item's run form, when one opened this tab to ask for
// a deployed run. The page fills its form from it and routes Run back to the call.
const pendingRun = $derived(runtime.pendingRunFor(kind, path))
// Nothing deployed at this path. Reset per remount key so a refetch after a deploy is
// not answered from the previous load's verdict.
let loadState: 'loaded' | 'not_found' | undefined = $state(undefined)
@@ -111,8 +115,8 @@
// segment with no slash is a hash — pinned in the query instead, or this tab's
// identity would become that hash and it would stop being a tab on this script.
const isVersionHash = route.kind === 'script' && !route.itemPath.includes('/')
runtime.previewTabs.navigate({
type: 'item',
const target = {
type: 'item' as const,
item: {
path: isVersionHash ? path : route.itemPath,
summary: '',
@@ -121,7 +125,13 @@
},
mode: route.mode,
version: isVersionHash ? route.itemPath : (query.get('version') ?? undefined)
})
}
// This tab is the deployed side, so a link to an editor is a request for the other
// side: it opens beside this one rather than replacing the page it was asked from.
// Everything else the page links — another version, the head, a rename's new path —
// is still this same view of the item, so it re-points in place.
if (route.mode === 'edit') runtime.previewTabs.open(target)
else runtime.previewTabs.navigate(target)
return
}
runtime.previewTabs.open({
@@ -157,6 +167,7 @@
{onNavigate}
{active}
syncArgsToUrl={false}
{pendingRun}
onLoadState={setLoadState}
/>
{:else if kind === 'flow'}
@@ -167,6 +178,7 @@
{onNavigate}
{active}
syncArgsToUrl={false}
{pendingRun}
onLoadState={setLoadState}
/>
{:else}
@@ -60,7 +60,11 @@
if (item) {
runtime.previewTabs.open({
type: 'item',
item: { kind: item.kind, raw_app: item.raw_app, path: item.itemPath, summary: '' }
item: { kind: item.kind, raw_app: item.raw_app, path: item.itemPath, summary: '' },
// A list page only ever links what it runs — a schedule's script, a
// trigger's flow — which is deployed by construction. Its deployed page
// is what the reader clicking through is asking about.
mode: item.mode ?? 'view'
})
} else {
runtime.previewTabs.open({ type: 'page', href, label: previewLocationLabel(href) })
@@ -16,7 +16,7 @@ section or the flat layout.
<script lang="ts">
import { untrack } from 'svelte'
import { Compass, FileText } from 'lucide-svelte'
import { Compass, FileText, Pen } from 'lucide-svelte'
import { resource } from 'runed'
import { workspaceStore } from '$lib/stores'
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
@@ -45,6 +45,7 @@ section or the flat layout.
isArtifactKey,
pageHref,
pageKey,
previewModeFor,
type PreviewTarget
} from './previewRouter'
@@ -135,7 +136,11 @@ section or the flat layout.
draftPath: storagePath !== d.path ? d.path : d.draftPath,
summary: d.summary ?? '',
kind: k,
raw_app: k === 'app' ? !!(d.value as { files?: unknown })?.files : undefined
raw_app: k === 'app' ? !!(d.value as { files?: unknown })?.files : undefined,
// A chat-scaffolded draft the workspace listers have never seen. It has
// an editor and nothing else, so picking it must not aim at a deployed
// page — and a row merged over a loaded one keeps the lister's answer.
draftOnly: true
}
})
}
@@ -145,14 +150,42 @@ section or the flat layout.
// Wrap item leaves as PreviewTargets so the tree can carry page leaves too.
// Branch structure is untouched — only leaf `data` is re-tagged.
//
// `mode` is stamped here rather than left to the tab: picking is the reader
// saying "show me this", so it lands on the deployed page, and `navigate`
// would otherwise keep whichever side the re-pointed tab happened to be on.
function tagItems(nodes: DrillNode<WorkspaceItem>[]): DrillNode<PreviewTarget>[] {
return nodes.map((n) =>
n.type === 'leaf'
? { ...n, data: { type: 'item', item: n.data } }
? { ...n, data: { type: 'item', item: n.data, mode: previewModeFor(n.data) } }
: { ...n, children: tagItems(n.children) }
)
}
// Open the item's editor. Offered on every item row, including the draft-only ones
// a plain click already opens there: a row that drops the button to say "you were
// getting this anyway" reads as one that cannot be edited.
function editAction(leaf: DrillLeaf<PreviewTarget>) {
const target = leaf.data
if (target.type !== 'item') return undefined
return {
icon: Pen,
label: 'Edit',
title: `Edit ${workspaceItemDisplayPath(target.item)}`,
run: () => onPick({ ...target, mode: 'edit' })
}
}
// What the row says about the item's deploy state, in the review dock's words so
// the two never describe the same item differently.
function stateBadge(leaf: DrillLeaf<PreviewTarget>) {
const target = leaf.data
if (target.type !== 'item') return undefined
if (target.item.draftOnly) return { label: 'Draft only', color: 'indigo' as const }
if (target.item.hasDraft) return { label: 'Draft', color: 'indigo' as const }
return undefined
}
function pageLeaf(p: (typeof PREVIEW_PAGES)[number]): DrillLeaf<PreviewTarget> {
return {
type: 'leaf',
@@ -262,5 +295,7 @@ section or the flat layout.
if (scope.length > 0) loader.ensureForScopeSegment(scope[0])
}}
onFilterChange={loader.onFilterChange}
leafAction={editAction}
leafBadge={stateBadge}
{rootLoading}
/>
@@ -2,7 +2,10 @@
import { untrack } from 'svelte'
import RawAppEditor from '$lib/components/raw_apps/RawAppEditor.svelte'
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
import type { WorkspaceItem } from '$lib/components/workspacePicker'
import {
invalidate as invalidateWorkspaceItems,
type WorkspaceItem
} from '$lib/components/workspacePicker'
import type { SessionRuntime } from './sessionRuntime.svelte'
import SessionEditorTarget from './SessionEditorTarget.svelte'
import { runResetToDeployed } from '$lib/userDraftToast'
@@ -271,6 +274,10 @@
runtime.syncPreviewWithDeployed(workspaceId, 'raw_app', e.path)
// Deploying clears the item's pending draft — refresh the Draft Count.
invalidateWorkspaceDrafts(workspaceId)
// And drop the picker's cached row, whose `draftOnly` now decides which
// side a pick opens: stale, it would send the reader back to the editor
// of the thing they just deployed.
invalidateWorkspaceItems(workspaceId, 'app')
}}
defaultSidebarCollapsed
sidebarStorageKey="raw-app-sidebar-collapsed-preview"
@@ -1,7 +1,10 @@
<script lang="ts">
import ScriptBuilder from '$lib/components/ScriptBuilder.svelte'
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
import type { WorkspaceItem } from '$lib/components/workspacePicker'
import {
invalidate as invalidateWorkspaceItems,
type WorkspaceItem
} from '$lib/components/workspacePicker'
import type { SessionRuntime } from './sessionRuntime.svelte'
import type { NewScript } from '$lib/gen'
import { UserDraft } from '$lib/userDraft.svelte'
@@ -129,6 +132,10 @@
// Deploying clears the item's pending draft — refresh the workspace
// Draft Count so the session bar / compare page drop it immediately.
invalidateWorkspaceDrafts(workspaceId)
// And drop the picker's cached row, whose `draftOnly` now decides which
// side a pick opens: stale, it would send the reader back to the editor
// of the thing they just deployed.
invalidateWorkspaceItems(workspaceId, 'script')
}}
/>
{/if}
@@ -194,7 +194,9 @@
// Routed on the storage `path` — the edit route's key, which for a draft-only
// item is its `draft_<uuid>` path, not the friendly `displayPath`.
function previewTargetFor(item: DeployItem): PreviewTarget | undefined {
return previewTargetForDeployKind(item.deployKind, item.path)
// `done` means this session already deployed it, so there is a deployed
// version to show even though the row was built from a draft-only item.
return previewTargetForDeployKind(item.deployKind, item.path, item.draftOnly && !item.done)
}
// The row's primary action is the preview; kinds the panel can't host fall back
@@ -13,12 +13,28 @@ import {
parseRunFormRoute,
previewLocationContext,
previewLocationLabel,
previewModeFor,
resolvePreviewTab,
runFormUrl,
workspacePageHref
} from './previewRouter'
import { pageItemUrl } from './previewPaths'
describe('previewModeFor', () => {
// Every opener that shows the reader an item that already exists routes through
// this, so the two halves are one decision rather than a repeated conditional.
it('sends a deployed item to its page and a draft-only one to its editor', () => {
expect(previewModeFor({ draftOnly: false })).toBe('view')
expect(previewModeFor({ draftOnly: true })).toBe('edit')
})
// A row from a lister that was not asked for draft-only items carries no flag,
// and everything it lists is deployed.
it('treats a missing flag as deployed', () => {
expect(previewModeFor({})).toBe('view')
})
})
describe('workspacePageHref', () => {
it('sends a page item tab to its list page with the row open, never to its scheme', () => {
expect(workspacePageHref(pageItemUrl({ kind: 'resource', path: 'u/me/db' }))).toBe(
@@ -87,6 +87,19 @@ export type PreviewTarget =
| { type: 'runform'; toolCallId: string; label: string }
| { type: 'pageitem'; ref: PageItemRef }
/**
* Which side of an item to open when the opener is showing the reader something
* that already exists, rather than something it just wrote: the deployed page,
* unless nothing is deployed there and the editor is all the item has.
*
* The openers that follow a write (`open_preview`, the write-result preview card)
* pass `'edit'` themselves — the item they mean may well be deployed, and its
* deployed page is precisely not what the reader asked to see.
*/
export function previewModeFor(item: Pick<WorkspaceItem, 'draftOnly'>): PreviewItemMode {
return item.draftOnly ? 'edit' : 'view'
}
export type PreviewPage = { label: string; path: string; icon: DrillIcon }
// Core workspace-level destinations the preview can route to. Intentionally
@@ -22,6 +22,7 @@ import {
runFormUrl,
stripBase,
type ArtifactVersionTarget,
type PreviewItemMode,
type PreviewTarget
} from './previewRouter'
import type { SessionPreviewTab, SessionTarget } from './sessionState.svelte'
@@ -60,13 +61,25 @@ export type PreviewTabsAdapter = {
onTabOpened?: (url: string) => void
}
// True when a tab's URL is on a specific editable item — either side of it. Both
// sides are one tab, so this is the "same item" dedupe test in open()/navigate():
// re-opening an item the user left on View must focus that tab, not add a second.
function isItemTabFor(url: string, target: SessionTarget): boolean {
// True when a tab's URL is on one SIDE of a specific editable item. The two sides are
// separate tabs, so this is the dedupe test in open()/navigate(): asking for a side that
// is already open focuses it, asking for the other one opens it alongside.
//
// Keying on the side is also what keeps the editor cell safe. Two editor tabs on one item
// would mount two editors racing the same (kind, path) cell; an editor beside a viewer is
// fine, because the viewer reads the deployed version over the API and touches no cell.
function isItemTabFor(url: string, target: SessionTarget, side: PreviewItemMode): boolean {
const slot = resolvePreviewTab(url)
if (slot.kind === 'editor') return slot.editorKind === target.kind && slot.path === target.path
return slot.kind === 'viewer' && slot.viewerKind === target.kind && slot.path === target.path
if (side === 'view') {
return slot.kind === 'viewer' && slot.viewerKind === target.kind && slot.path === target.path
}
return slot.kind === 'editor' && slot.editorKind === target.kind && slot.path === target.path
}
/** Which side a target asks for. Openers that predate the split omit `mode`, and the editor
* is what they have always meant. */
function sideOf(target: PreviewTarget): PreviewItemMode {
return target.type === 'item' && target.mode === 'view' ? 'view' : 'edit'
}
// The version a tab shows when it is re-pointed: the opener's, if it named one, else whatever
@@ -176,12 +189,23 @@ export function previewTargetForSessionTarget(
// drag-and-drop apps, which the panel hosts as an iframe over their edit route.
// Every other kind maps to undefined, and its row falls back to the diff. The
// undefined is also the caller's test for "can this row be previewed?".
export function previewTargetForDeployKind(kind: Kind, path: string): PreviewTarget | undefined {
export function previewTargetForDeployKind(
kind: Kind,
path: string,
/** Whether nothing is deployed at `path`. The review dock reads this off the
* row it is about to open, so an item side is chosen the same way the picker
* chooses it; omitted, the tab falls back to the editor as before. */
draftOnly?: boolean
): PreviewTarget | undefined {
const mode: PreviewItemMode | undefined =
draftOnly === undefined ? undefined : draftOnly ? 'edit' : 'view'
if (kind === 'app') {
return { type: 'item', item: { kind: 'app', raw_app: false, path, summary: '' } }
return { type: 'item', item: { kind: 'app', raw_app: false, path, summary: '' }, mode }
}
if (kind === 'script' || kind === 'flow' || kind === 'raw_app') {
return previewTargetForSessionTarget(kind, path)
const target = previewTargetForSessionTarget(kind, path)
if (target?.type !== 'item') return target
return { type: 'item', item: target.item, mode }
}
if (kind === 'variable' || kind === 'resource' || kind === 'schedule') {
return { type: 'pageitem', ref: { kind, path } }
@@ -420,8 +444,9 @@ export class SessionPreviewTabs {
// collapsed panel and the user sees nothing change.
this.#collapsed = false
if (editorTarget) {
// One editor tab per item: focus the tab already hosting this exact item.
const existing = this.#tabs.find((t) => isItemTabFor(t.url, editorTarget))
// One tab per side: focus the tab already showing this side of this item. The
// other side, if open, is a tab of its own and is left where it is.
const existing = this.#tabs.find((t) => isItemTabFor(t.url, editorTarget, sideOf(target)))
if (existing) {
this.#activeId = existing.id
this.#flush()
@@ -504,10 +529,11 @@ export class SessionPreviewTabs {
if (!t) return
const editorTarget = editorTargetFor(target)
if (editorTarget) {
// Same dedupe as open(): if another tab already hosts this exact item,
// focus it instead of re-pointing this one — two tabs for one item would
// mount two editors racing the same (kind, path) cell.
const existing = this.#tabs.find((x) => isItemTabFor(x.url, editorTarget))
// Same dedupe as open(): another tab already showing this side of this item is
// focused rather than duplicated. Re-pointing THIS tab to the other side of the
// item it already shows falls through to the retarget below — the breadcrumb
// redirects the tab you are on, it does not spawn one.
const existing = this.#tabs.find((x) => isItemTabFor(x.url, editorTarget, sideOf(target)))
if (existing && existing.id !== t.id) {
this.#activeId = existing.id
this.#flush()
@@ -648,6 +674,15 @@ export class SessionPreviewTabs {
this.#flush()
}
/** Point a known tab at something else, in place — for a caller that has already
* decided which one, as the deployed page that adopted a run form has. */
retargetById(id: string, url: string): void {
const tab = this.#tabs.find((t) => t.id === id)
if (!tab) return
retargetTab(tab, url)
this.#flush()
}
setCollapsed(collapsed: boolean): void {
if (this.#collapsed === collapsed) return
this.#collapsed = collapsed
@@ -679,7 +714,10 @@ export class SessionPreviewTabs {
label: string | undefined,
friendlyPath?: string
): void {
const t = this.#tabs.find((x) => isItemTabFor(x.url, target))
// The editor's tab, never the viewer's: this is the live editor stamping the name it
// is holding, including a rename staged only in the draft. The deployed page beside
// it still answers to the deployed name.
const t = this.#tabs.find((x) => isItemTabFor(x.url, target, 'edit'))
if (!t) return
// Set before the no-change early return: an item with neither a summary nor
// a staged path leaves both fields undefined, and the editor still owns it.
@@ -1114,3 +1114,40 @@ describe('SessionPreviewTabs.pulseFocus', () => {
expect(o.focusPulse.nonce).toBe(0)
})
})
describe('the two sides of an item are separate tabs', () => {
const viewTarget: PreviewTarget = { ...scriptTarget, mode: 'view' } as PreviewTarget
// The editor and the deployed page are different things to look at, so opening one
// never costs you the other — the reason the dedupe keys on the side and not the item.
it('opens a tab per side and focuses the side already open', () => {
const o = owner()
expect(o.open(scriptTarget).status).toBe('opened')
expect(o.open(viewTarget).status).toBe('opened')
expect(o.tabs.length).toBe(2)
expect(o.open(scriptTarget).status).toBe('focused')
expect(o.open(viewTarget).status).toBe('focused')
expect(o.tabs.length).toBe(2)
})
// Two editor tabs on one item would mount two editors racing its single (kind, path)
// cell; one editor beside one viewer does not, because the viewer holds no cell.
it('never opens a second editor for the same item', () => {
const o = owner()
o.open(scriptTarget)
o.open(scriptTarget)
expect(o.tabs.filter((t) => t.url.includes('/scripts/edit/')).length).toBe(1)
})
// The breadcrumb redirects the tab you are on. Sending it to the other side of the item
// it already shows must not leave a duplicate behind.
it('re-points in place when navigating a tab to its other side', () => {
const o = owner()
o.open(scriptTarget)
const id = o.tabs[0].id
o.navigate(viewTarget)
expect(o.tabs.length).toBe(1)
expect(o.tabs[0].id).toBe(id)
expect(o.tabs[0].url).toContain('/scripts/get/')
})
})
@@ -1,6 +1,7 @@
import { SvelteMap } from 'svelte/reactivity'
import { get } from 'svelte/store'
import { base } from '$lib/base'
import type { PendingRun } from '$lib/components/details/pendingRun'
import { AIChatManager, AIMode } from '$lib/components/copilot/chat/AIChatManager.svelte'
import { PipelineEditorState } from '$lib/components/assets/AssetGraph/pipelineEditorState.svelte'
import { initFlow } from '$lib/components/flows/flowStore.svelte'
@@ -218,6 +219,8 @@ export interface SessionRuntime {
* viewer refetches when this changes — it reads the deployed version from the
* API, so nothing else tells it the editor beside it just published. */
deployedRevision(kind: SessionTargetKind, path: string): number
/** The chat tool call waiting on this item's deployed run form, if one adopted it. */
pendingRunFor(kind: SessionTargetKind, path: string): PendingRun | undefined
/** Record that a preview tab landed on `url`. Fires for a newly opened tab and
* for an existing one switched to the other side of its item. */
logTabUsage(url: string): void
@@ -541,10 +544,49 @@ function createRuntime(session: Session): SessionRuntime {
// Only persist a real width; undefined means "never resized" (defaults to 50).
if (snap.previewSize != null) setSessionPreviewSize(session.id, snap.previewSize)
},
onTabsChanged: pruneEditorCells,
onTabsChanged: () => {
pruneEditorCells()
declineAdoptionsWithoutTab()
},
onTabOpened: logTabUsage
})
// Deployed pages that have taken over a parked run form, keyed by the item they show.
// Not persisted: a parked call does not survive a reload, so a restored tab comes back
// as a plain deployed page.
const adoptedRuns = new SvelteMap<string, { toolCallId: string; args: Record<string, any> }>()
const adoptionKey = (kind: SessionTargetKind, path: string) => `${kind}:${path}`
/** The call waiting on this item's run form, for the viewer that renders it. */
function pendingRunFor(kind: SessionTargetKind, path: string): PendingRun | undefined {
const adopted = adoptedRuns.get(adoptionKey(kind, path))
if (!adopted) return undefined
return {
toolCallId: adopted.toolCallId,
args: adopted.args,
submit: (args) => manager.handleRunFormSubmit(adopted.toolCallId, args),
decline: () => manager.handleRunFormCancel(adopted.toolCallId)
}
}
// Closing the tab declines the call, the contract the run form tab already has: the tab
// is the call's presence in the panel. Driven off the tab set rather than a close
// handler so a tab dropped by a reset or a session switch counts too.
function declineAdoptionsWithoutTab() {
for (const [key, adopted] of [...adoptedRuns]) {
const [kind, path] = [key.slice(0, key.indexOf(':')), key.slice(key.indexOf(':') + 1)]
const stillOpen = previewTabs.tabs.some((t) => {
const slot = resolvePreviewTab(t.url)
return slot.kind === 'viewer' && slot.viewerKind === kind && slot.path === path
})
if (stillOpen) continue
adoptedRuns.delete(key)
if (manager.isRunFormPending(adopted.toolCallId)) {
manager.handleRunFormCancel(adopted.toolCallId)
}
}
}
// Let the jobs tray open a run in this session's preview panel (as an iframe
// tab over the run page). The global side-panel chat leaves this unset and
// falls back to a new browser tab.
@@ -562,16 +604,49 @@ function createRuntime(session: Session): SessionRuntime {
manager.openRunForm = ({ toolCallId, label }) => {
previewTabs.open({ type: 'runform', toolCallId, label })
}
manager.closeRunForm = (toolCallId) => previewTabs.closeRunForm(toolCallId)
// A deployed run shows the item's real deployed page instead, with the model's arguments
// in its own run form. The page hands them back through `pendingRunFor` rather than
// starting a job, so the tool still owns the run and the card still gets its logs.
manager.openRunOnDeployedPage = ({ toolCallId, kind, path, args, summary }) => {
adoptedRuns.set(adoptionKey(kind, path), { toolCallId, args })
previewTabs.open({
type: 'item',
item: { kind: kind === 'raw_app' ? 'app' : kind, raw_app: kind === 'raw_app', path, summary },
mode: 'view'
})
}
manager.closeRunForm = (toolCallId) => {
// The page outlives the call it adopted — it is still the item's deployed page — so
// settling only drops the adoption, and the form reverts to running on its own.
for (const [key, adopted] of [...adoptedRuns]) {
if (adopted.toolCallId === toolCallId) adoptedRuns.delete(key)
}
previewTabs.closeRunForm(toolCallId)
}
manager.showRunInPlaceOfForm = ({ toolCallId, jobId, workspace }) => {
previewTabs.retargetRunForm(toolCallId, `${base}/run/${jobId}?workspace=${workspace}`)
const runHref = `${base}/run/${jobId}?workspace=${workspace}`
// Either surface the call was waiting on becomes the run it started, in place.
for (const [key, adopted] of [...adoptedRuns]) {
if (adopted.toolCallId !== toolCallId) continue
adoptedRuns.delete(key)
const [kind, path] = [key.slice(0, key.indexOf(':')), key.slice(key.indexOf(':') + 1)]
const tab = previewTabs.tabs.find((t) => {
const slot = resolvePreviewTab(t.url)
return slot.kind === 'viewer' && slot.viewerKind === kind && slot.path === path
})
if (tab) previewTabs.retargetById(tab.id, runHref)
}
previewTabs.retargetRunForm(toolCallId, runHref)
}
// Read off the tab list rather than the slot's lifecycle: a tab the user has switched
// away from is unmounted but still open, and the card must keep its form hidden until it
// is closed. A resolver, like activePreviewResolver: the reader's own $derived subscribes
// to `tabs` through it, and the runtime is not inside an effect root to push from.
// An adopted deployed page counts: its form is this call's form, so the card must not
// mount a second one there either.
manager.isRunFormInPreview = (toolCallId) =>
previewTabs.tabs.some((t) => parseRunFormRoute(t.url)?.toolCallId === toolCallId)
previewTabs.tabs.some((t) => parseRunFormRoute(t.url)?.toolCallId === toolCallId) ||
[...adoptedRuns.values()].some((a) => a.toolCallId === toolCallId)
manager.openArtifact = (id, name, version) => {
previewTabs.open({ type: 'artifact', id, name, version })
@@ -607,6 +682,7 @@ function createRuntime(session: Session): SessionRuntime {
flowCell,
loadedEditorPath,
deployedRevision,
pendingRunFor,
logTabUsage,
async loadFlow(workspace: string, path: string, force = false) {
@@ -1191,7 +1267,7 @@ export function removeSession(sessionId: string): void {
// backgrounded session's tool call opens its OWN preview, not the one the user
// happens to be viewing. Outside a session there is no calling/active id and
// the tool returns a polite error.
setOpenPreviewHandler(async ({ sessionId: callerSessionId, kind, path }) => {
setOpenPreviewHandler(async ({ sessionId: callerSessionId, kind, path, mode }) => {
const sessionId = callerSessionId ?? sessionState.currentSessionId
if (!sessionId) {
return 'Error: no active session to open the preview in.'
@@ -1205,7 +1281,11 @@ setOpenPreviewHandler(async ({ sessionId: callerSessionId, kind, path }) => {
return `Error: ${kind} targets cannot be shown in the preview panel.`
}
const runtime = getOrCreateRuntime(session)
const result = runtime.previewTabs.open(target)
// The editor unless the model asked otherwise: this tool's job is showing the user
// the draft a write tool just produced. A reference to an item that already exists
// reaches the panel as a pill in the transcript, which asks for the deployed page.
const resolved = target.type === 'item' ? { ...target, mode: mode ?? 'edit' } : target
const result = runtime.previewTabs.open(resolved)
// The pipeline editor registers its build_pipeline_node / edit_pipeline_node
// tools asynchronously once the canvas mounts. Block the tool result until
// they are live so the model's next turn doesn't race ahead and hit an
@@ -29,7 +29,9 @@ function itemsEqual(a: WorkspaceItem[] | undefined, b: WorkspaceItem[]): boolean
item.summary === b[i].summary &&
item.kind === b[i].kind &&
item.raw_app === b[i].raw_app &&
item.draftPath === b[i].draftPath
item.draftPath === b[i].draftPath &&
item.draftOnly === b[i].draftOnly &&
item.hasDraft === b[i].hasDraft
)
}
+21 -8
View File
@@ -1,5 +1,3 @@
import type { Flow, ListableApp, Script } from '$lib/gen'
export type WorkspaceItemKind = 'flow' | 'script' | 'app'
export type WorkspaceItem = {
@@ -12,6 +10,15 @@ export type WorkspaceItem = {
* Display-only: `path` stays the storage key and is what URLs and
* dedupe must use — routing to the friendly path 404s. */
draftPath?: string
/** Nothing is deployed at this path: the row exists only because the
* listers were asked for `include_draft_only`. Decides which side of an
* item a picker opens, so it must be invalidated on deploy — a stale
* `true` sends the reader to the editor of something they just shipped. */
draftOnly?: boolean
/** The authed user has a draft for this item. True for a draft-only item too,
* so read it with `draftOnly` to tell "nothing deployed" from "deployed, with
* edits pending". */
hasDraft?: boolean
}
/** Path to display (and group by) in pickers: the friendly draft path when
@@ -127,11 +134,13 @@ export async function loadKind(
includeDraftOnly: true,
withoutDescription: true
})
items = flows.map((f: Flow & { draft_path?: string }) => ({
items = flows.map((f) => ({
path: f.path,
summary: f.summary ?? '',
kind: 'flow' as const,
draftPath: f.draft_path
draftPath: f.draft_path,
draftOnly: f.draft_only ?? false,
hasDraft: f.is_draft ?? false
}))
} else if (kind === 'script') {
const scripts = await ScriptService.listScripts({
@@ -139,23 +148,27 @@ export async function loadKind(
includeDraftOnly: true,
withoutDescription: true
})
items = scripts.map((s: Script & { draft_path?: string }) => ({
items = scripts.map((s) => ({
path: s.path,
summary: s.summary ?? '',
kind: 'script' as const,
draftPath: s.draft_path
draftPath: s.draft_path,
draftOnly: s.draft_only ?? false,
hasDraft: s.is_draft ?? false
}))
} else {
const apps = await AppService.listApps({
workspace,
includeDraftOnly: true
})
items = apps.map((a: ListableApp & { draft_path?: string }) => ({
items = apps.map((a) => ({
path: a.path,
summary: a.summary ?? '',
kind: 'app' as const,
raw_app: a.raw_app ?? false,
draftPath: a.draft_path
draftPath: a.draft_path,
draftOnly: a.draft_only ?? false,
hasDraft: a.is_draft ?? false
}))
}
// Only commit if the cache version hasn't changed since we started —
@@ -668,7 +668,7 @@
if (!o) return
const target = previewTargetForSessionTarget(action.previewKind, action.path)
if (!target) return
o.open(target)
o.open(target.type === 'item' ? { ...target, mode: action.mode } : target)
})
})
// Variables, resources, schedules and triggers the chat links to open as tabs of their
@@ -847,8 +847,11 @@
// An editor's `Exit & see details`: the session hosts the details page itself, so the
// tab moves to the item's deployed view rather than the browser leaving the session.
// `open`, not `navigate`: leaving the editor to look at what is deployed is not a
// redirect of the tab you are working in — you are stepping away from an editor you
// still want, so the deployed page arrives beside it.
function seeDetailsInPreview(item: WorkspaceItem) {
owner?.navigate({ type: 'item', item, mode: 'view' })
owner?.open({ type: 'item', item, mode: 'view' })
}
// A preview iframe that navigates to an editor route posts up to us instead of