feat: move the open in AI session button to the page header (#11888)

* feat: move the open in AI session button to the page header

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: address local review findings on the header session button

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: update header fold comments and test the page header session source

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: show the header session button to users who cannot edit the item

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: place the header session button right before the page's main action

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: drop an always-true guard and stale comments around the header session button

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: keep the compact autosave label for screen readers and correct two comments

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem
2026-10-21 18:34:00 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 17de2ba983
commit 34db23952a
17 changed files with 365 additions and 152 deletions
@@ -25,7 +25,8 @@
// Count of OTHER users with a draft here. >0 makes the on-mount hint
// take priority over "Loaded from draft" and offers "See others' drafts".
othersDraftsCount = 0,
onOpenOthersDrafts
onOpenOthersDrafts,
compact = false
}: {
workspace: string
itemKind: UserDraftItemKind
@@ -35,6 +36,8 @@
loadedFromDraft?: boolean
othersDraftsCount?: number
onOpenOthersDrafts?: () => void
/** The icon alone, for a bar with no room for the label, which stays for screen readers. */
compact?: boolean
} = $props()
// Reactive to the in-memory draft appearing/disappearing.
@@ -365,7 +368,7 @@
</Popover>
{#if label}
<span
class="relative text-2xs pr-1.5 {labelIsError
class="{compact ? 'sr-only' : 'relative'} text-2xs pr-1.5 {labelIsError
? 'text-red-500 font-semibold'
: 'text-secondary'}">{label}</span
>
@@ -84,6 +84,7 @@
import { type TriggerContext, type ScheduleTrigger } from './triggers'
import type { SavedAndModifiedValue } from './common/confirmationModal/unsavedTypes'
import DeployButton from './DeployButton.svelte'
import OpenInSessionButton from './sessions/OpenInSessionButton.svelte'
import { invalidateWorkspacePaths } from './PathNameAutocomplete.svelte'
import type { Trigger } from './triggers/utils'
import { deployTriggers, handleSelectTriggerFromKind } from './triggers/utils'
@@ -908,6 +909,9 @@
// falling back to `$pathStore` in drawer mounts that carry no storage path.
const sessionTargetPath = $derived(liveEditorDraftStoragePath || $pathStore)
const aiBuilderShown = $derived(!disableAi && customUi?.topBar?.aiBuilder != false)
const showFlowAiButton = $derived(aiBuilderShown && $roleCaps.flows !== 'compose')
const sessionOpen = $derived(
sessionTargetPath
? {
@@ -917,6 +921,7 @@
}
: undefined
)
const headerSessionOpen = $derived(aiBuilderShown ? sessionOpen : undefined)
// Reaches the AI entry point in a step's inline-editor toolbar, which the
// recursive module wrapper sits too deep under to be handed a prop. `selected`
@@ -1611,6 +1616,7 @@
: undefined
}}
actions={flowHeaderActions}
sessionSource={headerSessionOpen}
actionFolds={2}
contexts={headerContexts}
separator="none"
@@ -1793,6 +1799,7 @@
{@render previewButtons()}
{/if}
<OpenInSessionButton source={headerSessionOpen} />
<DeployButton
on:save={async ({ detail }) => await handleSaveFlow(detail)}
{loading}
@@ -1875,9 +1882,7 @@
{forceTestTab}
{highlightArg}
aiChatOpen={aiChatManager.open}
showFlowAiButton={!disableAi &&
customUi?.topBar?.aiBuilder != false &&
$roleCaps.flows !== 'compose'}
{showFlowAiButton}
toggleAiChat={() => aiChatManager.toggleOpen()}
{sessionOpen}
onOpenPreview={flowPreviewButtons?.openPreview}
@@ -33,6 +33,7 @@ The row's height matches the sidebar's own header row, so the two read as one ba
const content = $derived(pageHeader.content)
const actions = $derived(pageHeader.actions)
const hasActions = $derived(actions.length > 0)
const item = $derived(content?.item)
const section = $derived(content?.section)
@@ -82,8 +83,8 @@ The row's height matches the sidebar's own header row, so the two read as one ba
trail: trailMeasures.trail,
summary: BAR_STAGES.map(() => (hasSummary ? GAP + summaryFloorWidth : 0)),
actions:
actions.length === 0 ? [0] : actionsRigid ? actionWidths : [GAP + FLEXIBLE_ACTIONS_RESERVE],
folds: actions.length > 0 && actionsRigid ? (content?.actionFolds ?? 0) : 0,
!hasActions ? [0] : actionsRigid ? actionWidths : [GAP + FLEXIBLE_ACTIONS_RESERVE],
folds: hasActions && actionsRigid ? (content?.actionFolds ?? 0) : 0,
earlyFolds: content?.earlyActionFolds,
summaryNamesItem,
natural: {
@@ -314,7 +315,7 @@ The row's height matches the sidebar's own header row, so the two read as one ba
</div>
{/if}
{#if actions.length > 0}
{#if hasActions}
<!-- A plain row of buttons holds its width (shrink-0): squeezing it only pushes buttons out
of the bar, so the bar makes room by dropping breadcrumb parts and then by asking the
page to fold (`actionFolds`). Flexible actions (the Runs filter row) are a control that
@@ -15,6 +15,7 @@ the header, so nesting is decided by the caller rather than guessed from context
section,
afterName,
actions,
sessionSource,
actionsOrder,
actionsFlexible,
actionsFill,
@@ -35,6 +36,7 @@ the header, so nesting is decided by the caller rather than guessed from context
section,
afterName,
actions,
sessionSource,
actionsOrder,
actionsFlexible,
actionsFill,
+22 -19
View File
@@ -31,10 +31,12 @@
import Toggle from '$lib/components/Toggle.svelte'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import RunsQueue from '$lib/components/runs/RunsQueue.svelte'
import OpenInSessionButton from '$lib/components/sessions/OpenInSessionButton.svelte'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte'
import { RUNS_PATH } from '$lib/components/sessions/previewPaths'
import OpenInSessionButton, {
type OpenInSessionSource
} from '$lib/components/sessions/OpenInSessionButton.svelte'
import { computeJobKinds, useJobsLoader } from '$lib/components/runs/useJobsLoader.svelte'
import ConcurrentJobsChart from '$lib/components/ConcurrentJobsChart.svelte'
import BatchLoadProgress from '$lib/components/BatchLoadProgress.svelte'
@@ -651,11 +653,19 @@
}
let resolutionNote = $state('')
// The bar folds this row (`actionFolds` below) once its breadcrumb has given all it can: first
// the counters, which are a glance and repeat the chart below; then the session button; then
// the filter field moves to a row of its own rather than shrinking past usable. Hosted, the row
// is the page's own and wraps instead.
// the counters, which are a glance and repeat the chart below; then the filter field moves to a
// row of its own rather than shrinking past usable. Hosted, the row is the page's own and wraps
// instead.
const fold = $derived(hosted ? 0 : pageHeader.actionFold)
const filterBelow = $derived(fold >= 3)
const filterBelow = $derived(fold >= 2)
const sessionSource: OpenInSessionSource = $derived({
// The filters are shallow-routed, so the search has to come off `window.location` at click
// time — `page.url` never sees them. Always the canonical `/runs`: only that is a recognized
// preview page, and the `/runs/<path>` route mirrors its path into `?path=` anyway.
page: () => RUNS_PATH + window.location.search,
workspaceId: $operatingWorkspace ?? undefined
})
</script>
{#snippet runsHint()}
@@ -876,19 +886,11 @@
items={runsTimeframes}
bind:value={_timeframe.val}
/>
<!-- The filters are shallow-routed, so the search has to come off `window.location` at click
time — `page.url` never sees them. Always the canonical `/runs`: only that is a recognized
preview page, and the `/runs/<path>` route mirrors its path into `?path=` anyway. -->
<div class={fold >= 2 || hosted ? 'hidden' : 'contents'}>
<OpenInSessionButton
source={{
page: () => RUNS_PATH + window.location.search,
workspaceId: $operatingWorkspace ?? undefined
}}
btnProps={{ unifiedSize: 'sm' }}
/>
</div>
</div>
{#if !hosted}
<!-- No main action on this page: the session button ends the row. -->
<OpenInSessionButton source={sessionSource} />
{/if}
{/snippet}
<ConfirmationModal
@@ -971,9 +973,10 @@
<PageHeaderContent
afterName={runsHint}
actions={headerActions}
actionFolds={3}
{sessionSource}
actionFolds={2}
belowBar={filterRow}
belowBarFold={3}
belowBarFold={2}
/>
{/if}
@@ -22,6 +22,10 @@
} from '$lib/script_helpers'
import { isWorkflowAsCode } from './graph/wacToFlow'
import AIFormSettings from './copilot/AIFormSettings.svelte'
import { SUPPORTED_CHAT_SCRIPT_LANGUAGES } from './copilot/chat/script/core'
import OpenInSessionButton, {
type OpenInSessionSource
} from './sessions/OpenInSessionButton.svelte'
import {
defaultScripts,
enterpriseLicense,
@@ -212,9 +216,13 @@
const compactTopbar = $derived(
ownsPageHeader ? pageHeader.actionFold >= 1 : topbarWidth > 0 && topbarWidth < 900
)
// The second fold holds the trail and one button. Diff and Settings join the menu that compact
// In the page header no fold drops a button's label: what does not fit goes into the menu. The
// second fold holds the trail and one button. Diff and Settings join the menu that compact
// already shows, and Deploy stays out: it is what this page is for.
const phoneTopbar = $derived(ownsPageHeader && pageHeader.actionFold >= 2)
// The third fold leaves the autosave indicator as its icon, so the AI session button still fits
// right before Deploy on a phone.
const narrowTopbar = $derived(ownsPageHeader && pageHeader.actionFold >= 3)
// The workspace this editor operates on: deploy, save-draft, trigger loading
// and the AutosaveIndicator all target it. Falls back to the full-page
@@ -915,6 +923,24 @@
}
}
const sessionOpen: OpenInSessionSource | undefined = $derived(
userDraftPath
? {
// URL draft path the editor loads/saves by, not the friendly
// `script.path` (a new script's has no row → "not found").
target: { kind: 'script', path: userDraftPath },
workspaceId: opWorkspace ?? undefined,
beforeOpen: persistDraftForSession
}
: undefined
)
// The header's button, gated like the code editor's AI entry point it stands in for.
function headerSessionOpen(): OpenInSessionSource | undefined {
if (disableAi || customUi?.editorBar?.aiGen == false) return undefined
if (!SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(script?.language ?? '')) return undefined
return sessionOpen
}
// Inside an AI session pane (which injects an aiChatManager via context) the
// extra deploy-dropdown options — Deploy & Stay here, Fork, Edit in workspace
// fork, Export — don't make sense: the session always stays put and is already
@@ -2323,7 +2349,8 @@
}}
afterName={scriptMarks}
actions={scriptHeaderActions}
actionFolds={2}
sessionSource={headerSessionOpen()}
actionFolds={3}
separator="none"
/>
{:else}
@@ -2481,6 +2508,7 @@
{loadedFromDraft}
{othersDraftsCount}
{onOpenOthersDrafts}
compact={narrowTopbar}
/>
{/if}
{/snippet}
@@ -2510,7 +2538,7 @@
unifiedSize={headerBtnSize}
on:click={() => (metadataOpen = true)}
startIcon={{ icon: Settings }}
iconOnly={compactTopbar}
iconOnly={compactTopbar && !ownsPageHeader}
title="Settings"
>
<span> Settings </span>
@@ -2534,7 +2562,7 @@
on:click={() => openDiffDrawer()}
disabled={diffDisabled}
btnClasses={diffDisabled ? 'pointer-events-none' : undefined}
iconOnly={compactTopbar}
iconOnly={compactTopbar && !ownsPageHeader}
title={diffTitle}
startIcon={{ icon: DiffIcon }}
>
@@ -2580,6 +2608,9 @@
{@render settingsButton()}
{/if}
{#if ownsPageHeader}
<OpenInSessionButton source={headerSessionOpen()} />
{/if}
<DeployButton
loading={!fullyLoaded}
{loadingSave}
@@ -2652,15 +2683,7 @@
script.on_behalf_of_email
)}
workspaceOverride={opWorkspace}
sessionOpen={userDraftPath
? {
// URL draft path the editor loads/saves by, not the friendly
// `script.path` (a new script's has no row → "not found").
target: { kind: 'script', path: userDraftPath },
workspaceId: opWorkspace ?? undefined,
beforeOpen: persistDraftForSession
}
: undefined}
{sessionOpen}
bind:selectedTab={selectedInputTab}
{customUi}
{onTestJob}
@@ -2720,7 +2720,7 @@
{/if}
{#if !aiChatManager.open && !disableAi && !inSessionPane}
{#if customUi?.editorBar?.aiGen != false && SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(lang ?? '')}
<OpenInSessionButton source={sessionOpen}>
<OpenInSessionButton source={sessionOpen} deferToHeader>
{#snippet fallback()}
<HideButton
hidden={true}
@@ -55,7 +55,7 @@
{#snippet aiButton(unifiedSize: 'sm' | 'md')}
<!-- The fallback is a plain Button rather than AskAiButton, whose own session branch
would fire here too and open an empty session. -->
<OpenInSessionButton source={sessionSource} btnProps={{ unifiedSize }}>
<OpenInSessionButton source={sessionSource} btnProps={{ unifiedSize }} deferToHeader>
{#snippet fallback()}
<Button
{unifiedSize}
@@ -11,7 +11,13 @@
import type { Item } from '$lib/utils'
import { Bell, BellOff, Calendar } from 'lucide-svelte'
import { toggleWorkspaceErrorHandler } from './errorHandlerToggle'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import {
operatingWorkspaceContextEntry,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
import OpenInSessionButton, {
type OpenInSessionSource
} from '$lib/components/sessions/OpenInSessionButton.svelte'
const operatingWorkspace = useOperatingWorkspace()
@@ -42,7 +48,10 @@
const triggerContext = getContext<TriggerContext | undefined>('TriggerContext')
const { triggersCount, triggersState } = $state(triggerContext ?? ({} as Partial<TriggerContext>))
// The buttons below render in the page header, away from this page's tree.
const headerContexts = new Map<any, any>([['TriggerContext', triggerContext]])
const headerContexts = new Map<any, any>([
['TriggerContext', triggerContext],
operatingWorkspaceContextEntry()
])
interface Props {
mainButtons?: MainButton[]
@@ -104,6 +113,17 @@
// no error handler and says so itself.
const renameKind = $derived(itemKind ?? errorHandlerKind)
// The session's preview opens on this same deployed page (`/get/`), not the item's editor:
// someone here came to run the item, not to change it.
const sessionSource: OpenInSessionSource | undefined = $derived(
errorHandlerKind && path
? {
page: () => `/${errorHandlerKind}s/get/${path}`,
workspaceId: $operatingWorkspace ?? undefined
}
: undefined
)
// The band folds the buttons once its breadcrumb has given all it can (`actionFolds` below):
// first the secondary ones go to their `placement`, then every bar button but the last, which
// is what the page is for (Edit), then that one too. Outside the band — a session's preview
@@ -277,8 +297,15 @@
unifiedSize={size}
/>
{/if}
{#each barButtons as btn (btn.label)}
<!-- The session button goes right before the last bar button, the one the page is for (Edit). -->
{#if ownsPageHeader && barButtons.length === 0}
<OpenInSessionButton source={sessionSource} />
{/if}
{#each barButtons as btn, i (btn.label)}
{@const dropdownItems = dropdownItemsOf(btn)}
{#if ownsPageHeader && i === barButtons.length - 1}
<OpenInSessionButton source={sessionSource} />
{/if}
<Button
{...btn.buttonProps}
startIcon={{ icon: btn.buttonProps.startIcon }}
@@ -313,6 +340,7 @@
pathTrigger: onSaved ? pathTrigger : undefined
}}
{actions}
{sessionSource}
actionFolds={3}
earlyActionFolds={1}
contexts={headerContexts}
@@ -101,7 +101,7 @@
</Popover>
{/if}
{#if showFlowAiButton}
<OpenInSessionButton source={sessionOpen}>
<OpenInSessionButton source={sessionOpen} deferToHeader>
{#snippet fallback()}
<AIButton
label="Open AI chat"
@@ -1,5 +1,6 @@
import type { Snippet } from 'svelte'
import type { WorkspaceItemKind } from './workspacePicker'
import type { OpenInSessionSource } from './sessions/OpenInSessionButton.svelte'
/** An item the page is working on: the breadcrumb walks its path and ends with its summary. */
export type PageHeaderItem = {
@@ -41,6 +42,10 @@ export type PageHeaderContent = {
section?: PageHeaderSection
/** Rendered at the right end of the bar — the page's own buttons. */
actions?: Snippet
/** What the "Open in AI session" button in `actions` takes this page to. The page draws that
* button itself, right before its main action (Deploy, Edit), since only the page knows which
* that is; naming it here lets in-page buttons to the same destination stand down. */
sessionSource?: OpenInSessionSource
/** Orders these buttons among the other registrations', ascending; registration order breaks a
* tie. For a menu that must stay at the far end however many pages register before it. */
actionsOrder?: number
@@ -199,6 +204,14 @@ export const pageHeader = {
})
.sort((a, b) => a.order - b.order || a.i - b.i)
},
/** The innermost registration's session source: the hand-off the header offers. */
get sessionSource(): OpenInSessionSource | undefined {
for (let i = entries.length - 1; i >= 0; i--) {
const c = read(entries[i])
if (c?.sessionSource) return c.sessionSource
}
return undefined
},
/** Width of the bar in px, or 0 before it has been measured. */
get barWidth(): number {
return barWidth
@@ -52,4 +52,20 @@ describe('pageHeader', () => {
register({ actions: snippet('nested') })
expect(pageHeader.actions.map((a) => a.contexts)).toEqual([pageContexts, undefined])
})
// In-page buttons stand down when the header offers the same hand-off, so which registration
// is read decides which of them disappear; a nested registration that names no source must not
// hide the page's.
it('takes the session source of the innermost registration that has one', () => {
const route = { page: () => '/runs' }
const editor = { target: { kind: 'script' as const, path: 'u/admin/s' } }
register({ sessionSource: route })
register({ sessionSource: editor })
register({ section: { label: 'Nested' } })
expect(pageHeader.sessionSource).toBe(editor)
pageHeader.release(ids.splice(1, 1)[0])
expect(pageHeader.sessionSource).toBe(route)
pageHeader.release(ids.shift()!)
expect(pageHeader.sessionSource).toBeUndefined()
})
})
@@ -323,10 +323,19 @@
const compactTopbar = $derived(
ownsPageHeader ? pageHeader.actionFold >= 1 : topbarWidth > 0 && topbarWidth < 720
)
// The second fold keeps the trail, the file sidebar's toggle, Diff and Deploy. AI stands down —
// the sidebar's own Ask AI is the way in at that width — while Jobs and the export are already
// in the menu, Jobs from `compactTopbar` (true at the second fold too) and the export always.
// In the page header no fold drops a button's label: what does not fit goes into the menu.
// The second fold keeps the trail, the file sidebar's toggle and Deploy; Diff joins the menu.
// The docked-chat AI toggle stands down — the sidebar's own Ask AI is the way in at that
// width — while Jobs and the
// export are already in the menu, Jobs from `compactTopbar` (true at the second fold too) and
// the export always.
const phoneTopbar = $derived(ownsPageHeader && pageHeader.actionFold >= 2)
// The third fold leaves the autosave indicator as its icon, so the AI session button still fits
// right before Deploy on a phone.
const narrowTopbar = $derived(ownsPageHeader && pageHeader.actionFold >= 3)
// The last fold, for the narrowest phones: the file sidebar's toggle joins the menu, which is
// then the way back to a collapsed sidebar.
const sidebarToggleInMenu = $derived(ownsPageHeader && pageHeader.actionFold >= 4)
// Top-bar button size + bar height. Condensed (session preview) uses the
// smallest well-supported unified size (`sm`) so the bar is thinner.
@@ -704,6 +713,26 @@
const mod = isMac() ? '⌘' : 'Ctrl+'
let moreItems = $derived([
...(sidebarToggleInMenu && onToggleSidebar
? [
{
displayName: `${sidebarCollapsed ? 'Expand' : 'Collapse'} file sidebar`,
icon: sidebarCollapsed ? PanelLeft : PanelLeftClose,
action: () => onToggleSidebar?.(),
shortcut: `${mod}B`
}
]
: []),
...(phoneTopbar
? [
{
displayName: 'Diff',
icon: DiffIcon,
action: () => openDiffDrawer(),
disabled: !savedApp || newApp || savedApp?.no_deployed === true
}
]
: []),
...(compactTopbar
? [
{
@@ -956,7 +985,8 @@
pathTrigger: rawAppPathTrigger
}}
actions={rawAppHeaderActions}
actionFolds={2}
sessionSource={sessionOpen}
actionFolds={4}
contexts={headerContexts}
separator="none"
/>
@@ -996,7 +1026,7 @@
{/if}
{#snippet sidebarToggle()}
{#if onToggleSidebar}
{#if onToggleSidebar && !sidebarToggleInMenu}
<Button
unifiedSize="sm"
variant="subtle"
@@ -1019,6 +1049,7 @@
{loadedFromDraft}
{othersDraftsCount}
{onOpenOthersDrafts}
compact={narrowTopbar}
/>
{/if}
{/snippet}
@@ -1075,7 +1106,8 @@
{#snippet rawAppHeaderActions()}
<!-- The bar's own order kept: the file sidebar, how the draft is doing, who else is here, then
what to do with the app. The sidebar's toggle is the only way back once it is folded — the
collapsed sidebar renders nothing of its own — so it stays on the bar at every width. -->
collapsed sidebar renders nothing of its own — so it stays on the bar until the last fold,
which moves it into the ⋮ menu rather than dropping it. -->
{@render sidebarToggle()}
{@render autosaveIndicator()}
{@render awarenessMark()}
@@ -1102,9 +1134,11 @@
title={newApp || savedApp?.no_deployed === true
? 'Deploy this app once to compare against the deployed version'
: 'Diff'}
class={!savedApp || newApp || savedApp?.no_deployed === true
? 'flex cursor-not-allowed'
: 'flex'}
class={phoneTopbar
? 'hidden'
: !savedApp || newApp || savedApp?.no_deployed === true
? 'flex cursor-not-allowed'
: 'flex'}
>
<Button
variant="default"
@@ -1114,7 +1148,7 @@
btnClasses={!savedApp || newApp || savedApp?.no_deployed === true
? 'pointer-events-none'
: undefined}
iconOnly={compactTopbar}
iconOnly={compactTopbar && !ownsPageHeader}
title={newApp || savedApp?.no_deployed === true
? 'Deploy this app once to compare against the deployed version'
: 'Diff'}
@@ -1145,9 +1179,11 @@
<!-- Draws nothing in the bar: it is the export drawer, opened from the menu's Export entry
through this instance. -->
<AppExportButton bind:this={appExport} />
{#if !phoneTopbar}
<OpenInSessionButton source={sessionOpen}>
{#snippet fallback()}
<!-- Right before Deploy at every fold; only the opted-out docked-chat toggle gives way on a
phone. -->
<OpenInSessionButton source={sessionOpen}>
{#snippet fallback()}
{#if !phoneTopbar}
<Button
unifiedSize={headerBtnSize}
variant="default"
@@ -1158,9 +1194,9 @@
>
AI
</Button>
{/snippet}
</OpenInSessionButton>
{/if}
{/if}
{/snippet}
</OpenInSessionButton>
<Button
loading={loading.save}
startIcon={{ icon: Save }}
@@ -90,7 +90,9 @@
import ExecutionDuration from '$lib/components/ExecutionDuration.svelte'
import { isWindmillTooBigObject } from '$lib/components/job_args'
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
import OpenInSessionButton from '$lib/components/sessions/OpenInSessionButton.svelte'
import OpenInSessionButton, {
type OpenInSessionSource
} from '$lib/components/sessions/OpenInSessionButton.svelte'
import { onDestroy, setContext, untrack } from 'svelte'
import { getJobStatusKind, resetFavicon, setStatusFavicon } from '$lib/favicon'
@@ -715,11 +717,10 @@
onDestroy(resetFavicon)
}
// The bar folds these (`actionFolds` below) once its breadcrumb has given all it can: first the
// buttons drop their labels, then the plain actions go into one menu. Embedded, the buttons
// have a row of their own that wraps.
const compactBar = $derived(!embedded && pageHeader.actionFold >= 1)
const actionsInMenu = $derived(!embedded && pageHeader.actionFold >= 2)
// The bar folds these (`actionFolds` below) once its breadcrumb has given all it can: the plain
// actions go into one menu rather than losing their labels. Embedded, the buttons have a row
// of their own that wraps.
const actionsInMenu = $derived(!embedded && pageHeader.actionFold >= 1)
const isScriptJob = $derived(job?.job_kind === 'script')
const isRunnableJob = $derived(
@@ -757,6 +758,17 @@
(job?.job_kind === 'script' || job?.job_kind === 'flow') &&
editRights.canEditItem(isScriptJob ? 'script' : 'flow', job?.script_path ?? '', {})
)
// Opens the deployed runnable at this job's path, like Edit — unlike "View script", which pins
// the hash this run executed. Not gated on edit rights: a session also reads and explains, and
// the server still refuses a deploy the user has no right to.
const sessionOpen: OpenInSessionSource | undefined = $derived(
(job?.job_kind === 'script' || job?.job_kind === 'flow') && job?.script_path
? {
target: { kind: isScriptJob ? 'script' : 'flow', path: job?.script_path ?? '' },
workspaceId: workspaceId ?? undefined
}
: undefined
)
const canEditInFork = $derived(
(job?.job_kind === 'script' || job?.job_kind === 'flow') &&
editRights.capabilities.code &&
@@ -1080,7 +1092,8 @@
<PageHeaderContent
section={{ label: `run/${runId}` }}
actions={runActions}
actionFolds={2}
sessionSource={sessionOpen}
actionFolds={1}
{actionsVersion}
/>
{/if}
@@ -1090,15 +1103,6 @@
{#if restartStep !== undefined && canRestart(restart)}
{@render flowRestartButton(restart, restartStep, 'button')}
{/if}
{#if canEdit && showEditButton}
<OpenInSessionButton
source={{
target: { kind: isScriptJob ? 'script' : 'flow', path: job?.script_path ?? '' },
workspaceId: workspaceId ?? undefined
}}
btnProps={{ unifiedSize: 'sm' }}
/>
{/if}
<Dropdown items={menuItems} customWidth={260}>
{#snippet buttonReplacement()}
<Button
@@ -1111,6 +1115,7 @@
/>
{/snippet}
</Dropdown>
<OpenInSessionButton source={sessionOpen} />
{:else}
{@render runActionsRow()}
{/if}
@@ -1138,7 +1143,6 @@
href={runsHref}
variant="default"
unifiedSize="sm"
iconOnly={compactBar}
title="View runs"
startIcon={{ icon: List }}
>
@@ -1173,7 +1177,6 @@
nonCaptureEvent
variant="default"
unifiedSize="sm"
iconOnly={compactBar}
title="Share"
startIcon={{ icon: Share2 }}
>
@@ -1209,7 +1212,6 @@
<Button
unifiedSize="sm"
variant="default"
iconOnly={compactBar}
title={isHubFlowPreview
? 'Fork flow into workspace'
: `Fork ${isFlowPreview(job?.job_kind) ? 'flow' : 'code'} preview`}
@@ -1225,7 +1227,6 @@
<Button
unifiedSize="sm"
variant="default"
iconOnly={compactBar}
title="Current runs"
startIcon={{ icon: Activity }}
on:click={() => {
@@ -1302,7 +1303,6 @@
on:click={runAgain}
unifiedSize="sm"
variant="default"
iconOnly={compactBar}
title="Run again"
startIcon={{ icon: RefreshCw }}
loading={runImmediatelyLoading}
@@ -1332,22 +1332,9 @@
unifiedSize="sm"
variant="default"
disabled={!showEditButton}
iconOnly={compactBar}
title="Edit"
startIcon={{ icon: Pen }}>Edit</Button
>
{#if showEditButton}
<!-- Opens the deployed runnable at this job's path, like Edit — unlike
"View script", which pins the hash this run executed. Same gate as
Edit: where direct deployment is off, the way in is "Edit in fork". -->
<OpenInSessionButton
source={{
target: { kind: isScriptJob ? 'script' : 'flow', path: job?.script_path ?? '' },
workspaceId: workspaceId ?? undefined
}}
btnProps={{ unifiedSize: 'sm' }}
/>
{/if}
{/if}
{#if canEditInFork}
<Button
@@ -1359,19 +1346,21 @@
onClick={(e) => editInFork(e, isScriptJob ? 'script' : 'flow', job?.script_path ?? '')}
unifiedSize="sm"
variant="default"
iconOnly={compactBar}
title={editInForkLabel(workspaceId, $userWorkspaces)}
startIcon={{ icon: Pen }}>{editInForkLabel(workspaceId, $userWorkspaces)}</Button
>
{/if}
{/if}
{/if}
<OpenInSessionButton
source={sessionOpen}
btnProps={embedded ? { unifiedSize: 'sm' } : undefined}
/>
{#if job?.job_kind === 'script' || job?.job_kind === 'script_hub' || job?.job_kind === 'flow'}
<Button
href={viewHref}
unifiedSize="sm"
variant="accent"
iconOnly={compactBar}
title="View {job?.job_kind === 'script_hub' ? 'script' : job?.job_kind}"
startIcon={{
icon:
@@ -32,6 +32,13 @@
* only `window.location` read at that moment matches what the user sees. */
| { page: () => string | undefined; target?: never }
)
function sameDestination(a: OpenInSessionSource | undefined, b: OpenInSessionSource | undefined) {
if (!a || !b) return false
if (a.target && b.target)
return a.target.kind === b.target.kind && a.target.path === b.target.path
return !!a.page && !!b.page && a.page() === b.page()
}
</script>
<script lang="ts">
@@ -43,6 +50,7 @@
import { copilotInfo } from '$lib/aiStore'
import { sendUserToast } from '$lib/toast'
import { openSourceInSession } from './sessionSwitch.svelte'
import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte'
import { useRoleCapabilities } from '$lib/operatorWriteRights'
const roleCaps = useRoleCapabilities()
@@ -53,7 +61,8 @@
btnProps,
label,
tooltip,
fallback
fallback,
deferToHeader = false
}: {
/** Undefined (e.g. an item without a path yet) renders the fallback. */
source?: OpenInSessionSource
@@ -71,6 +80,11 @@
* opted-out user (typically the editor's inline-chat
* toggle). Never rendered inside the session panel. */
fallback?: Snippet
/** For a button inside a page whose header can carry the same hand-off: while the header
* does, this renders nothing, so the hand-off has one place on screen. The fallback still
* renders for a user who opted out of sessions, since the header then offers nothing. An
* editor in a drawer opened on another item keeps its own button. */
deferToHeader?: boolean
} = $props()
// Inside the session panel the chat is already on screen — a second entry
@@ -82,11 +96,14 @@
// prefersSessionHandoff carries the role clause: the sessions page refuses a role
// that cannot code, so an entry point on a page they can reach (Runs, the trigger lists)
// would only route them into that refusal.
const show = $derived(
const handoff = $derived(
!inSessionPanel &&
!!(source?.target || source?.page) &&
prefersSessionHandoff(!$roleCaps.code)
)
const show = $derived(
handoff && !(deferToHeader && sameDestination(pageHeader.sessionSource, source))
)
// Not $state: only read inside open() as a re-entrancy latch, never rendered.
let opening = false
@@ -118,6 +135,6 @@
{label}
{tooltip}
/>
{:else if !inSessionPanel}
{:else if !inSessionPanel && !handoff}
{@render fallback?.()}
{/if}
@@ -3,6 +3,7 @@
import { roleCapabilities, workspaceStore, userStore } from '$lib/stores'
import { PIPELINE_DRAFT_KIND, pipelineBundlePath } from '$lib/pipelinePaths'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte'
import { page } from '$app/state'
import Button from '$lib/components/common/button/Button.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
@@ -532,7 +533,8 @@
// autosave still inside its debounce has to land first.
async function persistDraftsForSession(): Promise<void> {
const workspace = $workspaceStore
if (!workspace) return
// A role that cannot code only views the pipeline, so there is no draft of theirs to save.
if (!workspace || viewOnly) return
await tick()
const draft = { workspace, itemKind: PIPELINE_DRAFT_KIND, path: pipelineDraftPath }
await UserDraftDbSyncer.flush(draft)
@@ -552,15 +554,11 @@
bypassNavigationGuard = true
}
const sessionOpen: OpenInSessionSource | undefined = $derived(
viewOnly
? undefined
: {
target: { kind: 'pipeline', path: folder },
workspaceId: $workspaceStore ?? undefined,
beforeOpen: persistDraftsForSession
}
)
const sessionOpen: OpenInSessionSource = $derived({
target: { kind: 'pipeline', path: folder },
workspaceId: $workspaceStore ?? undefined,
beforeOpen: persistDraftsForSession
})
// Lets the navigation rail's session switch open this pipeline, with its drafts.
setOpenInSessionHandoff({ source: () => sessionOpen })
@@ -1887,8 +1885,44 @@
// overflow (⋮) menu so the bar stays legible on small screens; only primary
// actions stay inline. Recording lives here rather than on the bar at all
// times — while armed it surfaces a compact inline pill (below) instead.
// The bar folds the actions (`actionFolds` below) once its breadcrumb has given all it can,
// moving them into the ⋮ menu rather than dropping their labels: first the view helpers, then
// the run and the drafts overlay. The mode toggle and Save all stay on the bar.
const viewHelpersInMenu = $derived(pageHeader.actionFold >= 1)
const runInMenu = $derived(pageHeader.actionFold >= 2)
let overflowMenuItems = $derived.by<Item[]>(() => {
const items: Item[] = []
if (runInMenu && !viewOnly && allPipelineScripts.length > 0) {
items.push({
displayName: cascadeRunningRoot ? 'Running…' : 'Run pipeline',
icon: Play,
disabled: !!cascadeRunningRoot,
action: () => runWholePipeline()
})
}
if (runInMenu && !viewOnly && mode === 'view' && pendingCount > 0) {
items.push({
displayName: `${includeDrafts ? 'Hide' : 'Show'} ${pendingCount} draft${pendingCount === 1 ? '' : 's'}`,
icon: Telescope,
action: () => (includeDrafts = !includeDrafts)
})
}
if (viewHelpersInMenu) {
if (mode === 'view') {
items.push({
displayName: activityShowing ? 'Hide activity' : 'Activity',
icon: History,
action: () => toggleActivity()
})
}
items.push({
displayName: 'Refresh',
icon: RefreshCw,
disabled: graphRes.loading,
action: () => graphRes.refetch()
})
}
if (!viewOnly && allPipelineScripts.length > 0) {
items.push({
displayName: recordingMode ? 'Disarm recorder' : 'Record next run',
@@ -2300,6 +2334,10 @@
.filter((r) => r.usage_kind === 'script' && r.in_pipeline)
.map((r) => r.path)
)
// What the bar shows of the page's main actions: the session button goes right before them.
const saveAllShown = $derived(mode === 'edit' && pendingCount > 0)
const runPipelineShown = $derived(!viewOnly && allPipelineScripts.length > 0 && !runInMenu)
// Run the whole pipeline: hand every script to the bounded-cascade engine,
// which topo-orders them (roots first) and fans downstream — i.e. run + all
// downstream from every source at once. Reuses the same per-hop launch/poll
@@ -2816,7 +2854,8 @@
<PageHeaderContent
section={{ label: 'Pipelines', content: pipelineCrumb }}
actions={pipelineActions}
actionsFlexible
sessionSource={sessionOpen}
actionFolds={2}
/>
{#snippet pipelineCrumb()}
@@ -2866,19 +2905,16 @@
{/snippet}
{#snippet pipelineActions()}
<!-- This page carries more actions than any other in the band, so the set scrolls rather than
being pushed off the end. `actionsFlexible` on the registration is what lets this box
shrink far enough for that to matter. -->
<div
class="flex flex-row items-center gap-2 min-w-0 overflow-x-auto scrollbar-hidden whitespace-nowrap"
>
<!-- This page carries more actions than any other in the band; what does not fit folds into
the ⋮ menu (see `viewHelpersInMenu`). -->
<div class="flex flex-row items-center gap-2 whitespace-nowrap">
{#if !viewOnly}
<!-- Center group: the mode toggle is the page's primary control —
anchored between the two flex-1 side groups so it stays
centered, with breathing room on both sides. -->
<div class="flex flex-row items-center gap-2 shrink-0">
<PipelineModeToggle {mode} draftCount={pendingCount} onModeChange={(m) => setMode(m)} />
{#if mode === 'view' && pendingCount > 0}
{#if mode === 'view' && pendingCount > 0 && !runInMenu}
<!-- View variant: overlay the unsaved drafts onto the deployed
graph — "what View will show once they're deployed". -->
<Button
@@ -2914,33 +2950,39 @@
Recording
</button>
{/if}
<!-- Pipeline-level run: always visible in both View and Edit so a
<!-- Pipeline-level run: in both View and Edit, on the bar or in the ⋮ menu, so a
run never requires hunting for a specific node's play button
(dbt `build` / Dagster "Materialize all"). Runs every script in
dependency order (roots first, cascading downstream) over the
displayed graph — deployed in View, draft-overlaid in Edit. -->
<Button
variant="accent-secondary"
unifiedSize="sm"
startIcon={{
icon: cascadeRunningRoot ? Loader2 : Play,
classes: cascadeRunningRoot ? 'animate-spin' : undefined
}}
onclick={runWholePipeline}
disabled={!!cascadeRunningRoot}
title={cascadeRunningRoot
? 'A pipeline run is already in progress'
: `Run all ${allPipelineScripts.length} script${
allPipelineScripts.length === 1 ? '' : 's'
} in dependency order (roots first, cascading downstream)`}
>
{cascadeRunningRoot ? 'Running…' : 'Run pipeline'}
</Button>
{#if !runInMenu}
{#if !saveAllShown}
{@render sessionButton()}
{/if}
<Button
variant="accent-secondary"
unifiedSize="sm"
startIcon={{
icon: cascadeRunningRoot ? Loader2 : Play,
classes: cascadeRunningRoot ? 'animate-spin' : undefined
}}
onclick={runWholePipeline}
disabled={!!cascadeRunningRoot}
title={cascadeRunningRoot
? 'A pipeline run is already in progress'
: `Run all ${allPipelineScripts.length} script${
allPipelineScripts.length === 1 ? '' : 's'
} in dependency order (roots first, cascading downstream)`}
>
{cascadeRunningRoot ? 'Running…' : 'Run pipeline'}
</Button>
{/if}
{/if}
{#if mode === 'edit' && saveErrors.size > 0}
<PipelineDeployErrors errors={saveErrors} bind:open={saveErrorsOpen} />
{/if}
{#if mode === 'edit' && pendingCount > 0}
{#if saveAllShown}
{@render sessionButton()}
<Button
variant="accent"
unifiedSize="sm"
@@ -2952,7 +2994,7 @@
{savingAll ? 'Saving…' : `Save all (${pendingCount})`}
</Button>
{/if}
{#if mode === 'view'}
{#if mode === 'view' && !viewHelpersInMenu}
<Button
variant={activityShowing ? 'accent-secondary' : 'subtle'}
unifiedSize="sm"
@@ -2965,20 +3007,29 @@
Activity
</Button>
{/if}
<OpenInSessionButton source={sessionOpen} />
<Button
variant="subtle"
unifiedSize="sm"
startIcon={{ icon: RefreshCw }}
onclick={() => graphRes.refetch()}
disabled={graphRes.loading}
iconOnly
title="Refresh"
/>
{#if !viewHelpersInMenu}
<Button
variant="subtle"
unifiedSize="sm"
startIcon={{ icon: RefreshCw }}
onclick={() => graphRes.refetch()}
disabled={graphRes.loading}
iconOnly
title="Refresh"
/>
{/if}
<DropdownV2 size="sm" items={overflowMenuItems} />
{#if !saveAllShown && !runPipelineShown}
{@render sessionButton()}
{/if}
</div>
{/snippet}
{#snippet sessionButton()}
<!-- Right before the page's main action: Save all while there are drafts, else Run pipeline. -->
<OpenInSessionButton source={sessionOpen} />
{/snippet}
<div class="flex flex-col h-full">
<div class="flex-1 min-h-0">
{#if graphRes.loading && !graphRes.current}
@@ -18,6 +18,10 @@
type HandlerDraft
} from '$lib/components/handlerDraft.svelte'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import { WORKSPACE_SETTINGS_PATH } from '$lib/components/sessions/previewPaths'
import OpenInSessionButton, {
type OpenInSessionSource
} from '$lib/components/sessions/OpenInSessionButton.svelte'
import ScriptPicker from '$lib/components/ScriptPicker.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
@@ -50,6 +54,8 @@
import { clone, emptyString, encodeState, hasUnsavedChanges } from '$lib/utils'
import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile'
import { ExternalLink, Slack, Target } from 'lucide-svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte'
import SidebarNavigation from '$lib/components/common/sidebar/SidebarNavigation.svelte'
import PremiumInfo from '$lib/components/settings/PremiumInfo.svelte'
@@ -1544,6 +1550,13 @@
const tabLabel = $derived(
navigationGroups.flatMap((g) => g.items).find((i) => i.id === tab)?.label
)
const sessionSource: OpenInSessionSource = $derived({
// Read at click time: the tab lives in the query, so the preview opens on the tab being
// viewed. YAML mode is component state and does not carry over.
page: () => WORKSPACE_SETTINGS_PATH + window.location.search,
workspaceId: $workspaceStore ?? undefined
})
</script>
<CenteredPage wrapperClasses="pb-0 h-full" handleOverflow={false} class="flex flex-col h-full">
@@ -1558,6 +1571,8 @@
? { parent: 'Workspace settings', label: tabLabel }
: undefined}
actions={settingsActions}
{sessionSource}
actionFolds={1}
separator="always"
/>
@@ -2561,10 +2576,21 @@ export async function main(
{/key}
{/if}
{#if $superadmin}
<Button variant="default" unifiedSize="sm" on:click={() => goto('#superadmin-settings')}>
Instance settings
</Button>
<!-- Goes into a menu on a phone, where the AI session button that ends the row would
otherwise squeeze the breadcrumb under these controls. -->
{#if pageHeader.actionFold >= 1}
<DropdownV2
size="sm"
items={[{ displayName: 'Instance settings', action: () => goto('#superadmin-settings') }]}
/>
{:else}
<Button variant="default" unifiedSize="sm" on:click={() => goto('#superadmin-settings')}>
Instance settings
</Button>
{/if}
{/if}
<!-- No main action on this page: the session button ends the row. -->
<OpenInSessionButton source={sessionSource} />
{/snippet}
<style>