{#each actions as entry, i (i)}
diff --git a/frontend/src/lib/components/PageHeaderContent.svelte b/frontend/src/lib/components/PageHeaderContent.svelte
index 5e023a68b3..2630faf863 100644
--- a/frontend/src/lib/components/PageHeaderContent.svelte
+++ b/frontend/src/lib/components/PageHeaderContent.svelte
@@ -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,
diff --git a/frontend/src/lib/components/common/tabs/DraggableTabs.svelte b/frontend/src/lib/components/common/tabs/DraggableTabs.svelte
index d89bd37fa6..ce25706b0c 100644
--- a/frontend/src/lib/components/common/tabs/DraggableTabs.svelte
+++ b/frontend/src/lib/components/common/tabs/DraggableTabs.svelte
@@ -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'
diff --git a/frontend/src/lib/components/pageHeaderRegistry.svelte.ts b/frontend/src/lib/components/pageHeaderRegistry.svelte.ts
index f7c4759009..03a004769e 100644
--- a/frontend/src/lib/components/pageHeaderRegistry.svelte.ts
+++ b/frontend/src/lib/components/pageHeaderRegistry.svelte.ts
@@ -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
diff --git a/frontend/src/lib/components/sessions/SessionWrapper.svelte b/frontend/src/lib/components/sessions/SessionWrapper.svelte
index 0743c0c94e..af00454f81 100644
--- a/frontend/src/lib/components/sessions/SessionWrapper.svelte
+++ b/frontend/src/lib/components/sessions/SessionWrapper.svelte
@@ -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 @@
{/snippet}
-
- {#if actingIsFork}
-
-
Acting on
-
- {/if}
{/snippet}
{#if headerInPage}
+ 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. -->
- 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 | 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 @@
+
+{#snippet fullscreenBand()}
+
+
+ {@render previewTabStrip(true)}
+
+
+ {@render previewControls()}
+
+
+{/snippet}
+
{#snippet sessionPageActions()}
{#if previewCollapsed && !fullscreen}
+
- {#if !fullscreen}
-
-
-
-
- {#each mountedChatSessions as s (s.id)}
-
-
-
- {/each}
-
-
- {/if}
+
+
+
+
+
+ {#each mountedChatSessions as s (s.id)}
+
+
+
+ {/each}
+
+
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'}"
>
-
-
- {#if activeWorkspaceHref}
-
-
-
- {/if}
-
(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"
+
+ {#if !fullscreen}
+
+
- {#if fullscreen}
-
- {:else}
-
- {/if}
-
-
+ {@render previewControls()}
+
-
-
(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}
-
-
- (e.currentTarget as HTMLElement)
- .closest('[role="tab"]')
- ?.focus()
- }}
- >
- {#snippet content()}
-
- {#key activePickerScope?.dir ?? ''}
- {
- activeTabPickerOpen = false
- navigatePreviewTo(t)
- }}
- />
- {/key}
- {/snippet}
-
-
- {/if}
- {/snippet}
- {#snippet afterTabs()}
-
- {#snippet trigger()}
-
- {/snippet}
- {#snippet content()}
- {
- newTabOpen = false
- openInNewTab(t)
- }}
- />
- {/snippet}
-
- {/snippet}
-
+ {@render previewTabStrip(false)}
+ {/if}
+{#snippet previewControls()}
+ {#if activeWorkspaceHref}
+
+
+
+ {/if}
+ {#if fullscreen}
+
+
+
+ Chat
+
+ {:else}
+
+
+
+ {/if}
+{/snippet}
+
+{#snippet previewTabStrip(inBand: boolean)}
+
+
+
(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}
+
+
+ (e.currentTarget as HTMLElement).closest('[role="tab"]')?.focus()
+ }}
+ >
+ {#snippet content()}
+
+ {#key activePickerScope?.dir ?? ''}
+ {
+ activeTabPickerOpen = false
+ navigatePreviewTo(t)
+ }}
+ />
+ {/key}
+ {/snippet}
+
+
+ {/if}
+ {/snippet}
+ {#snippet afterTabs()}
+
+ {#snippet trigger()}
+
+ {/snippet}
+ {#snippet content()}
+ {
+ newTabOpen = false
+ openInNewTab(t)
+ }}
+ />
+ {/snippet}
+
+ {/snippet}
+
+
+{/snippet}
+