feat: a session in full screen keeps its tabs in the band

Full screen used to drop the session's name from the breadcrumb and leave the
tab strip inside the panel. The band now carries both: the session's name where
"Sessions" stood, and from the breadcrumb to the right edge one filled surface
holding the tabs and the preview's controls, the bar's full height.

The switch between the two layouts is the panel expanding to the left. The chat
column is resized to nothing rather than unmounted, so it can be seen taking its
place -- and so a toggle no longer tears down every mounted chat and rebuilds it
on the way back. Panes transition their width only while that switch is in
flight, since a splitter drag sets the same inline width and would trail the
pointer.

The band floats over the content in full screen as it already did beside a
panel, so only its right edge moves and the page below never shifts by the
bar's height. Leaving full screen, the panel travels back across ground the
band has already claimed, so it passes one layer above it for the length of the
move: pinned to the width the panel will end at, the bar would otherwise show
through as a white patch over the panel's left edge.

The breadcrumb also gains the acting fork, the way every other page shows it,
which replaces the "Acting on" control the session header carried.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-10-05 11:32:54 -07:00
co-authored by Claude Opus 5
parent fe94f4dc44
commit bf80473d35
7 changed files with 333 additions and 202 deletions
@@ -53,9 +53,6 @@ same weight, size and icon size, so the line reads as one control rather than fo
} = $props()
const scopeId = $derived(actingWorkspaceId ?? $workspaceStore ?? undefined)
// In session mode the workspace is the session's scope, not a place to navigate to: the name
// says which workspace the chat acts on, and going home from it would leave the session.
const sessionMode = $derived(page.url.pathname.startsWith(`${base}/sessions`))
const homeHref = $derived(
actingWorkspaceId ? `${base}/?workspace=${encodeURIComponent(actingWorkspaceId)}` : `${base}/`
)
@@ -103,9 +100,7 @@ same weight, size and icon size, so the line reads as one control rather than fo
const forkableWorkspaces = $derived(forkable.current)
const currentWs = $derived(forkableWorkspaces.find((w) => w.id === scopeId))
const inFork = $derived(!!currentWs?.parent_workspace_id)
// A session names the workspace it acts on beside its own title, so here the family name alone
// says where the user is browsing — the fork in this spot would read as a workspace switch.
const showFork = $derived(inFork && !actingWorkspaceId)
const showFork = $derived(inFork)
const familyName = $derived(family?.name ?? scopeId ?? '')
// The workspace the user actually stands in, which is the fork's own name inside a fork.
const scopeName = $derived(currentWs?.name ?? familyName)
@@ -116,7 +111,6 @@ same weight, size and icon size, so the line reads as one control rather than fo
// A dev workspace wears its environment; the root wears "prod", so which environment the user
// is standing in is answered the same way whichever one it is.
const envBadge = $derived.by(() => {
if (actingWorkspaceId) return undefined
if (!inFork) return { text: 'prod', dev: false }
if (currentWs?.is_dev_workspace)
return { text: devBadgeText(currentWs.dev_workspace_label), dev: true }
@@ -182,8 +176,10 @@ same weight, size and icon size, so the line reads as one control rather than fo
{/if}
{/snippet}
{#snippet slash()}
<span class="shrink-0 text-hint/40 text-xs" aria-hidden="true">/</span>
{#snippet slash(extra = '')}
<!-- `extra` tops the workspace part's own 4px gap up to the 6px the breadcrumb puts around its
other separators, so the slash before a fork is spaced like every other slash on the line. -->
<span class={twMerge('shrink-0 text-hint/40 text-xs', extra)} aria-hidden="true">/</span>
{/snippet}
<nav
@@ -202,8 +198,8 @@ same weight, size and icon size, so the line reads as one control rather than fo
the name to line its left edge up with the disc. -->
<div class="flex items-center min-w-0" bind:clientWidth={nameWidth}>
<svelte:element
this={sessionMode ? 'span' : 'a'}
href={sessionMode ? undefined : homeHref}
this={'a'}
href={homeHref}
class={scopeChipClass}
title={showFork ? `${familyName} / ${scopeName}` : familyName}
>
@@ -225,7 +221,7 @@ same weight, size and icon size, so the line reads as one control rather than fo
{#if showFork}
<!-- Both names: the fork is where the work happens, the family is which product it
is part of, and either alone leaves the other to be guessed. -->
{@render slash()}
{@render slash('mx-0.5')}
<span class={forkChipClass} style={forkAccent}>
<GitFork size={ICON} class="flex-shrink-0" />
<span class="truncate">{scopeName}</span>
@@ -87,6 +87,9 @@ The row's height matches the sidebar's own header row, so the two read as one ba
class={twMerge(
'flex items-center gap-1 shrink-0 min-w-0 bg-surface transition-shadow duration-150',
phone ? 'flex-wrap content-center min-h-11 py-1 pl-1 pr-2' : 'h-11 pl-2 pr-4',
// The filling surface is what meets the right edge, so the bar keeps no gutter of its own
// there — and it cannot wrap, since that surface is as tall as the bar.
content?.actionsFill && 'pr-0 flex-nowrap',
// The small left padding is there to sit a button against: the sidebar handle, or the
// workspace disc, both of which carry their own visual margin. Embedded there is neither,
// and the page's name would start as bare text 8px from the edge.
@@ -171,11 +174,19 @@ The row's height matches the sidebar's own header row, so the two read as one ba
here that can give width back, and it can only do that if this box may shrink. A phone
only tightens the gutter — pages fold their own buttons into a menu at that width, and
a box that refused to shrink would make the one flexible field take its max instead. -->
<!-- `actionsFill`: the actions are a surface rather than a row, so they take the rest of the
bar and run to its right edge, and they stretch its full height because that surface
sits on the bottom edge. -->
<div
class={twMerge(
'ml-auto flex items-center',
phone && !content?.actionsFlexible ? 'shrink-0' : 'min-w-0',
phone ? 'gap-1 pl-1' : 'gap-2 pl-4'
'flex',
// pl-4: the gap between the breadcrumb and the filling surface is bare bar, so it
// belongs here rather than inside that surface, where it would be filled.
content?.actionsFill
? 'flex-1 min-w-0 self-stretch items-stretch pl-4'
: 'ml-auto items-center',
!content?.actionsFill && (phone && !content?.actionsFlexible ? 'shrink-0' : 'min-w-0'),
!content?.actionsFill && (phone ? 'gap-1 pl-1' : 'gap-2 pl-4')
)}
>
{#each actions as entry, i (i)}
@@ -17,6 +17,7 @@ the header, so nesting is decided by the caller rather than guessed from context
actions,
actionsOrder,
actionsFlexible,
actionsFill,
separator,
contexts,
actingWorkspaceId,
@@ -31,6 +32,7 @@ the header, so nesting is decided by the caller rather than guessed from context
actions,
actionsOrder,
actionsFlexible,
actionsFill,
separator,
contexts,
actingWorkspaceId,
@@ -50,6 +50,9 @@
/** Render after the right-pinned tabs, outside the scroll area so it stays
* pinned (e.g. a "Split with Preview" toggle). */
trailing?: import('svelte').Snippet
/** Tab height. `sm` (the default) is the 24px pill the panel strips use; `md` is 28px, for a
* strip that fills a 44px bar, where the smaller pill leaves too much air around it. */
size?: 'sm' | 'md'
/** Render inside each tab (after the label; receives the tab + isActive). Clicks
* bubble to the tab unless the accessory stops them. The tab is position:relative
* so an `absolute inset-0 pointer-events-none` child can anchor a whole-tab
@@ -65,6 +68,7 @@
onReorder,
onActiveClick,
class: c = '',
size = 'sm',
afterTabs,
trailing,
tabAccessory
@@ -180,7 +184,8 @@
function tabClasses(isActive: boolean) {
return twMerge(
'group relative inline-flex items-center gap-1.5 px-2.5 h-6 text-xs rounded-md select-none cursor-pointer whitespace-nowrap transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-border-selected focus-visible:ring-inset',
'group relative inline-flex items-center gap-1.5 text-xs rounded-md select-none cursor-pointer whitespace-nowrap transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-border-selected focus-visible:ring-inset',
size === 'md' ? 'h-7 px-3 gap-2' : 'h-6 px-2.5',
isActive
? 'bg-surface-tertiary text-emphasis'
: 'bg-transparent text-hint hover:text-secondary'
@@ -37,6 +37,11 @@ export type PageHeaderContent = {
* pipeline editors, whose rows are too wide for any bar). Without it the box holds its content
* width, because squeezing a plain row of buttons only pushes them out of the bar. */
actionsFlexible?: boolean
/** These actions are not a row of buttons at the far end but a surface of their own, running
* from the breadcrumb to the bar's right edge: a session in full screen, whose tab strip and
* controls share one shape that opens into the page below. The bar gives up its right padding
* to it, since the surface is what meets the edge. */
actionsFill?: boolean
/** Rendered right after the page's name in the breadcrumb: a mark that belongs to the name,
* like a documentation tooltip, or a control that acts on the thing named — an app's Edit,
* which at the far end of the bar would be a journey away from what it edits. */
@@ -106,6 +111,7 @@ export const pageHeader = {
if (c.actingWorkspaceId !== undefined) merged.actingWorkspaceId = c.actingWorkspaceId
if (c.barRightInset !== undefined) merged.barRightInset = c.barRightInset
if (c.actionsFlexible !== undefined) merged.actionsFlexible = c.actionsFlexible
if (c.actionsFill !== undefined) merged.actionsFill = c.actionsFill
if (c.separator !== undefined) merged.separator = c.separator
}
return merged
@@ -4,7 +4,6 @@
import AIChat from '$lib/components/copilot/chat/AIChat.svelte'
import EditableInput from '$lib/components/common/EditableInput.svelte'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import WorkspaceScopeTrigger from '$lib/components/WorkspaceScopeTrigger.svelte'
import { Button } from '$lib/components/common'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
@@ -110,9 +109,6 @@
// The workspace the session acts on. The page header's breadcrumb scopes itself to it, so a
// session running in a fork reads as that fork rather than as the navigation workspace.
const actingWorkspaceId = $derived(session ? getEffectiveWorkspaceId(session) : undefined)
const actingIsFork = $derived(
!!actingWorkspaceId && workspaceIsFork(actingWorkspaceId, $userWorkspaces)
)
// Load copilot config (models, providers) for the workspace the session acts
// on, not the navigation workspace — a session deliberately leaves
@@ -436,25 +432,13 @@
</span>
{/snippet}
</DropdownV2>
{#if actingIsFork}
<!-- Only a fork earns this: on the root the breadcrumb already names the workspace the
chat writes to, and repeating it says nothing. The fork is the case worth calling
out, and it belongs with the session's name rather than in the breadcrumb, where it
would read as if the user had switched workspace. -->
<span class="shrink-0 text-2xs text-tertiary pl-1">Acting on</span>
<WorkspaceScopeTrigger
workspaceId={actingWorkspaceId}
showChevron={false}
interactive={false}
class="min-w-0"
/>
{/if}
{/snippet}
{#if headerInPage}
<!-- The chat's own header row is the page header on this route: the session's name is its
breadcrumb, and the menu is its action. -->
breadcrumb, and the menu is its action. The workspace it acts on is named by the
breadcrumb's own workspace part, the way every other route names it — a session in a
fork reads like anything else in that fork. -->
<PageHeaderContent
section={{ label: session.summary ?? 'Untitled session', content: sessionHeader }}
{actingWorkspaceId}
@@ -1,14 +1,14 @@
<script lang="ts">
import { onMount, tick, untrack } from 'svelte'
import { onDestroy, onMount, tick, untrack } from 'svelte'
import { SvelteSet } from 'svelte/reactivity'
import { page } from '$app/state'
import {
Plus,
Maximize2,
Minimize2,
ExternalLink,
PanelRightClose,
PanelRightOpen,
Minimize2,
ChevronDown,
MonitorPlay,
Loader2
@@ -17,6 +17,8 @@
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import { Button } from '$lib/components/common'
import DraggableTabs, { type TabItem } from '$lib/components/common/tabs/DraggableTabs.svelte'
import { cubicOut } from 'svelte/easing'
import { useReducedMotion } from '$lib/svelte5Utils.svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import PreviewRouterPicker, {
type Scope
@@ -449,8 +451,51 @@
let emptyStateNewTabOpen = $state(false)
let fullscreen = $state(false)
// Going full screen is the preview panel expanding to the left: the chat column shrinks to
// nothing and the panel takes its place. Animating that is what carries the switch, so the
// panel is never unmounted — it is resized, like a collapse — and the tab strips, which live
// in two different trees (the panel's own card, and the band), only fade.
const reducedMotion = useReducedMotion()
const PANES_MOVE_MS = 240
/** Panes transition their width only while a full-screen switch is in flight. Left on, every
* splitter drag would lag 240ms behind the pointer. */
let panesAnimating = $state(false)
let panesAnimationTimer: ReturnType<typeof setTimeout> | undefined = undefined
function toggleFullscreen() {
fullscreen = !fullscreen
if (reducedMotion.val) return
panesAnimating = true
clearTimeout(panesAnimationTimer)
panesAnimationTimer = setTimeout(() => {
panesAnimating = false
// The panel's final width was measured while the writes above were suppressed, and a
// settled panel may get no further resize to publish it.
if (!fullscreen) panelWidth = previewWidth
}, PANES_MOVE_MS)
}
onDestroy(() => clearTimeout(panesAnimationTimer))
/** The arriving strip fades in over the expansion; the leaving one goes quickly, so the two
* are never both legible at once — they stand in different places, and a slow dissolve would
* read as two tab rows rather than one changing place. */
function stripArrive() {
return reducedMotion.val
? { duration: 0 }
: { duration: 180, delay: 60, easing: cubicOut, css: (t: number) => `opacity: ${t}` }
}
function stripLeave() {
return reducedMotion.val
? { duration: 0 }
: { duration: 80, easing: cubicOut, css: (t: number) => `opacity: ${t}` }
}
// Width of the preview panel, so the band can stop where it starts.
let previewWidth = $state(0)
/** The same width, excluding what the panel measures while it is expanding or contracting for
* a full-screen switch. The band's inset is read from this: fed the live measurement, the bar
* would be sized against a panel that has not arrived yet, and on the way back from full
* screen it starts at 146px — narrow enough that the bar wraps to two rows for a tenth of a
* second before the panel settles. */
let panelWidth = $state(0)
// Fullscreen is page state, not per-session, so it outlives a session switch —
// tell the incoming session's model, whose own collapsed flag it overrides, or
// re-opening the item plainly on screen would be judged invisible and not flash.
@@ -504,9 +549,10 @@
lastExpandedPreviewSize = previewPaneSize
}
if (full) {
// Chat pane is unmounted: the preview is the only pane and must own
// the full width, not its remembered split share.
// The preview owns the full width, not its remembered split share. The chat pane
// stays mounted at zero, so it has to be driven to 0 in the same pass.
previewPaneSize = 100
chatPaneSize = 0
} else if (collapsed) {
previewPaneSize = 0
chatPaneSize = 100
@@ -909,11 +955,37 @@
<!-- The session showing fills the band's breadcrumb. The band is the layout's, but it stops
where the preview panel starts so that panel can run from the top of the viewport. -->
<!-- Full screen drops the chat column, and with it the SessionWrapper that names the band — so the
page names it instead, and hands the band the preview's own strip and controls as one filling
surface. -->
<PageHeaderContent
barRightInset={previewCollapsed || fullscreen || previewWidth === 0 ? undefined : previewWidth}
actions={sessionPageActions}
barRightInset={fullscreen
? 0
: previewCollapsed || panelWidth === 0
? undefined
: panelWidth}
section={fullscreen ? { label: activeSession?.summary ?? 'Untitled session' } : undefined}
actions={fullscreen ? fullscreenBand : sessionPageActions}
actionsFill={fullscreen}
separator={fullscreen ? 'always' : 'none'}
/>
{#snippet fullscreenBand()}
<!-- The band is split in two: the breadcrumb on the page's own surface, and from there to the
right edge one filled panel carrying the tabs and the preview's controls. It takes the bar's
full height, and the bar's bottom border runs underneath it, so the line between header and
content is unbroken. -->
<div
class="flex-1 min-w-0 self-stretch flex items-center gap-1 pl-2.5 pr-2 bg-surface-secondary/50"
>
{@render previewTabStrip(true)}
<div class="flex-1 min-w-4"></div>
<div class="flex items-center gap-0.5 shrink-0">
{@render previewControls()}
</div>
</div>
{/snippet}
{#snippet sessionPageActions()}
{#if previewCollapsed && !fullscreen}
<!-- Collapsed preview: the way back to the side panel, in the band with the session's own
@@ -996,40 +1068,56 @@
switch, and a verdict still loading would otherwise tear them down on every
one. The covered stack is inert so nothing under the overlay takes focus. -->
<div class="flex-1 min-h-0 flex flex-col relative">
<!-- Leaving full screen, the panel travels back to the right across ground the band has
already claimed: the band is pinned to the width the panel will end at, so under it
the bar would show through as a white patch over the panel's left edge and the left
end of its tab strip. z-[31] — one above the band — puts the panel over it for the
length of the move instead, uncovering it as it settles. Entering full screen needs
none of this: the band is full width from the first frame and the panel stays below. -->
<div
class="flex-1 min-h-0 flex flex-row relative z-0"
class="flex-1 min-h-0 flex flex-row relative {panesAnimating && !fullscreen
? 'z-[31]'
: 'z-0'}"
use:splitterPointerCapture
inert={aiHiddenVerdict !== false}
>
<Splitpanes
horizontal={false}
class="flex-1 min-h-0 session-splitter {previewCollapsed ? 'splitter-off' : ''}"
class="flex-1 min-h-0 session-splitter {previewCollapsed || fullscreen
? 'splitter-off'
: ''} {panesAnimating ? 'panes-animating' : ''}"
>
{#if !fullscreen}
<!-- Chat column. Recently visited sessions stay mounted (stacked,
<!-- Chat column. Recently visited sessions stay mounted (stacked,
visibility-toggled) — see mountChat. -->
<!-- The band floats over this column's top strip while a panel is beside it, so the
column starts below it. The offset belongs on the pane, not on the stack's
container: the session stacks are `absolute inset-0` and would ignore padding. -->
<Pane
bind:size={chatPaneSize}
minSize={25}
class="flex flex-col min-h-0 {previewCollapsed || fullscreen ? '' : 'pt-11'}"
>
<div class="relative flex-1 min-h-0">
{#each mountedChatSessions as s (s.id)}
<div
class="absolute inset-0 flex flex-col {s.id === activeSession?.id
? 'z-10 opacity-100 pointer-events-auto'
: 'z-0 opacity-0 pointer-events-none'}"
aria-hidden={s.id !== activeSession?.id}
>
<SessionWrapper sessionId={s.id} headerInPage={s.id === activeSession?.id} />
</div>
{/each}
</div>
</Pane>
{/if}
<!-- The band floats over this column's top strip while a panel is beside it, so the
column starts below it. The offset belongs on the pane, not on the stack's
container: the session stacks are `absolute inset-0` and would ignore padding. -->
<!-- Full screen resizes this column to nothing rather than unmounting it, so the
panel can be seen taking its place — and so a toggle doesn't tear down every
mounted chat and rebuild it on the way back. -->
<Pane
bind:size={chatPaneSize}
minSize={fullscreen ? 0 : 25}
class="flex flex-col min-h-0 {previewCollapsed ? '' : 'pt-11'} {panesAnimating &&
!fullscreen
? '!bg-transparent'
: ''}"
>
<!-- Zero-width but still in the document, so its composer and buttons are taken
out of the tab order while the panel is full screen. -->
<div class="relative flex-1 min-h-0" inert={fullscreen}>
{#each mountedChatSessions as s (s.id)}
<div
class="absolute inset-0 flex flex-col {s.id === activeSession?.id
? 'z-10 opacity-100 pointer-events-auto'
: 'z-0 opacity-0 pointer-events-none'}"
aria-hidden={s.id !== activeSession?.id}
>
<SessionWrapper sessionId={s.id} headerInPage={s.id === activeSession?.id} />
</div>
{/each}
</div>
</Pane>
<!-- Preview panel: the live Windmill page, framed like the editor pane.
Always mounted (collapse resizes it to 0 — see previewPaneSize) so
@@ -1040,9 +1128,15 @@
maxSize={previewCollapsed ? 0 : 100}
class="flex flex-col min-h-0"
>
<!-- The band floats over this column too in full screen (it holds the tab strip
there), so the panel clears it with the same offset the chat column uses. -->
<div
bind:clientWidth={previewWidth}
class="flex-1 min-h-0 flex flex-col {fullscreen ? 'p-0' : 'p-2 pl-0'}"
bind:clientWidth={() => previewWidth,
(w) => {
previewWidth = w
if (!fullscreen && !panesAnimating) panelWidth = w
}}
class="flex-1 min-h-0 flex flex-col {fullscreen ? 'p-0 pt-11' : 'p-2 pl-0'}"
>
<!-- The action controls float over the tab strip, so the strip has to reserve
their width or tabs slide underneath them. Measured rather than guessed:
@@ -1069,141 +1163,22 @@
</button>
{/if}
<!-- Open-in-full-page + full-screen toggle, floating over the top-right
corner to mirror the collapse control. -->
<!-- Spans the tab strip's own height and centres within it, rather than
being pinned a fixed distance from the top, so these controls sit on
the same axis as the collapse button opposite. Its width is measured
so the strip can reserve room and its tabs never scroll underneath. -->
<div
bind:clientWidth={previewActionsWidth}
class="absolute top-0 right-1 z-30 flex h-8 items-center gap-0.5"
>
{#if activeWorkspaceHref}
<a
href={withWorkspaceParam(activeWorkspaceHref, 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)}
title={fullscreen ? 'Exit full screen' : 'Full screen'}
aria-label={fullscreen ? 'Exit full screen' : 'Full screen'}
class="inline-flex items-center justify-center w-6 h-6 rounded text-tertiary hover:text-primary hover:bg-surface-hover"
<!-- In full screen the strip and these controls move into the page header band,
which is empty there and has the width for them; the pane below keeps only
the content. -->
{#if !fullscreen}
<!-- Open-in-full-page + full-screen toggle, floating over the top-right
corner to mirror the collapse control. Its width is measured so the strip
can reserve room and its tabs never scroll underneath. -->
<div
bind:clientWidth={previewActionsWidth}
class="absolute top-0 right-1 z-30 flex h-8 items-center gap-0.5"
>
{#if fullscreen}
<Minimize2 size={14} />
{:else}
<Maximize2 size={14} />
{/if}
</button>
</div>
{@render previewControls()}
</div>
<!-- Tab strip: open preview pages, shared with the raw-app editor
(DraggableTabs). Clicking the active tab (label or accessory chevron)
toggles its breadcrumb picker; the "+" trailing opens the router picker.
Left/right padding clears the floating collapse/fullscreen buttons. -->
<DraggableTabs
tabs={previewTabItems}
activeId={owner?.activeId ?? ''}
onSelect={selectTab}
onActiveClick={() => (activeTabPickerOpen = !activeTabPickerOpen)}
onClose={closeTab}
onReorder={reorderTabs}
class="session-preview-tab-strip h-8 border-b border-light bg-surface-secondary/50 {fullscreen
? 'pl-1.5'
: 'pl-9'} pr-[var(--preview-actions-w,4rem)]"
>
{#snippet tabAccessory(_tab, isActive)}
{#if isActive}
<!-- Any active-tab click toggles the picker (`onActiveClick`); the tab
is excluded from pointerdown-outside so toggle doesn't race close.
The trigger is an inert whole-tab overlay (anchor only — clickable
would break dnd reorder); the chevron is purely visual. -->
<Popover
placement="bottom-start"
usePointerDownOutside
excludeSelectors=".drawer, .session-preview-tab-strip [role='tab'][aria-selected='true']"
disableFocusTrap
closeOnOtherPopoverOpen
enableFlyTransition
bind:isOpen={activeTabPickerOpen}
openFocus="[data-workspace-picker-search]"
contentClasses="flex flex-col overflow-hidden"
class="absolute inset-0 pointer-events-none"
triggerAttrs={{
'aria-label': 'Change preview',
tabindex: -1,
// The inert trigger only ever receives focus from melt's
// close-time restore; hand it straight to the tab so
// arrow/Delete tab shortcuts keep working.
onfocus: (e: FocusEvent) =>
(e.currentTarget as HTMLElement)
.closest<HTMLElement>('[role="tab"]')
?.focus()
}}
>
{#snippet content()}
<!-- The picker snapshots its scope at mount, but `friendlyPath` is
stamped async once the editor cell loads — a picker opened
before the stamp is scoped to the `draft_<uuid>` storage
folder while the tree groups the draft under its friendly
folder. Remount on the scope dir so it re-lands on the item. -->
{#key activePickerScope?.dir ?? ''}
<PreviewRouterPicker
initialScope={activePickerScope}
initialHighlight={activePickerHighlight}
{currentItem}
workspaceId={previewWorkspace}
artifacts={sessionArtifacts}
onPick={(t) => {
activeTabPickerOpen = false
navigatePreviewTo(t)
}}
/>
{/key}
{/snippet}
</Popover>
<ChevronDown
size={12}
class="shrink-0 text-tertiary group-hover:text-primary"
/>
{/if}
{/snippet}
{#snippet afterTabs()}
<Popover
placement="bottom-start"
usePointerDownOutside
excludeSelectors=".drawer"
disableFocusTrap
closeOnOtherPopoverOpen
bind:isOpen={newTabOpen}
enableFlyTransition
openFocus="[data-workspace-picker-search]"
contentClasses="flex flex-col overflow-hidden"
class="shrink-0 inline-flex items-center justify-center w-6 h-6 rounded text-tertiary hover:text-primary hover:bg-surface-hover cursor-pointer"
>
{#snippet trigger()}
<Plus size={14} />
{/snippet}
{#snippet content()}
<PreviewRouterPicker
workspaceId={previewWorkspace}
artifacts={sessionArtifacts}
onPick={(t) => {
newTabOpen = false
openInNewTab(t)
}}
/>
{/snippet}
</Popover>
{/snippet}
</DraggableTabs>
{@render previewTabStrip(false)}
{/if}
<!-- One host per tab of every warm session, stacked and
visibility-toggled so switching tabs or sessions never reloads
@@ -1306,6 +1281,149 @@
{/if}
</div>
<!-- The strip and the preview's own controls render in two places: in the panel, where they
are a row above the pane; and in full screen, inside the band's filling surface. Written
once so the two cannot drift. -->
{#snippet previewControls()}
{#if activeWorkspaceHref}
<a
href={withWorkspaceParam(activeWorkspaceHref, 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}
{#if fullscreen}
<!-- Named rather than an icon alone: full screen hid the chat, and what the user wants back
is the chat, not a window size. The band has the room for the word. -->
<button
type="button"
onclick={toggleFullscreen}
title="Back to chat"
class="inline-flex items-center gap-1.5 h-6 pl-1.5 pr-2 rounded text-xs text-tertiary hover:text-primary hover:bg-surface-hover"
>
<Minimize2 size={14} />
Chat
</button>
{:else}
<button
type="button"
onclick={toggleFullscreen}
title="Full screen"
aria-label="Full screen"
class="inline-flex items-center justify-center w-6 h-6 rounded text-tertiary hover:text-primary hover:bg-surface-hover"
>
<Maximize2 size={14} />
</button>
{/if}
{/snippet}
{#snippet previewTabStrip(inBand: boolean)}
<!-- Both layouts render this one snippet, so the move between them is described once. The
wrapper exists to carry it: `stripArrive` measures this box against where the strip stood
before the switch. -->
<div
class={inBand ? 'flex items-center h-full min-w-0' : 'shrink-0'}
in:stripArrive
out:stripLeave
>
<DraggableTabs
tabs={previewTabItems}
activeId={owner?.activeId ?? ''}
onSelect={selectTab}
onActiveClick={() => (activeTabPickerOpen = !activeTabPickerOpen)}
onClose={closeTab}
onReorder={reorderTabs}
size={inBand ? 'md' : 'sm'}
class={inBand
? 'session-preview-tab-strip h-full bg-transparent min-w-0'
: 'session-preview-tab-strip h-8 border-b border-light bg-surface-secondary/50 pl-9 pr-[var(--preview-actions-w,4rem)]'}
>
{#snippet tabAccessory(_tab, isActive)}
{#if isActive}
<!-- Any active-tab click toggles the picker (`onActiveClick`); the tab
is excluded from pointerdown-outside so toggle doesn't race close.
The trigger is an inert whole-tab overlay (anchor only — clickable
would break dnd reorder); the chevron is purely visual. -->
<Popover
placement="bottom-start"
usePointerDownOutside
excludeSelectors=".drawer, .session-preview-tab-strip [role='tab'][aria-selected='true']"
disableFocusTrap
closeOnOtherPopoverOpen
enableFlyTransition
bind:isOpen={activeTabPickerOpen}
openFocus="[data-workspace-picker-search]"
contentClasses="flex flex-col overflow-hidden"
class="absolute inset-0 pointer-events-none"
triggerAttrs={{
'aria-label': 'Change preview',
tabindex: -1,
// The inert trigger only ever receives focus from melt's
// close-time restore; hand it straight to the tab so
// arrow/Delete tab shortcuts keep working.
onfocus: (e: FocusEvent) =>
(e.currentTarget as HTMLElement).closest<HTMLElement>('[role="tab"]')?.focus()
}}
>
{#snippet content()}
<!-- The picker snapshots its scope at mount, but `friendlyPath` is
stamped async once the editor cell loads — a picker opened
before the stamp is scoped to the `draft_<uuid>` storage
folder while the tree groups the draft under its friendly
folder. Remount on the scope dir so it re-lands on the item. -->
{#key activePickerScope?.dir ?? ''}
<PreviewRouterPicker
initialScope={activePickerScope}
initialHighlight={activePickerHighlight}
{currentItem}
workspaceId={previewWorkspace}
artifacts={sessionArtifacts}
onPick={(t) => {
activeTabPickerOpen = false
navigatePreviewTo(t)
}}
/>
{/key}
{/snippet}
</Popover>
<ChevronDown size={12} class="shrink-0 text-tertiary group-hover:text-primary" />
{/if}
{/snippet}
{#snippet afterTabs()}
<Popover
placement="bottom-start"
usePointerDownOutside
excludeSelectors=".drawer"
disableFocusTrap
closeOnOtherPopoverOpen
bind:isOpen={newTabOpen}
enableFlyTransition
openFocus="[data-workspace-picker-search]"
contentClasses="flex flex-col overflow-hidden"
class="shrink-0 inline-flex items-center justify-center w-6 h-6 rounded text-tertiary hover:text-primary hover:bg-surface-hover cursor-pointer"
>
{#snippet trigger()}
<Plus size={14} />
{/snippet}
{#snippet content()}
<PreviewRouterPicker
workspaceId={previewWorkspace}
artifacts={sessionArtifacts}
onPick={(t) => {
newTabOpen = false
openInNewTab(t)
}}
/>
{/snippet}
</Popover>
{/snippet}
</DraggableTabs>
</div>
{/snippet}
<style>
/* Draggable gutter between the chat and the preview: a real (layout-occupying)
10px-wide grab zone, no overlap tricks that could cover the chat's scrollbar
@@ -1328,6 +1446,15 @@
border-radius: 9999px !important;
}
/* The full-screen switch is the preview panel expanding to the left over the chat column, so
the panes' widths are animated — but only while that switch is in flight (`panes-animating`),
since a splitter drag sets the same inline width and would trail the pointer. */
:global(.splitpanes--vertical.panes-animating) > :global(.splitpanes__pane) {
/* `!important` to beat app.css's blanket `transition: none !important` on vertical panes,
which is there so a splitter drag tracks the pointer exactly. */
transition: width 240ms cubic-bezier(0.33, 1, 0.68, 1) !important;
}
/* Collapsed preview: the pane is resized to 0 but stays mounted, so remove
the gutter entirely — it would otherwise leave a dead 10px drag zone on the
chat's right edge. */