mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-23 16:03:33 +00:00
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:
co-authored by
Claude Opus 5.5
parent
17de2ba983
commit
34db23952a
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user