feat(sessions): switch preview sides through Exit & see details / Edit

The preview tab's side is now driven by the same affordances the full-page
editor and detail page already carry, instead of a View|Edit toggle in the
session header: `Exit & see details` in the deploy dropdown flips the tab to
the deployed item, and the detail page's `Edit` flips it back. Both stay in
the same tab, and neither leaves /sessions.

`Exit & see details` is gated on `no_deployed !== true` in both builders, so a
draft-only item offers no entry at all — that replaces the toggle's disabled
state and its tooltip. `deployedExists` in SessionRuntime existed only to feed
that tooltip and is gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CoC4MCs3BhVV1DyevrVc7N
This commit is contained in:
Guilhem Lemouel
2026-09-25 13:44:07 +02:00
co-authored by Claude Opus 5
parent b94323cfa1
commit 0a079469f4
12 changed files with 64 additions and 147 deletions
@@ -1083,10 +1083,11 @@
onClick: () => void
}> = []
// In a session pane every one of these leaves the session (details page, new
// tab), so the deploy button carries no dropdown there — as in ScriptBuilder.
if (untrack(() => customUi).topBar?.extraDeployOptions != false && !inSessionPane) {
if (!newFlow) {
if (untrack(() => customUi).topBar?.extraDeployOptions != false) {
// The one option that stays in a session pane: the session hosts the details page
// itself, so this flips the tab to it rather than leaving the session. Everything
// below opens a details page or a new tab outside the session, so it stays hidden.
if (!newFlow && savedFlow?.no_deployed !== true) {
dropdownItems.push({
label: 'Exit & see details',
// Use the deployed path, not the live `$pathStore` — the latter
@@ -1096,7 +1097,7 @@
})
}
if (!untrack(() => newFlow)) {
if (!untrack(() => newFlow) && !inSessionPane) {
dropdownItems.push({
label: 'Fork',
onClick: () => window.open(`/flows/add?template=${initialPath}`)
@@ -1105,6 +1106,7 @@
if (
!untrack(() => newFlow) &&
!inSessionPane &&
!isCloudHosted() &&
editInForkAllowed(opWorkspace, $userWorkspaces)
) {
@@ -806,9 +806,10 @@
// Inside an AI session pane (which injects an aiChatManager via context) the
// extra deploy-dropdown options — Deploy & Stay here, Fork, Edit in workspace
// fork, Exit & See details, Export — don't make sense: the session always
// stays put and is already scoped to a fork. Diff is exposed as a standalone
// top-bar button (rendered independently of the session pane), not here.
// fork, Export — don't make sense: the session always stays put and is already
// scoped to a fork. `Exit & See details` is the exception: the session hosts the
// details page itself, so it switches the tab instead of leaving. Diff is exposed
// as a standalone top-bar button (rendered independently of the pane), not here.
const inSessionPane = !!getContext('aiChatManager')
async function openDiffDrawer() {
@@ -874,10 +875,7 @@
: [])
]
: []),
...(!inSessionPane &&
savedScript?.no_deployed !== true &&
script.kind === 'script' &&
!script.auto_kind
...(savedScript?.no_deployed !== true && script.kind === 'script' && !script.auto_kind
? [
{
label: 'Exit & See details',
@@ -6,7 +6,6 @@
import { twMerge } from 'tailwind-merge'
import { userStore } from '$lib/stores'
import { createEventDispatcher, getContext, tick } from 'svelte'
import { MediaQuery } from 'svelte/reactivity'
import SummaryPathDisplay from '$lib/components/SummaryPathDisplay.svelte'
import type { TriggerContext } from '../triggers'
import type { Item } from '$lib/utils'
@@ -49,6 +48,10 @@
onSaved?: (newPath: string) => void
children?: import('svelte').Snippet
trigger_badges?: import('svelte').Snippet
/** Whether the bar is wide enough to hold every button beside the summary, decided by
* the layout from its own width. Not a viewport media query: the same page also renders
* inside an AI session's preview panel, where the window is wide and the pane is not. */
wide?: boolean
}
let {
@@ -64,15 +67,13 @@
inheritedLabels = undefined,
onSaved,
children,
trigger_badges
trigger_badges,
wide = true
}: Props = $props()
const dispatch = createEventDispatcher()
// Tailwind's `lg`, matched in JS so the one ellipsis menu can carry the collapsed buttons.
const wide = new MediaQuery('(min-width: 1024px)')
const barButtons = $derived(wide.current ? mainButtons : mainButtons.filter((b) => !b.narrow))
const barButtons = $derived(wide ? mainButtons : mainButtons.filter((b) => !b.narrow))
function dropdownHost(btn: MainButton): MainButton | undefined {
if (typeof btn.narrow !== 'object') return undefined
@@ -90,7 +91,7 @@
}
const allMenuItems: Item[] = $derived([
...(wide.current ? [] : mainButtons.filter((b) => b.narrow && !dropdownHost(b))).map((b) => ({
...(wide ? [] : mainButtons.filter((b) => b.narrow && !dropdownHost(b))).map((b) => ({
displayName: b.label,
description: b.description,
icon: b.buttonProps.startIcon,
@@ -99,7 +100,7 @@
disabled: b.buttonProps.disabled,
type: 'action' as const
})),
...(wide.current
...(wide
? []
: [
{
@@ -114,12 +115,12 @@
icon: item.Icon,
action: item.onclick,
type: item.color === 'red' ? ('delete' as const) : ('action' as const),
separatorTop: i === 0 && !wide.current
separatorTop: i === 0 && !wide
}))
])
function dropdownItemsOf(host: MainButton) {
if (wide.current) return undefined
if (wide) return undefined
const items = mainButtons
.filter((b) => dropdownHost(b) === host)
.map((b) => ({
@@ -181,7 +182,7 @@
<DropdownV2 items={allMenuItems} placement="bottom-end" size="md" />
{/key}
{/if}
{#if wide.current}
{#if wide}
<ErrorHandlerToggleButton
kind={errorHandlerKind}
{scriptOrFlowPath}
@@ -11,7 +11,7 @@
selected: string
forceSmallScreen?: boolean
isChatMode?: boolean
header?: import('svelte').Snippet
header?: import('svelte').Snippet<[{ wide: boolean }]>
/** `graphInline`: whether the form should carry the flow graph under it. It does in the
* split layout; the tabbed layout gives the graph a tab of its own. */
form?: import('svelte').Snippet<[{ graphInline: boolean }]>
@@ -70,7 +70,7 @@
<main class="h-screen w-full" bind:clientWidth>
{#if useDesktopLayout}
<div class="h-full w-full flex flex-col">
{@render header?.()}
{@render header?.({ wide: useDesktopLayout })}
<div class="grow min-h-0 w-full">
<Splitpanes>
<Pane size={65} minSize={50}>
@@ -101,7 +101,7 @@
</div>
{:else}
<div class="h-full w-full flex flex-col">
{@render header?.()}
{@render header?.({ wide: useDesktopLayout })}
<div class="grow min-h-0 w-full flex flex-col">
<!-- no-scrollbar: at phone widths the tabs overflow their strip, and a browser with
classic scrollbars would spend a track under them, opening a band between the tabs
@@ -603,8 +603,9 @@
schema: flow?.schema
}}
>
{#snippet header()}
{#snippet header({ wide }: { wide: boolean })}
<DetailPageHeader
{wide}
on:seeTriggers={() => {
detailLayout?.showTriggers()
}}
@@ -798,8 +798,9 @@
</Drawer>
{#key script?.hash}
<DetailPageLayout bind:selected={rightPaneSelected} isOperator={$userStore?.operator}>
{#snippet header()}
{#snippet header({ wide }: { wide: boolean })}
<DetailPageHeader
{wide}
{mainButtons}
menuItems={getMenuItems(script, deployUiSettings)}
bind:errorHandlerMuted={
@@ -15,6 +15,7 @@
path,
workspaceId,
onNavigate,
onSeeDetails,
isActiveSession = true,
active = true,
initialSelectedId
@@ -23,6 +24,9 @@
path: string
workspaceId: string
onNavigate?: (item: WorkspaceItem) => void
/** `Exit & see details` — flips this tab to the item's deployed view rather than
* navigating out of the session. */
onSeeDetails?: (e: { path: string }) => void
/** Forwarded to SessionEditorTarget — only the visible session claims the
* workspace's single live-editor slot. */
isActiveSession?: boolean
@@ -117,6 +121,7 @@
bind:savedFlow={cell.saved.val}
{diffDrawer}
{onNavigate}
onDetails={onSeeDetails}
condensedHeader={true}
customUi={{ topBar: { aiBuilder: false } }}
onDeploy={() => {
@@ -70,9 +70,6 @@
const notDeployed = $derived(loadState === 'not_found')
function setLoadState(state: 'loaded' | 'not_found') {
loadState = state
// Tell the runtime, so the View|Edit control can withhold the switch from here on
// rather than sending the next reader to this same empty panel.
runtime.setDeployedExists(kind, path, state === 'loaded')
}
$effect(() => {
reloadKey
@@ -33,6 +33,7 @@
darkMode,
fullscreen = false,
onNavigate,
onSeeDetails,
onLoad
}: {
tab: SessionPreviewTab
@@ -57,6 +58,9 @@
darkMode: boolean
/** A link click inside a live editor re-points the active preview tab. */
onNavigate: (item: WorkspaceItem) => void
/** An editor's `Exit & see details` — re-points this tab at the item's deployed
* view instead of navigating out of the session. */
onSeeDetails: (item: WorkspaceItem) => void
/** Iframe finished loading — the page reads back its observed location. */
onLoad: (frame: HTMLIFrameElement) => void
} = $props()
@@ -112,6 +116,16 @@
else if (slot.kind === 'viewer') viewVisited = true
})
// The path an editor reports is its deployed one, which a staged rename can make
// differ from this tab's — so the view opens on what actually exists.
const seeDetails = (e: { path: string }) =>
onSeeDetails({
path: e.path,
summary: '',
kind: itemKind === 'raw_app' ? 'app' : (itemKind as 'script' | 'flow'),
raw_app: itemKind === 'raw_app'
})
const sideVisibility = (shown: boolean) =>
shown ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'
// Where inside the editor the tab was opened on ("open this flow step in a
@@ -332,6 +346,7 @@
path={itemPath}
{workspaceId}
{onNavigate}
onSeeDetails={seeDetails}
{isActiveSession}
active={active && mode === 'edit'}
initialSelectedId={selectedId}
@@ -346,6 +361,7 @@
path={itemPath}
{workspaceId}
{onNavigate}
onSeeDetails={seeDetails}
{isActiveSession}
active={active && mode === 'edit'}
{fullscreen}
@@ -15,6 +15,7 @@
path,
workspaceId,
onNavigate,
onSeeDetails,
fullscreen = false,
isActiveSession = true,
active = true
@@ -23,6 +24,9 @@
path: string
workspaceId: string
onNavigate?: (item: WorkspaceItem) => void
/** `Exit & See details` — flips this tab to the item's deployed view rather than
* navigating out of the session. */
onSeeDetails?: (e: { path: string }) => void
/** Preview panel is in full screen: collapse the test pane in the narrow
* side-by-side layout, reopen it when there's room in full screen. */
fullscreen?: boolean
@@ -114,6 +118,7 @@
condensedHeader={true}
{diffDrawer}
{onNavigate}
{onSeeDetails}
testPanelCollapsed={!fullscreen}
onDeploy={(e) => {
// Fires on every deploy (primary, "Deploy & Stay here", and lib — we
@@ -203,12 +203,6 @@ 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
/** Whether a deployed version exists at (kind, path); `undefined` until something has
* looked. Read by the View|Edit control to withhold a switch that could only land on
* "not deployed yet". */
deployedExists(kind: SessionTargetKind, path: string): boolean | undefined
/** Record what a load found, from either side of the item. */
setDeployedExists(kind: SessionTargetKind, path: string, exists: boolean): void
/** 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
@@ -485,18 +479,6 @@ function createRuntime(session: Session): SessionRuntime {
function deployedRevision(kind: SessionTargetKind, path: string): number {
return deployedRevisions[revisionKey(kind, path)] ?? 0
}
// Whether a deployed version exists at (kind, path), recorded by whichever side found
// out: the editor cell's load carries `no_deployed`, and the viewer's own fetch either
// renders the deployed item or 404s. Kept on the runtime rather than either component
// so the answer outlives them — a tab opened straight onto the View side has no editor
// cell to ask, and the View|Edit control has to know before it can withhold the switch.
const deployedExistsByItem = $state<Record<string, boolean>>({})
function deployedExists(kind: SessionTargetKind, path: string): boolean | undefined {
return deployedExistsByItem[revisionKey(kind, path)]
}
function setDeployedExists(kind: SessionTargetKind, path: string, exists: boolean): void {
deployedExistsByItem[revisionKey(kind, path)] = exists
}
function logTabUsage(url: string): void {
logFeatureUsage('ai_session', 'tab', {
key: tabUsageKey(resolvePreviewTab(url)),
@@ -607,8 +589,6 @@ function createRuntime(session: Session): SessionRuntime {
flowCell,
loadedEditorPath,
deployedRevision,
deployedExists,
setDeployedExists,
logTabUsage,
async loadFlow(workspace: string, path: string, force = false) {
@@ -645,10 +625,8 @@ function createRuntime(session: Session): SessionRuntime {
try {
const result = await FlowService.getFlowByPath({ workspace, path, getDraft: true })
saved.val = result as SavedFlow
setDeployedExists('flow', path, !(result as { no_deployed?: boolean }).no_deployed)
} catch {
saved.val = undefined
setDeployedExists('flow', path, false)
}
await initFlow(aiDraft, store, stateStore, workspace)
if (deployedVersionId != null && store.val) store.val.version_id = deployedVersionId
@@ -660,7 +638,6 @@ function createRuntime(session: Session): SessionRuntime {
// No local draft yet — seed from `result.draft ?? result`.
const result = await FlowService.getFlowByPath({ workspace, path, getDraft: true })
saved.val = result as SavedFlow
setDeployedExists('flow', path, !(result as { no_deployed?: boolean }).no_deployed)
const flow: Flow = ((result as SavedFlow).draft ?? (result as Flow)) as Flow
// Seed the per-tab last_sync from the server draft's timestamp so the
// seeding save below attaches a matching last_sync and the server can
@@ -712,10 +689,8 @@ function createRuntime(session: Session): SessionRuntime {
try {
const result = await ScriptService.getScriptByPath({ workspace, path, getDraft: true })
saved.val = result as SavedScript
setDeployedExists('script', path, !(result as { no_deployed?: boolean }).no_deployed)
} catch {
saved.val = undefined
setDeployedExists('script', path, false)
}
// Clone before layering the AI draft on top, else we'd mutate
// `saved.val` in place and lose the pristine diff baseline.
@@ -756,7 +731,6 @@ function createRuntime(session: Session): SessionRuntime {
// No local draft yet — seed from `result.draft ?? result`.
const result = await ScriptService.getScriptByPath({ workspace, path, getDraft: true })
saved.val = result as SavedScript
setDeployedExists('script', path, !(result as { no_deployed?: boolean }).no_deployed)
// Clone before mutating, else `baseline` aliases `result` and
// `baseline.parent_hash` corrupts the diff baseline.
const baseline = structuredClone(
@@ -825,10 +799,8 @@ function createRuntime(session: Session): SessionRuntime {
custom_path: result.custom_path,
no_deployed: result.no_deployed
}
setDeployedExists('raw_app', path, !result.no_deployed)
} catch {
saved.val = undefined
setDeployedExists('raw_app', path, false)
}
store.val = applyDraftToRuntimeRawApp(
{
@@ -863,7 +835,6 @@ function createRuntime(session: Session): SessionRuntime {
custom_path: result.custom_path,
no_deployed: result.no_deployed
}
setDeployedExists('raw_app', path, !result.no_deployed)
// Prefer the server draft over the deployed value (mirrors the
// flow/script `result.draft ?? result`). A raw-app draft is already
// editor-shaped, same keys the extraction below reads.
@@ -67,11 +67,8 @@
parsePreviewItemRoute,
previewLocationLabel,
resolvePreviewTab,
type PreviewItemMode,
type PreviewTarget
} from '$lib/components/sessions/previewRouter'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import {
toolReloadEffect,
tabsToReload,
@@ -650,68 +647,6 @@
// to the plain path.
const parsedRoute = $derived(parsePreviewItemRoute(displayPath))
// The View|Edit control acts on the active tab by re-pointing its URL. Only an item
// with two sides gets one: pages, artifacts, pipelines and legacy drag-and-drop apps
// have a single side. Read from the tab's `url` rather than the observed `loc` — an
// in-realm side reports no location of its own, so `loc` can lag a flip.
const activeSide = $derived.by(() => {
const url = owner?.activeTab?.url
const slot = url ? resolvePreviewTab(url) : undefined
if (!slot) return undefined
if (slot.kind === 'viewer') {
return { kind: slot.viewerKind, path: slot.path, mode: 'view' as PreviewItemMode }
}
if (slot.kind === 'editor' && slot.editorKind !== 'pipeline') {
return { kind: slot.editorKind, path: slot.path, mode: 'edit' as PreviewItemMode }
}
return undefined
})
// Nothing to view until the item has been deployed once. This only ever hides the
// switch when the editor cell has actually been told so by the backend; when it knows
// nothing the switch stays open and the viewer says what it found, which is the case
// that has to work — a tab can be opened, or restored, straight onto the View side
// with no editor ever mounted.
const viewDisabledReason = $derived.by(() => {
const side = activeSide
const rt = activeRuntime
if (!side || !rt) return undefined
// Through the cell accessor, not `loadedEditorPath`: that one peeks into a plain
// Map, so this would read "no cell yet" once and never re-run when the load lands.
// The accessor creates the cell on a miss, which costs nothing here — a tab is open
// on this item, so its Edit side owns a cell either way.
const cell =
side.kind === 'flow'
? rt.flowCell(side.path)
: side.kind === 'script'
? rt.scriptCell(side.path)
: rt.rawAppCell(side.path)
// Two ways to know, because either side can be the one that looked: the editor
// cell's `no_deployed`, and what the viewer's own fetch found. An absent baseline is
// NOT a third: the cell is created empty and `SessionEditorTarget` clears
// `loadedPath` on every unmount, so "this cell knows nothing" is a state a deployed
// item reaches too — reading that as "not deployed" offered View on drafts and
// withheld it from deployed items, by whether the Edit side happened to be loaded.
const undeployed =
cell.saved.val?.no_deployed === true || rt.deployedExists(side.kind, side.path) === false
return undeployed ? 'Not deployed yet' : undefined
})
function switchSide(mode: PreviewItemMode) {
const side = activeSide
if (!side || side.mode === mode) return
const item: WorkspaceItem = {
path: side.path,
summary: '',
kind: side.kind === 'raw_app' ? 'app' : side.kind,
raw_app: side.kind === 'raw_app'
}
owner?.navigate({ type: 'item', item, mode })
// Counted per switch onto the View side, under keys disjoint from the tab-open
// ones, so `entity_count` answers how many sessions ever used it.
if (mode === 'view') activeRuntime?.logTabUsage(owner?.activeTab?.url ?? '')
}
// Split the item path into breadcrumb dirs + leaf, mirroring EditorHeader:
// scope (`f/<folder>` | `u/<user>`) → subfolders → item name. Prefers the
// tab's friendly path (a draft-only item's typed name): the picker tree
@@ -862,6 +797,12 @@
owner?.navigate({ type: 'item', item })
}
// 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.
function seeDetailsInPreview(item: WorkspaceItem) {
owner?.navigate({ type: 'item', item, mode: 'view' })
}
// A preview iframe that navigates to an editor route posts up to us instead of
// booting the editor inside the frame (see the logged layout's beforeNavigate).
// Retarget the active tab — the navigating frame is the visible one the user
@@ -1049,28 +990,6 @@
bind:clientWidth={previewActionsWidth}
class="absolute top-0 right-1 z-30 flex h-8 items-center gap-0.5"
>
{#if activeSide}
<!-- Both sides of an item are one tab; this re-points it. -->
<div class="mr-1">
<ToggleButtonGroup
selected={activeSide.mode}
on:selected={({ detail }) => switchSide(detail as PreviewItemMode)}
>
{#snippet children({ item })}
<ToggleButton small value="edit" label="Edit" icon={Pen} {item} />
<ToggleButton
small
value="view"
label="View"
icon={Eye}
disabled={!!viewDisabledReason}
tooltip={viewDisabledReason}
{item}
/>
{/snippet}
</ToggleButtonGroup>
</div>
{/if}
{#if !activeTabHasNoWorkspacePage}
<a
href={withWorkspaceParam(
@@ -1227,6 +1146,7 @@
darkMode={isDarkMode.val}
{fullscreen}
onNavigate={navigateEditorTo}
onSeeDetails={seeDetailsInPreview}
onLoad={(frame) => tabs && onTabLoad(tabs, tab, frame)}
/>
{/each}