mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 00:03:00 +00:00
Merge remote-tracking branch 'origin/enable-operator-ai-chat-nav' into improve-data-pipelines
# Conflicts: # frontend/src/lib/components/SummaryPathDisplay.svelte # frontend/src/lib/components/sessions/SessionsBetaBanner.svelte # frontend/src/routes/(root)/(logged)/+layout.svelte
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
<!--
|
||||
@component
|
||||
The inside of one breadcrumb part: an optional leading icon and the label.
|
||||
|
||||
Every part of the header's breadcrumb renders this, whether its dropdown is a melt menu or a
|
||||
popover, so they share one look. Separators are the host's job — a part must not contain the
|
||||
slash before it, or hovering the part would highlight the separator too.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte'
|
||||
|
||||
let { label, icon }: { label: string; icon?: Snippet } = $props()
|
||||
</script>
|
||||
|
||||
{@render icon?.()}
|
||||
<span class="truncate">{label}</span>
|
||||
@@ -6,7 +6,7 @@ state — switching popovers relies on melt-ui's `closeOnOtherPopoverOpen` to
|
||||
close siblings.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { ChevronRight } from 'lucide-svelte'
|
||||
import BreadcrumbItemContent from './BreadcrumbItemContent.svelte'
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
import WorkspaceItemDrillPicker, {
|
||||
type Scope
|
||||
@@ -15,7 +15,8 @@ close siblings.
|
||||
|
||||
interface Props {
|
||||
label: string
|
||||
withChevron?: boolean
|
||||
/** Leading glyph, e.g. the folder or user a path scope names. */
|
||||
icon?: import('svelte').Snippet
|
||||
extraClass?: string
|
||||
disabled?: boolean
|
||||
/** Where the picker lands when this segment is clicked. */
|
||||
@@ -33,7 +34,7 @@ close siblings.
|
||||
|
||||
let {
|
||||
label,
|
||||
withChevron = false,
|
||||
icon,
|
||||
extraClass = '',
|
||||
disabled = false,
|
||||
initialScope,
|
||||
@@ -71,8 +72,7 @@ close siblings.
|
||||
{disabled}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
{#if withChevron}<ChevronRight size={10} class="shrink-0" /><span class="truncate">{label}</span
|
||||
>{:else}{label}{/if}
|
||||
<BreadcrumbItemContent {label} {icon} />
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<WorkspaceItemDrillPicker
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<!--
|
||||
@component
|
||||
Re-exposes contexts from one component tree inside another.
|
||||
|
||||
A snippet handed to the page header renders under the header, not under the page that wrote it, so
|
||||
any component it creates looks its contexts up in the header's tree and finds nothing. The page
|
||||
passes the entries its buttons need and this sets them again around the render.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { setContext, type Snippet } from 'svelte'
|
||||
|
||||
let { contexts, children }: { contexts?: Map<any, any>; children: Snippet } = $props()
|
||||
|
||||
// setContext only takes effect during init, so this instance is keyed on `contexts` by callers.
|
||||
if (contexts) for (const [key, value] of contexts) setContext(key, value)
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Pencil } from 'lucide-svelte'
|
||||
import { ChevronRight, Pencil } from 'lucide-svelte'
|
||||
import { Alert, Button } from '$lib/components/common'
|
||||
import EditableInput from '$lib/components/common/EditableInput.svelte'
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
@@ -14,6 +14,7 @@
|
||||
type WorkspaceItemKind
|
||||
} from '$lib/components/workspacePicker'
|
||||
import BreadcrumbSegment from '$lib/components/BreadcrumbSegment.svelte'
|
||||
import { splitItemPath } from '$lib/components/breadcrumbPath'
|
||||
import { isOwner } from '$lib/utils'
|
||||
import { userStore } from '$lib/stores'
|
||||
import {
|
||||
@@ -98,27 +99,7 @@
|
||||
// follows the live `path` — coherent with the picker because we inject a
|
||||
// virtual current-item entry at that same live path (see `currentItem`).
|
||||
let displayPath = $derived(snapshotPath ?? path ?? '')
|
||||
/** Breakdown for breadcrumb rendering. Top-level dirs (scopes) come first
|
||||
* — `f/<folder>` or `u/<user>` — then any number of subfolders, then the
|
||||
* item name. Each dir entry's `fullPath` is the cumulative path up to
|
||||
* that level, used to key the picker's `initialOpen` / `initialHighlight`. */
|
||||
let segments = $derived.by(() => {
|
||||
const parts = displayPath.split('/')
|
||||
if (parts.length < 3) return null
|
||||
const scope = parts.slice(0, 2).join('/')
|
||||
const slug = parts.slice(2)
|
||||
const dirs: { name: string; fullPath: string }[] = []
|
||||
// Top-level scope is a dir.
|
||||
dirs.push({ name: scope, fullPath: scope })
|
||||
// Intermediate slug parts (everything except the last one).
|
||||
let acc = scope
|
||||
for (let i = 0; i < slug.length - 1; i++) {
|
||||
acc = `${acc}/${slug[i]}`
|
||||
dirs.push({ name: slug[i], fullPath: acc })
|
||||
}
|
||||
const leaf = { name: slug[slug.length - 1], fullPath: displayPath }
|
||||
return { dirs, leaf }
|
||||
})
|
||||
let segments = $derived(splitItemPath(displayPath) ?? null)
|
||||
|
||||
// Treat an empty path as ownable so the pen popover lets a user pick the
|
||||
// path for a brand-new item. `Path.reset()` then synthesizes a default
|
||||
@@ -145,7 +126,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="inline-block max-w-full align-top group px-2 py-0.5 leading-tight">
|
||||
<div class="max-w-full group px-2 py-0.5 leading-tight inline-block align-top">
|
||||
<!-- Path row -->
|
||||
{#if !hidePath}
|
||||
<div class="flex items-center max-w-full text-2xs text-secondary font-mono">
|
||||
@@ -162,9 +143,9 @@
|
||||
{#if segments}
|
||||
{#each segments.dirs as dir, i (dir.fullPath)}
|
||||
{@const dKey = dirKey('all', dir.fullPath)}
|
||||
<ChevronRight size={10} class="shrink-0" />
|
||||
<BreadcrumbSegment
|
||||
label={dir.name}
|
||||
withChevron
|
||||
extraClass={i === 0 ? 'gap-0.5 min-w-0 max-w-[40%]' : 'gap-0.5 min-w-0'}
|
||||
initialScope={i === 0
|
||||
? { kind: 'all' }
|
||||
@@ -177,9 +158,9 @@
|
||||
{/each}
|
||||
{@const leafKey = leafKeyFor(kind, segments.leaf.fullPath)}
|
||||
{@const leafParent = segments.dirs[segments.dirs.length - 1]?.fullPath}
|
||||
<ChevronRight size={10} class="shrink-0" />
|
||||
<BreadcrumbSegment
|
||||
label={segments.leaf.name}
|
||||
withChevron
|
||||
extraClass="gap-0.5 min-w-0"
|
||||
initialScope={leafParent ? { kind: 'all', dir: leafParent } : { kind: 'all' }}
|
||||
initialHighlight={leafKey}
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
import AIChangesWarningModal from '$lib/components/copilot/chat/flow/AIChangesWarningModal.svelte'
|
||||
|
||||
import { getContext, onDestroy, setContext, untrack } from 'svelte'
|
||||
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
|
||||
import { writable } from 'svelte/store'
|
||||
import CenteredPage from './CenteredPage.svelte'
|
||||
import { Button } from './common'
|
||||
@@ -68,6 +69,7 @@
|
||||
import FlowHistory from './flows/FlowHistory.svelte'
|
||||
import EditorHeader from './EditorHeader.svelte'
|
||||
import AutosaveIndicator from './AutosaveIndicator.svelte'
|
||||
import EditableInput from './common/EditableInput.svelte'
|
||||
import type { FlowBuilderWhitelabelCustomUi } from './custom_ui'
|
||||
import FlowYamlEditor from './flows/header/FlowYamlEditor.svelte'
|
||||
import { type TriggerContext, type ScheduleTrigger } from './triggers'
|
||||
@@ -157,12 +159,16 @@
|
||||
othersDraftsCount = 0,
|
||||
onOpenOthersDrafts,
|
||||
onTestJob,
|
||||
condensedHeader = false
|
||||
condensedHeader = false,
|
||||
/** True for the route's own editor: its top bar becomes the page header. */
|
||||
ownsPageHeader = false
|
||||
}: FlowBuilderProps = $props()
|
||||
|
||||
// Top-bar button size + bar height. Condensed (session preview) uses the
|
||||
// smallest well-supported unified size (`sm`) so the bar is thinner.
|
||||
const headerBtnSize = $derived(condensedHeader ? 'sm' : 'md')
|
||||
// In the page header the buttons ride a 40px band, the same size the condensed session-pane
|
||||
// header uses.
|
||||
const headerBtnSize = $derived(condensedHeader || ownsPageHeader ? 'sm' : 'md')
|
||||
|
||||
// The workspace this editor operates on: deploy, save-draft, trigger loading
|
||||
// and the AutosaveIndicator all target it. Falls back to the global store, so
|
||||
@@ -1271,6 +1277,16 @@
|
||||
|
||||
setContext('FlowCopilotContext', flowCopilotContext)
|
||||
|
||||
// The header's action buttons render under the page header, not under this component, so the
|
||||
// contexts they look up have to travel with them. Read back here, after every setContext above.
|
||||
const headerContexts = new Map<any, any>([
|
||||
['FlowEditorContext', getContext('FlowEditorContext')],
|
||||
['TriggerContext', getContext('TriggerContext')],
|
||||
['FlowGraphAssetContext', getContext('FlowGraphAssetContext')],
|
||||
['FlowCopilotContext', flowCopilotContext],
|
||||
['customUi', customUi]
|
||||
])
|
||||
|
||||
let renderCount = $state(0)
|
||||
|
||||
let jsonViewerDrawer: Drawer | undefined = $state(undefined)
|
||||
@@ -1530,78 +1546,155 @@
|
||||
<FlowEditorDrawer bind:this={$flowEditorDrawer} />
|
||||
|
||||
<div bind:this={flowBuilderRoot} class="flex flex-col h-full">
|
||||
<!-- Nav between steps-->
|
||||
<div
|
||||
bind:clientWidth={topbarWidth}
|
||||
class="justify-between flex flex-row items-center pl-2 pr-4 space-x-4 scrollbar-hidden overflow-x-auto h-full relative {condensedHeader
|
||||
? 'max-h-9'
|
||||
: 'max-h-12'}"
|
||||
>
|
||||
<div class="flex flex-row items-center gap-2 min-w-0">
|
||||
{#if customUi?.topBar?.path != false}
|
||||
<div class="min-w-0 overflow-hidden">
|
||||
<EditorHeader
|
||||
bind:summary={flowStore.val.summary}
|
||||
bind:path={$pathStore}
|
||||
savedPath={initialPath}
|
||||
onBehalfOfEmail={$savedOnBehalfOfEmail}
|
||||
summaryEditable={customUi?.topBar?.editableSummary != false}
|
||||
pathEditable={customUi?.topBar?.editablePath != false}
|
||||
hidePath={condensedHeader}
|
||||
workspaceId={autosaveWorkspace}
|
||||
onNavigate={(item) => onNavigate?.(item)}
|
||||
{#if ownsPageHeader}
|
||||
<!-- The editor's own top bar is the page header on this route: its breadcrumb and
|
||||
summary are the header's, and its buttons are the header's actions. -->
|
||||
<PageHeaderContent
|
||||
item={{ kind: 'flow', path: $pathStore, summaryContent: flowSummary }}
|
||||
actions={flowHeaderActions}
|
||||
contexts={headerContexts}
|
||||
/>
|
||||
{:else}
|
||||
<!-- Nav between steps-->
|
||||
<div
|
||||
bind:clientWidth={topbarWidth}
|
||||
class="justify-between flex flex-row items-center pl-2 pr-4 space-x-4 scrollbar-hidden overflow-x-auto h-full relative {condensedHeader
|
||||
? 'max-h-9'
|
||||
: 'max-h-12'}"
|
||||
>
|
||||
<div class="flex flex-row items-center gap-2 min-w-0">
|
||||
{#if customUi?.topBar?.path != false}
|
||||
<div class="min-w-0 overflow-hidden">
|
||||
<EditorHeader
|
||||
bind:summary={flowStore.val.summary}
|
||||
bind:path={$pathStore}
|
||||
savedPath={initialPath}
|
||||
onBehalfOfEmail={$savedOnBehalfOfEmail}
|
||||
summaryEditable={customUi?.topBar?.editableSummary != false}
|
||||
pathEditable={customUi?.topBar?.editablePath != false}
|
||||
hidePath={condensedHeader}
|
||||
workspaceId={autosaveWorkspace}
|
||||
onNavigate={(item) => onNavigate?.(item)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if opWorkspace && indicatorPath !== undefined}
|
||||
<AutosaveIndicator
|
||||
workspace={opWorkspace}
|
||||
itemKind="flow"
|
||||
path={indicatorPath}
|
||||
draftOnly={newFlow}
|
||||
{onResetToDeployed}
|
||||
{loadedFromDraft}
|
||||
{othersDraftsCount}
|
||||
{onOpenOthersDrafts}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if opWorkspace && indicatorPath !== undefined}
|
||||
<AutosaveIndicator
|
||||
workspace={opWorkspace}
|
||||
itemKind="flow"
|
||||
path={indicatorPath}
|
||||
draftOnly={newFlow}
|
||||
{onResetToDeployed}
|
||||
{loadedFromDraft}
|
||||
{othersDraftsCount}
|
||||
{onOpenOthersDrafts}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-row gap-2 items-center shrink-0">
|
||||
{#if $enterpriseLicense && !newFlow && !inSessionPane}
|
||||
<Awareness />
|
||||
{/if}
|
||||
<Dropdown items={getMoreItems} size={headerBtnSize} fixedHeight={!condensedHeader} />
|
||||
{#if diffEnabled && !diffInMenu}
|
||||
<!-- A disabled <button> fires no pointer events, so a title/tooltip on
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-row gap-2 items-center shrink-0">
|
||||
{#if $enterpriseLicense && !newFlow && !inSessionPane}
|
||||
<Awareness />
|
||||
{/if}
|
||||
<Dropdown items={getMoreItems} size={headerBtnSize} fixedHeight={!condensedHeader} />
|
||||
{#if diffEnabled && !diffInMenu}
|
||||
<!-- A disabled <button> fires no pointer events, so a title/tooltip on
|
||||
it never shows on hover. pointer-events-none on the button lets the
|
||||
hover reach this titled wrapper instead. -->
|
||||
<div title={diffTitle} class={diffDisabled ? 'flex cursor-not-allowed' : 'flex'}>
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize={headerBtnSize}
|
||||
on:click={() => openDiffDrawer()}
|
||||
disabled={diffDisabled}
|
||||
btnClasses={diffDisabled ? 'pointer-events-none' : undefined}
|
||||
title={diffTitle}
|
||||
startIcon={{ icon: DiffIcon }}
|
||||
>
|
||||
Diff
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !compactTopbar}
|
||||
{@render previewButtons()}
|
||||
{/if}
|
||||
<div title={diffTitle} class={diffDisabled ? 'flex cursor-not-allowed' : 'flex'}>
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize={headerBtnSize}
|
||||
on:click={() => openDiffDrawer()}
|
||||
disabled={diffDisabled}
|
||||
btnClasses={diffDisabled ? 'pointer-events-none' : undefined}
|
||||
title={diffTitle}
|
||||
startIcon={{ icon: DiffIcon }}
|
||||
>
|
||||
Diff
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !compactTopbar}
|
||||
{@render previewButtons()}
|
||||
{/if}
|
||||
|
||||
<DeployButton
|
||||
on:save={async ({ detail }) => await handleSaveFlow(detail)}
|
||||
{loading}
|
||||
{loadingSave}
|
||||
unifiedSize={headerBtnSize}
|
||||
{dropdownItems}
|
||||
/>
|
||||
<DeployButton
|
||||
on:save={async ({ detail }) => await handleSaveFlow(detail)}
|
||||
{loading}
|
||||
{loadingSave}
|
||||
unifiedSize={headerBtnSize}
|
||||
{dropdownItems}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#snippet flowSummary()}
|
||||
<!-- The summary stays editable where the editor's own bar had it: the breadcrumb
|
||||
beside it is the path, which this editor renames from its settings tab. -->
|
||||
<EditableInput
|
||||
value={flowStore.val.summary ?? ''}
|
||||
placeholder="Add a summary..."
|
||||
editable={customUi?.topBar?.editableSummary != false}
|
||||
commitOnInput
|
||||
size="sm"
|
||||
onSave={(v) => (flowStore.val.summary = v)}
|
||||
textClass="text-xs font-medium text-emphasis leading-tight"
|
||||
class="max-w-full min-w-0"
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#snippet flowHeaderActions()}
|
||||
<!-- Saving state, the autosave toggle and other people's drafts: the editor's own
|
||||
bar carries them beside the path, so the header has to carry them when it owns
|
||||
the bar — without them a full-page editor saves with no word either way. -->
|
||||
{#if opWorkspace && indicatorPath !== undefined}
|
||||
<AutosaveIndicator
|
||||
workspace={opWorkspace}
|
||||
itemKind="flow"
|
||||
path={indicatorPath}
|
||||
draftOnly={newFlow}
|
||||
{onResetToDeployed}
|
||||
{loadedFromDraft}
|
||||
{othersDraftsCount}
|
||||
{onOpenOthersDrafts}
|
||||
/>
|
||||
{/if}
|
||||
{#if $enterpriseLicense && !newFlow && !inSessionPane}
|
||||
<Awareness />
|
||||
{/if}
|
||||
<Dropdown items={getMoreItems} size={headerBtnSize} fixedHeight={!condensedHeader} />
|
||||
{#if diffEnabled && !diffInMenu}
|
||||
<!-- A disabled <button> fires no pointer events, so a title/tooltip on
|
||||
it never shows on hover. pointer-events-none on the button lets the
|
||||
hover reach this titled wrapper instead. -->
|
||||
<div title={diffTitle} class={diffDisabled ? 'flex cursor-not-allowed' : 'flex'}>
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize={headerBtnSize}
|
||||
on:click={() => openDiffDrawer()}
|
||||
disabled={diffDisabled}
|
||||
btnClasses={diffDisabled ? 'pointer-events-none' : undefined}
|
||||
title={diffTitle}
|
||||
startIcon={{ icon: DiffIcon }}
|
||||
>
|
||||
Diff
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !compactTopbar}
|
||||
{@render previewButtons()}
|
||||
{/if}
|
||||
|
||||
<DeployButton
|
||||
on:save={async ({ detail }) => await handleSaveFlow(detail)}
|
||||
{loading}
|
||||
{loadingSave}
|
||||
unifiedSize={headerBtnSize}
|
||||
{dropdownItems}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
<!-- Rendered either inline in the top bar (wide) or as a graph overlay
|
||||
(compactTopbar). Crossing the 720px threshold remounts
|
||||
FlowPreviewButtons; any open preview state will reset. -->
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<script lang="ts">
|
||||
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
|
||||
import { Badge } from './common'
|
||||
import { workspaceStore, userWorkspaces, userStore, type UserExt } from '$lib/stores'
|
||||
import { ScriptService } from '$lib/gen'
|
||||
import type { WorkspaceComparison } from '$lib/gen'
|
||||
import { fetchWorkspaceComparison } from '$lib/workspaceComparison'
|
||||
import { Button } from './common'
|
||||
import { AlertTriangle, GitFork, CircleCheck, CircleX, Loader2 } from 'lucide-svelte'
|
||||
import { goto } from '$app/navigation'
|
||||
import { onMount, untrack } from 'svelte'
|
||||
import { useWorkspaceDrafts } from '$lib/workspaceDrafts.svelte'
|
||||
import { childWorkspaceNoun, devLabelWord } from '$lib/utils/devWorkspaceLabel'
|
||||
import { childWorkspaceNoun } from '$lib/utils/devWorkspaceLabel'
|
||||
import { diffActionableInDirection } from '$lib/utils_workspace_deploy'
|
||||
|
||||
let loading = $state(false)
|
||||
@@ -20,12 +20,10 @@
|
||||
|
||||
let currentWorkspaceData = $derived($userWorkspaces.find((w) => w.id === $workspaceStore))
|
||||
let parentWorkspaceId = $derived(currentWorkspaceData?.parent_workspace_id)
|
||||
let parentWorkspaceData = $derived($userWorkspaces.find((w) => w.id === parentWorkspaceId))
|
||||
// Detect fork/dev workspaces by their parent link, not the `wm-fork-` id prefix (dev
|
||||
// workspaces have an ordinary, prefix-less id). Keying on the parent (rather than the
|
||||
// prefix) also avoids a parentless "Fork of ()" banner when the linkage is dropped.
|
||||
let isFork = $derived(parentWorkspaceId != null)
|
||||
let isDevWorkspace = $derived(currentWorkspaceData?.is_dev_workspace ?? false)
|
||||
let currentNoun = $derived(childWorkspaceNoun(currentWorkspaceData))
|
||||
// Operators run scripts and flows, they never deploy a fork, so the banner and
|
||||
// its CTA are noise for them. Gates the fetches too, not just the markup: the
|
||||
@@ -67,14 +65,12 @@
|
||||
let showDraftsOnly = $derived(upToDate && draftCount > 0)
|
||||
|
||||
// Leaving for a workspace with no comparison of its own has to invalidate whatever
|
||||
// is in flight too, or that response lands as this one's answer — and its CI counts
|
||||
// would be fetched for paths this workspace may not even have.
|
||||
// is in flight too, or that response lands as this one's answer.
|
||||
function dropComparison() {
|
||||
requestSeq++
|
||||
comparison = undefined
|
||||
comparisonFor = undefined
|
||||
loading = false
|
||||
resetCiTestSummary()
|
||||
}
|
||||
|
||||
// `isNotOperator` is a dependency of its own: it only turns true once this
|
||||
@@ -113,8 +109,7 @@
|
||||
if (comparisonFor !== ws) {
|
||||
comparison = undefined
|
||||
comparisonFor = undefined
|
||||
resetCiTestSummary()
|
||||
}
|
||||
}
|
||||
const seq = ++requestSeq
|
||||
loading = true
|
||||
error = undefined
|
||||
@@ -159,68 +154,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
let ciTestPassing = $state(0)
|
||||
let ciTestFailing = $state(0)
|
||||
let ciTestRunning = $state(0)
|
||||
let ciTestTotal = $state(0)
|
||||
|
||||
// These describe the rows of one comparison, so they are dropped with it.
|
||||
function resetCiTestSummary() {
|
||||
ciTestPassing = 0
|
||||
ciTestFailing = 0
|
||||
ciTestRunning = 0
|
||||
ciTestTotal = 0
|
||||
}
|
||||
|
||||
async function fetchCiTestSummary() {
|
||||
if (!$workspaceStore || !comparison?.diffs) return
|
||||
const items = comparison.diffs
|
||||
.filter((d) => d.kind === 'script' || d.kind === 'flow' || d.kind === 'resource')
|
||||
.map((d) => ({ path: d.path, kind: d.kind as 'script' | 'flow' | 'resource' }))
|
||||
if (items.length === 0) return
|
||||
// Counted for the comparison current at call time — the poll below outlives a
|
||||
// fork switch, and a slow batch for the fork we left would repaint this one's.
|
||||
const seq = requestSeq
|
||||
try {
|
||||
const batch = await ScriptService.getCiTestResultsBatch({
|
||||
workspace: $workspaceStore,
|
||||
requestBody: { items }
|
||||
})
|
||||
if (seq !== requestSeq) return
|
||||
let passing = 0
|
||||
let failing = 0
|
||||
let running = 0
|
||||
let total = 0
|
||||
for (const results of Object.values(batch)) {
|
||||
for (const r of results) {
|
||||
total++
|
||||
if (r.status === 'success') passing++
|
||||
else if (r.status === 'failure' || r.status === 'canceled') failing++
|
||||
else if (r.status === 'running' || (r.job_id && !r.status)) running++
|
||||
}
|
||||
}
|
||||
ciTestPassing = passing
|
||||
ciTestFailing = failing
|
||||
ciTestRunning = running
|
||||
ciTestTotal = total
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch CI test summary:', e)
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (comparison && comparison.summary.total_diffs > 0) {
|
||||
fetchCiTestSummary()
|
||||
}
|
||||
})
|
||||
|
||||
// Poll while any CI test is still running
|
||||
$effect(() => {
|
||||
if (ciTestRunning <= 0) return
|
||||
const interval = setInterval(fetchCiTestSummary, 3000)
|
||||
return () => clearInterval(interval)
|
||||
})
|
||||
|
||||
// Counted with the compare page's own predicate so the banner never advertises a
|
||||
// direction whose list is empty: the `ahead`/`behind` sums in the summary include
|
||||
// rows a direction does not carry, and miss a parent-only row that the update
|
||||
@@ -246,191 +179,60 @@
|
||||
</script>
|
||||
|
||||
{#if showBanner}
|
||||
<!-- Side padding mirrors the page content container below, so the banner
|
||||
stays aligned with it instead of bleeding to the viewport edges. -->
|
||||
<div class="w-full text-xs max-w-7xl mx-auto px-4 sm:px-8 pt-2">
|
||||
<div class="bg-blue-50 dark:bg-blue-900 rounded-md px-4 py-2">
|
||||
<!-- The summary wraps inside its own column while the CTA keeps its width and
|
||||
stays on the first line: laid out as one non-wrapping row, the summary is
|
||||
long enough on a laptop-width viewport to push the button out of the
|
||||
banner instead of getting shorter. -->
|
||||
<div class="flex items-center justify-between gap-x-3">
|
||||
<div class="flex items-center flex-wrap gap-x-3 gap-y-1 min-w-0">
|
||||
<GitFork class="w-4 h-4 text-accent shrink-0" />
|
||||
<div class="text-sm min-w-0">
|
||||
<span class="font-medium text-blue-900 dark:text-blue-100">
|
||||
{isDevWorkspace
|
||||
? `${devLabelWord(currentWorkspaceData?.dev_workspace_label)} workspace of`
|
||||
: 'Fork of'}
|
||||
<b>{parentWorkspaceData?.name}</b
|
||||
>{#if parentWorkspaceData?.name !== parentWorkspaceId}
|
||||
({parentWorkspaceId}){/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<span class="text-xs text-blue-600 dark:text-blue-400"> Checking for changes... </span>
|
||||
{:else if error}
|
||||
<span class="text-xs text-red-600 dark:text-red-400">
|
||||
{error}
|
||||
</span>
|
||||
{:else if comparison}
|
||||
<div class="flex items-center flex-wrap gap-x-4 gap-y-1 text-xs min-w-0">
|
||||
{#if comparison.summary.total_diffs > 0}
|
||||
<span class="text-blue-700 dark:text-blue-100">
|
||||
{forkAheadBehindMessage(changesAhead, changesBehind)}
|
||||
<span class="font-semibold underline">{parentWorkspaceId}</span> over {comparison
|
||||
.summary.total_diffs} items<span class="hidden lg:inline">:</span>
|
||||
</span>
|
||||
<!-- The per-kind breakdown is the first thing to go on a narrow
|
||||
viewport: the item total above already sizes the change set, and
|
||||
the compare page carries the detail. -->
|
||||
<div
|
||||
class="hidden lg:flex items-center flex-wrap gap-x-2 gap-y-1 whitespace-nowrap"
|
||||
>
|
||||
{#if comparison.summary.scripts_changed > 0}
|
||||
<span class="text-blue-700 dark:text-blue-100">
|
||||
{comparison.summary.scripts_changed} script{comparison.summary
|
||||
.scripts_changed !== 1
|
||||
? 's'
|
||||
: ''}
|
||||
</span>
|
||||
{/if}
|
||||
{#if comparison.summary.flows_changed > 0}
|
||||
<span class="text-blue-700 dark:text-blue-100">
|
||||
{comparison.summary.flows_changed} flow{comparison.summary.flows_changed !== 1
|
||||
? 's'
|
||||
: ''}
|
||||
</span>
|
||||
{/if}
|
||||
{#if comparison.summary.apps_changed > 0}
|
||||
<span class="text-blue-700 dark:text-blue-100">
|
||||
{comparison.summary.apps_changed} app{comparison.summary.apps_changed !== 1
|
||||
? 's'
|
||||
: ''}
|
||||
</span>
|
||||
{/if}
|
||||
{#if comparison.summary.resources_changed > 0}
|
||||
<span class="text-blue-700 dark:text-blue-100">
|
||||
{comparison.summary.resources_changed} resource{comparison.summary
|
||||
.resources_changed !== 1
|
||||
? 's'
|
||||
: ''}
|
||||
</span>
|
||||
{/if}
|
||||
{#if comparison.summary.variables_changed > 0}
|
||||
<span class="text-blue-700 dark:text-blue-100">
|
||||
{comparison.summary.variables_changed} variable{comparison.summary
|
||||
.variables_changed !== 1
|
||||
? 's'
|
||||
: ''}
|
||||
</span>
|
||||
{/if}
|
||||
{#if comparison.summary.resource_types_changed > 0}
|
||||
<span class="text-blue-700 dark:text-blue-100">
|
||||
{comparison.summary.resource_types_changed} resource type{comparison.summary
|
||||
.resource_types_changed !== 1
|
||||
? 's'
|
||||
: ''}
|
||||
</span>
|
||||
{/if}
|
||||
{#if comparison.summary.folders_changed > 0}
|
||||
<span class="text-blue-700 dark:text-blue-100">
|
||||
{comparison.summary.folders_changed} folder{comparison.summary
|
||||
.folders_changed !== 1
|
||||
? 's'
|
||||
: ''}
|
||||
</span>
|
||||
{/if}
|
||||
{#if comparison.summary.schedules_changed > 0}
|
||||
<span class="text-blue-700 dark:text-blue-100">
|
||||
{comparison.summary.schedules_changed} schedule{comparison.summary
|
||||
.schedules_changed !== 1
|
||||
? 's'
|
||||
: ''}
|
||||
</span>
|
||||
{/if}
|
||||
{#if comparison.summary.triggers_changed > 0}
|
||||
<span class="text-blue-700 dark:text-blue-100">
|
||||
{comparison.summary.triggers_changed} trigger{comparison.summary
|
||||
.triggers_changed !== 1
|
||||
? 's'
|
||||
: ''}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if ciTestTotal > 0}
|
||||
-
|
||||
{#if ciTestFailing > 0}
|
||||
<div
|
||||
class="flex items-center gap-1 text-red-600 dark:text-red-400 whitespace-nowrap"
|
||||
>
|
||||
<CircleX class="w-3 h-3" />
|
||||
<span>CI: {ciTestFailing} failing</span>
|
||||
</div>
|
||||
{:else if ciTestRunning > 0}
|
||||
<div
|
||||
class="flex items-center gap-1 text-yellow-600 dark:text-yellow-400 whitespace-nowrap"
|
||||
>
|
||||
<Loader2 class="w-3 h-3 animate-spin" />
|
||||
<span>CI: {ciTestRunning} running</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="flex items-center gap-1 text-green-600 dark:text-green-400 whitespace-nowrap"
|
||||
>
|
||||
<CircleCheck class="w-3 h-3" />
|
||||
<span>CI: {ciTestPassing} passing</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if comparison.summary.conflicts > 0}
|
||||
-
|
||||
<div
|
||||
class="flex items-center gap-1 text-orange-600 dark:text-orange-400 whitespace-nowrap"
|
||||
>
|
||||
<AlertTriangle class="w-3 h-3" />
|
||||
<span
|
||||
>{comparison.summary.conflicts} conflict{comparison.summary.conflicts !== 1
|
||||
? 's'
|
||||
: ''}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if comparison.skipped_comparison}
|
||||
<span class="text-blue-600 dark:text-blue-200">
|
||||
This {currentNoun} was created before the addition of certain windmill features, and
|
||||
therefore the changes with its parent workspace cannot be displayed.</span
|
||||
>
|
||||
{:else if showDraftsOnly}
|
||||
<span class="text-blue-700 dark:text-blue-100">
|
||||
This workspace has {draftCount} draft{draftCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-blue-600 dark:text-blue-200"> Everything is up to date </span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="sm"
|
||||
onclick={showDraftsOnly ? openDraftCompare : openComparisonDrawer}
|
||||
>
|
||||
{#if showDraftsOnly}
|
||||
Review & deploy drafts
|
||||
{:else if !hasAnswer || changesAhead > 0}
|
||||
Review & Deploy Changes
|
||||
{:else}
|
||||
Review & Update {currentNoun}
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- In the band, not a banner: the breadcrumb already says which fork this is, so all this has
|
||||
to carry is how far it has drifted and the way to review that. The per-kind breakdown, the
|
||||
CI line and the conflict count live on the compare page the button opens. -->
|
||||
<PageHeaderContent actions={forkAction} />
|
||||
{/if}
|
||||
|
||||
{#snippet forkAction()}
|
||||
{@const total = comparison?.summary.total_diffs ?? 0}
|
||||
{@const conflicts = comparison?.summary.conflicts ?? 0}
|
||||
{#if loading}
|
||||
<span class="text-2xs text-tertiary">Checking for changes…</span>
|
||||
{:else if error}
|
||||
<span class="text-2xs text-red-600 dark:text-red-400" title={error}>Comparison failed</span>
|
||||
{:else if showDraftsOnly}
|
||||
<Badge color="blue" small>
|
||||
{draftCount} draft{draftCount !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
onclick={openDraftCompare}
|
||||
title={`Review and deploy this workspace's drafts`}
|
||||
>
|
||||
Review & deploy drafts
|
||||
</Button>
|
||||
{:else if total > 0}
|
||||
<!-- Which way the drift goes is the thing to know at a glance: ahead is what this fork has
|
||||
to give its parent, behind is what it has yet to take. -->
|
||||
{#if changesAhead > 0}
|
||||
<Badge color="blue" small title="{changesAhead} ahead of {parentWorkspaceId}">
|
||||
↑ {changesAhead}
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if changesBehind > 0}
|
||||
<Badge color="gray" small title="{changesBehind} behind {parentWorkspaceId}">
|
||||
↓ {changesBehind}
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if conflicts > 0}
|
||||
<Badge color="orange" small title="{conflicts} conflicting item{conflicts !== 1 ? 's' : ''}">
|
||||
{conflicts} conflict{conflicts !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
{/if}
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
onclick={openComparisonDrawer}
|
||||
title={`${forkAheadBehindMessage(changesAhead, changesBehind)} ${parentWorkspaceId} over ${total} items`}
|
||||
>
|
||||
{#if !hasAnswer || changesAhead > 0}
|
||||
Review & Deploy Changes
|
||||
{:else}
|
||||
Review & Update {currentNoun}
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
<!--
|
||||
@component
|
||||
The page header's breadcrumb: workspace / fork / kind / path, in one flat line.
|
||||
|
||||
Every segment is a trigger for the picker that changes it — the workspace menu, the fork family
|
||||
picker, and the item drill picker for the kind and each path level — and every segment wears the
|
||||
same weight, size and icon size, so the line reads as one control rather than four.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Building, ChevronDown, Folder, GitFork, User } from 'lucide-svelte'
|
||||
import WorkspaceItemKindIcon from './WorkspaceItemKindIcon.svelte'
|
||||
import { Menu, Menubar } from '$lib/components/meltComponents'
|
||||
import NavBreadcrumbTrigger from './NavBreadcrumbTrigger.svelte'
|
||||
import BreadcrumbItemContent from './BreadcrumbItemContent.svelte'
|
||||
import WorkspacePickerBody from '$lib/components/sidebar/WorkspacePickerBody.svelte'
|
||||
import BreadcrumbSegment from '$lib/components/BreadcrumbSegment.svelte'
|
||||
import { userWorkspaces, workspaceStore, workspaceColor, superadmin } from '$lib/stores'
|
||||
import { findWorkspaceRoot } from '$lib/utils/workspaceHierarchy'
|
||||
import { useForkableWorkspaces } from '$lib/utils/useForkableWorkspaces.svelte'
|
||||
import { base } from '$lib/base'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { Badge } from '$lib/components/common'
|
||||
import { forkAccentStyle } from '$lib/utils/forkColor'
|
||||
import { devBadgeText } from '$lib/utils/devWorkspaceLabel'
|
||||
import { page } from '$app/state'
|
||||
import { navPageFor } from './sidebar/navPages'
|
||||
import { copyToClipboard, getContrastTextColor } from '$lib/utils'
|
||||
import { KIND_LABEL_LOWER, kindKey } from '$lib/components/workspacePicker'
|
||||
import type { PageHeaderItem, PageHeaderSection } from './pageHeaderRegistry.svelte'
|
||||
import type { Snippet } from 'svelte'
|
||||
|
||||
let {
|
||||
item,
|
||||
section,
|
||||
afterName,
|
||||
actingWorkspaceId
|
||||
}: {
|
||||
item?: PageHeaderItem
|
||||
section?: PageHeaderSection
|
||||
/** What sits beside the page's name: a mark like a documentation tooltip, or a control
|
||||
* that acts on the thing named. */
|
||||
afterName?: Snippet
|
||||
/** The workspace the page acts on, when it differs from the one the app is pointed at. */
|
||||
actingWorkspaceId?: string
|
||||
} = $props()
|
||||
|
||||
const scopeId = $derived(actingWorkspaceId ?? $workspaceStore ?? undefined)
|
||||
// In session mode the workspace is the session's scope, not a place to navigate to: the name
|
||||
// says which workspace the chat acts on, and going home from it would leave the session.
|
||||
const sessionMode = $derived(page.url.pathname.startsWith(`${base}/sessions`))
|
||||
const homeHref = $derived(
|
||||
actingWorkspaceId ? `${base}/?workspace=${encodeURIComponent(actingWorkspaceId)}` : `${base}/`
|
||||
)
|
||||
|
||||
// With no item or section registered, the route still says where the user is: Runs, Variables,
|
||||
// and the rest name themselves in the breadcrumb rather than leaving it at the workspace.
|
||||
// Home is the root the workspace part already stands for, so it names no page of its own.
|
||||
const routePage = $derived.by(() => {
|
||||
if (item || section) return undefined
|
||||
const found = navPageFor(page.url.pathname, base)
|
||||
return found?.path === '/' ? undefined : found
|
||||
})
|
||||
|
||||
/** One look for every segment, whichever picker it opens. */
|
||||
const SEGMENT =
|
||||
'flex items-center gap-1 min-w-0 px-1 py-0.5 rounded text-xs font-normal text-primary hover:bg-surface-hover hover:text-emphasis transition-colors'
|
||||
const ICON = 14
|
||||
/** The workspace disc: the same glyph as the rest, with a ring of its own around it. */
|
||||
const DISC = 20
|
||||
|
||||
/** Width of the name beside the chevron, so the picker can hang from the part's own left edge. */
|
||||
let nameWidth = $state(0)
|
||||
let workspaceMenu: Menu | undefined = $state(undefined)
|
||||
|
||||
// Its own confirmation under the path rather than the global toast: the answer belongs where
|
||||
// the click was, and a toast for something this small is a lot of furniture.
|
||||
let pathCopied = $state(false)
|
||||
let copiedReset: ReturnType<typeof setTimeout> | undefined
|
||||
async function copyPath() {
|
||||
if (!item?.path || !(await copyToClipboard(item.path, false))) return
|
||||
pathCopied = true
|
||||
clearTimeout(copiedReset)
|
||||
copiedReset = setTimeout(() => (pathCopied = false), 1500)
|
||||
}
|
||||
|
||||
const family = $derived(findWorkspaceRoot(scopeId, $userWorkspaces ?? []))
|
||||
const discColor = $derived(family?.color ?? $workspaceColor ?? 'rgb(var(--color-surface-sunken))')
|
||||
const glyphColor = $derived(getContrastTextColor(discColor))
|
||||
|
||||
const forkable = useForkableWorkspaces({
|
||||
workspaces: () => $userWorkspaces,
|
||||
currentWorkspaceId: () => scopeId,
|
||||
isSuperadmin: () => !!$superadmin
|
||||
})
|
||||
const forkableWorkspaces = $derived(forkable.current)
|
||||
const currentWs = $derived(forkableWorkspaces.find((w) => w.id === scopeId))
|
||||
const inFork = $derived(!!currentWs?.parent_workspace_id)
|
||||
// A session names the workspace it acts on beside its own title, so here the family name alone
|
||||
// says where the user is browsing — the fork in this spot would read as a workspace switch.
|
||||
const showFork = $derived(inFork && !actingWorkspaceId)
|
||||
const familyName = $derived(family?.name ?? scopeId ?? '')
|
||||
// The workspace the user actually stands in, which is the fork's own name inside a fork.
|
||||
const scopeName = $derived(currentWs?.name ?? familyName)
|
||||
|
||||
// A fork wears its workspace colour and, for a dev workspace, its environment badge — the same
|
||||
// two marks the fork chip carries everywhere else.
|
||||
const forkAccent = $derived(inFork ? forkAccentStyle(currentWs?.color) : undefined)
|
||||
// A dev workspace wears its environment; the root wears "prod", so which environment the user
|
||||
// is standing in is answered the same way whichever one it is.
|
||||
const envBadge = $derived.by(() => {
|
||||
if (actingWorkspaceId) return undefined
|
||||
if (!inFork) return { text: 'prod', dev: false }
|
||||
if (currentWs?.is_dev_workspace)
|
||||
return { text: devBadgeText(currentWs.dev_workspace_label), dev: true }
|
||||
return undefined
|
||||
})
|
||||
|
||||
// No hover fill: the chevron beside it is what reacts to the pointer. The accent stays on the
|
||||
// fork's own name inside, so the family's name is not painted as if it were the fork.
|
||||
const scopeChipClass = twMerge(SEGMENT, 'hover:bg-transparent')
|
||||
|
||||
/** `f/demo` and `u/alice` name a folder and a user: the prefix becomes the path's icon. */
|
||||
const scopeKind = $derived(item?.path?.split('/')[0])
|
||||
/** The path's levels, the `f`/`u` prefix dropped — that is what the icon says. */
|
||||
const pathLevels = $derived((item?.path ?? '').split('/').slice(1).filter(Boolean))
|
||||
</script>
|
||||
|
||||
{#snippet workspaceDisc()}
|
||||
<!-- The glyph is the same size as every other icon on the line; only the disc around it is
|
||||
larger, by the ring it needs to read as a disc at all. -->
|
||||
<svg width={DISC} height={DISC} viewBox="0 0 {DISC} {DISC}" class="flex-shrink-0">
|
||||
<circle cx={DISC / 2} cy={DISC / 2} r={DISC / 2} fill={discColor} />
|
||||
<foreignObject x={(DISC - ICON) / 2} y={(DISC - ICON) / 2} width={ICON} height={ICON}>
|
||||
<Building size={ICON} style={glyphColor ? `color: ${glyphColor}` : undefined} />
|
||||
</foreignObject>
|
||||
</svg>
|
||||
{/snippet}
|
||||
|
||||
{#snippet itemKindIcon()}
|
||||
{#if item}<WorkspaceItemKindIcon kind={item.kind} size={ICON} />{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet sectionKindIcon()}
|
||||
{#if section?.kind}<WorkspaceItemKindIcon kind={section.kind} size={ICON} />{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet folderIcon()}
|
||||
<Folder size={ICON} class="flex-shrink-0 text-tertiary" />
|
||||
{/snippet}
|
||||
|
||||
{#snippet userIcon()}
|
||||
<User size={ICON} class="flex-shrink-0 text-tertiary" />
|
||||
{/snippet}
|
||||
|
||||
{#snippet envBadgeMark()}
|
||||
{#if envBadge}
|
||||
<Badge
|
||||
color={envBadge.dev ? 'dark-blue' : 'gray'}
|
||||
small
|
||||
class={envBadge.dev
|
||||
? 'text-3xs px-1 py-0 dark:bg-surface-accent-primary text-white dark:text-white'
|
||||
: 'text-3xs px-1 py-0'}>{envBadge.text}</Badge
|
||||
>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet slash()}
|
||||
<span class="shrink-0 text-hint/40 text-xs" aria-hidden="true">/</span>
|
||||
{/snippet}
|
||||
|
||||
<nav
|
||||
aria-label="Breadcrumb"
|
||||
class="flex items-center gap-1.5 min-w-0 text-xs font-normal text-primary"
|
||||
>
|
||||
<!-- One part for where the user is: the name of the workspace they are standing in — a fork
|
||||
included, in its own colour — leading home, and one chevron whose picker changes it. The
|
||||
picker lists the families and expands one to reach its forks, so a fork needs no part of
|
||||
its own here. -->
|
||||
<div class="flex items-center min-w-0">
|
||||
<!-- No hover fill on the name: the chevron beside it is the thing that lights up, and two
|
||||
boxes reacting to one pass of the pointer read as two controls fighting. -->
|
||||
<!-- bind:clientWidth: the menu hangs from the chevron, so it is shifted back by the width of
|
||||
the name to line its left edge up with the disc. -->
|
||||
<div class="flex items-center min-w-0" bind:clientWidth={nameWidth}>
|
||||
<svelte:element
|
||||
this={sessionMode ? 'span' : 'a'}
|
||||
href={sessionMode ? undefined : homeHref}
|
||||
class={scopeChipClass}
|
||||
title={showFork ? `${familyName} / ${scopeName}` : familyName}
|
||||
>
|
||||
<BreadcrumbItemContent label={familyName} icon={workspaceDisc} />
|
||||
{#if showFork}
|
||||
<!-- Both names: the fork is where the work happens, the family is which product it is
|
||||
part of, and either alone leaves the other to be guessed. -->
|
||||
{@render slash()}
|
||||
<span
|
||||
class={twMerge(
|
||||
'flex items-center gap-1 min-w-0 px-1 rounded',
|
||||
forkAccent &&
|
||||
'bg-[color:var(--fork-accent-bg)] dark:bg-[color:var(--fork-accent-bg-dark)] text-[color:var(--fork-accent-text)] dark:text-[color:var(--fork-accent-text-dark)] font-semibold'
|
||||
)}
|
||||
style={forkAccent}
|
||||
>
|
||||
<GitFork size={ICON} class="flex-shrink-0" />
|
||||
<span class="truncate">{scopeName}</span>
|
||||
{@render envBadgeMark()}
|
||||
</span>
|
||||
{:else}
|
||||
{@render envBadgeMark()}
|
||||
{/if}
|
||||
</svelte:element>
|
||||
</div>
|
||||
<Menubar>
|
||||
{#snippet children({ createMenu })}
|
||||
<Menu
|
||||
bind:this={workspaceMenu}
|
||||
{createMenu}
|
||||
usePointerDownOutside
|
||||
placement="bottom-start"
|
||||
contentStyle="margin-left: {-nameWidth}px"
|
||||
>
|
||||
{#snippet triggr({ trigger })}
|
||||
<NavBreadcrumbTrigger
|
||||
{trigger}
|
||||
class="flex items-center p-1.5 rounded text-tertiary hover:bg-surface-hover hover:text-primary transition-colors"
|
||||
title="Switch workspace"
|
||||
>
|
||||
<ChevronDown size={ICON} class="flex-shrink-0" />
|
||||
</NavBreadcrumbTrigger>
|
||||
{/snippet}
|
||||
{#snippet children({ item: menuItem })}
|
||||
<WorkspacePickerBody item={menuItem} closeMenu={() => workspaceMenu?.close()} />
|
||||
{/snippet}
|
||||
</Menu>
|
||||
{/snippet}
|
||||
</Menubar>
|
||||
</div>
|
||||
|
||||
{#if item || section || routePage}
|
||||
{@render slash()}
|
||||
{/if}
|
||||
|
||||
{#if item}
|
||||
{#if item.path}
|
||||
<!-- The path whole, as one label rather than a row of pickers: it is what a person reads
|
||||
to know which item this is, and what they reach for to paste somewhere else. -->
|
||||
<div class="relative flex items-center min-w-0">
|
||||
<!-- No hover fill: a fill offers to take you somewhere, and this only copies. The
|
||||
pointer and the popup after the click are affordance enough. -->
|
||||
<button
|
||||
class={twMerge(SEGMENT, 'gap-1.5 hover:bg-transparent')}
|
||||
title="Copy path"
|
||||
onclick={copyPath}
|
||||
aria-label="Copy path {item.path}"
|
||||
>
|
||||
{#if scopeKind === 'u'}
|
||||
{@render userIcon()}
|
||||
{:else}
|
||||
{@render folderIcon()}
|
||||
{/if}
|
||||
{#each pathLevels as level, i (i)}
|
||||
{#if i > 0}{@render slash()}{/if}
|
||||
<span class="truncate">{level}</span>
|
||||
{/each}
|
||||
</button>
|
||||
{#if afterName}{@render afterName()}{/if}
|
||||
{#if pathCopied}
|
||||
<span
|
||||
class="absolute left-0 top-full mt-1 z-[6000] rounded-md border bg-surface px-2 py-1 text-2xs text-secondary shadow-md whitespace-nowrap"
|
||||
role="status"
|
||||
>
|
||||
Path copied successfully
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<span class="{SEGMENT} hover:bg-transparent" aria-current="page">
|
||||
{@render itemKindIcon()}
|
||||
<span class="truncate">{KIND_LABEL_LOWER[item.kind]}</span>
|
||||
</span>
|
||||
{/if}
|
||||
{:else if section?.kind}
|
||||
<BreadcrumbSegment
|
||||
label={KIND_LABEL_LOWER[section.kind]}
|
||||
icon={sectionKindIcon}
|
||||
extraClass={SEGMENT}
|
||||
isCurrent
|
||||
initialHighlight={kindKey(section.kind)}
|
||||
initialScope={{ kind: section.kind }}
|
||||
onPick={() => {}}
|
||||
/>
|
||||
{:else if section}
|
||||
<!-- A section's own widget lays out as a row: its title, and whatever belongs with it. -->
|
||||
<span
|
||||
class="{SEGMENT} hover:bg-transparent hover:text-primary {section.content ? '' : 'truncate'}"
|
||||
aria-current="page"
|
||||
>
|
||||
{#if section.content}{@render section.content()}{:else}{section.label}{/if}
|
||||
</span>
|
||||
{#if afterName}{@render afterName()}{/if}
|
||||
{:else if routePage}
|
||||
{@const RouteIcon = routePage.icon}
|
||||
<!-- shrink-0: a page's own name is short and is the one part of the trail worth keeping
|
||||
whole, so the workspace and fork names give way first when the bar is full. -->
|
||||
<span class="{SEGMENT} shrink-0 hover:bg-transparent hover:text-primary" aria-current="page">
|
||||
{#if RouteIcon}<RouteIcon size={ICON} class="flex-shrink-0 text-tertiary" />{/if}
|
||||
<span class="truncate">{routePage.label}</span>
|
||||
</span>
|
||||
{#if afterName}{@render afterName()}{/if}
|
||||
{/if}
|
||||
</nav>
|
||||
@@ -0,0 +1,27 @@
|
||||
<!--
|
||||
@component
|
||||
A breadcrumb segment that opens a melt menu.
|
||||
|
||||
The menu hands its trigger builder to a snippet, and a builder store cannot be subscribed inside
|
||||
one — so the segment takes it as a prop here, where both its attributes and its action are legal.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { conditionalMelt } from '$lib/utils'
|
||||
import type { Snippet } from 'svelte'
|
||||
|
||||
let {
|
||||
trigger,
|
||||
class: className = '',
|
||||
title,
|
||||
children
|
||||
}: {
|
||||
trigger?: any
|
||||
class?: string
|
||||
title?: string
|
||||
children: Snippet
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
<button use:conditionalMelt={trigger} {...$trigger} class={className} {title}>
|
||||
{@render children()}
|
||||
</button>
|
||||
@@ -16,7 +16,8 @@
|
||||
import { findCanonicalDevWorkspace } from '$lib/utils/workspaceHierarchy'
|
||||
import { devLabelKey, devLabelNoun } from '$lib/utils/devWorkspaceLabel'
|
||||
import { switchWorkspace } from '$lib/storeUtils'
|
||||
import { Badge, Button } from './common'
|
||||
import { Button, ButtonType } from './common'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import Popover from './meltComponents/Popover.svelte'
|
||||
import Toggle from './Toggle.svelte'
|
||||
import { GitFork, Lock, ShieldOff } from 'lucide-svelte'
|
||||
@@ -83,28 +84,35 @@
|
||||
? `Edits in ${devLabelKey(canonicalDev.dev_workspace_label)}`
|
||||
: 'Edits restricted'
|
||||
)
|
||||
|
||||
// The Popover renders its trigger as a <button>, so this wears the design system's subtle
|
||||
// button rather than containing one — a <Button> here would nest a button inside a button.
|
||||
const triggerClass = twMerge(
|
||||
'inline-flex items-center gap-1 rounded-md whitespace-nowrap transition-all',
|
||||
ButtonType.VariantStyles.subtle,
|
||||
ButtonType.UnifiedSizingClasses.sm,
|
||||
ButtonType.UnifiedHeightClasses.sm,
|
||||
ButtonType.UnifiedFontSizes.sm,
|
||||
'text-xs'
|
||||
)
|
||||
</script>
|
||||
|
||||
{#if !operatingUser.current?.operator && activeDeployRulesets.length > 0}
|
||||
<div class="my-2">
|
||||
<Popover
|
||||
placement="bottom-start"
|
||||
class="inline-flex items-center"
|
||||
class={triggerClass}
|
||||
triggerAttrs={{ 'aria-label': badgeLabel }}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<!-- `clickable` is unusable here: it renders the badge as a <button>, nested inside the
|
||||
one Popover wraps its trigger in. -->
|
||||
<Badge small color={bypassActive ? 'yellow' : 'blue'} class="cursor-pointer">
|
||||
{#if bypassActive}
|
||||
<ShieldOff class="h-3 w-3" />
|
||||
{:else if canonicalDev}
|
||||
<GitFork class="h-3 w-3" />
|
||||
{:else}
|
||||
<Lock class="h-3 w-3" />
|
||||
{/if}
|
||||
{badgeLabel}
|
||||
</Badge>
|
||||
{#if bypassActive}
|
||||
<ShieldOff size={ButtonType.UnifiedIconSizes.sm} class="shrink-0" />
|
||||
{:else if canonicalDev}
|
||||
<GitFork size={ButtonType.UnifiedIconSizes.sm} class="shrink-0" />
|
||||
{:else}
|
||||
<Lock size={ButtonType.UnifiedIconSizes.sm} class="shrink-0" />
|
||||
{/if}
|
||||
{badgeLabel}
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<div class="flex flex-col gap-3 p-4 text-xs max-w-sm">
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
<!--
|
||||
@component
|
||||
The bar every route wears, above the page content and beside the sidebar: the sidebar toggle, the
|
||||
workspace picker, then whatever the route registered — an item's breadcrumb and summary, or a
|
||||
section name — and the route's own buttons at the far end.
|
||||
|
||||
The row's height matches the sidebar's own header row, so the two read as one band.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { PanelLeft, PanelTopDashed } from 'lucide-svelte'
|
||||
import { navDetached } from './sidebar/navDetached.svelte'
|
||||
import { navHandleSlot } from './sidebar/navHandlePlacement.svelte'
|
||||
import NavBreadcrumb from './NavBreadcrumb.svelte'
|
||||
import { pageHeader } from './pageHeaderRegistry.svelte'
|
||||
import ContextBridge from './ContextBridge.svelte'
|
||||
|
||||
let {
|
||||
navHidden = false,
|
||||
hideNavHandle = false,
|
||||
onUnpin
|
||||
}: {
|
||||
navHidden?: boolean
|
||||
/** Drops the sidebar's handle, for a page whose own floating control reveals the sidebar
|
||||
* along with this band — two handles for one gesture would be one too many. */
|
||||
hideNavHandle?: boolean
|
||||
/** Sends the band back behind its handle, on a page that owns the viewport. */
|
||||
onUnpin?: () => void
|
||||
} = $props()
|
||||
|
||||
const content = $derived(pageHeader.content)
|
||||
const actions = $derived(pageHeader.actions)
|
||||
|
||||
const item = $derived(content?.item)
|
||||
const section = $derived(content?.section)
|
||||
</script>
|
||||
|
||||
<div data-page-header class="flex items-center gap-1 h-11 pl-2 pr-4 shrink-0 min-w-0 bg-surface">
|
||||
{#if navDetached.val && !navHidden && !hideNavHandle}
|
||||
<!-- Reveals the hidden sidebar, and only that: hovering slides the card in, clicking holds
|
||||
it there. Attaching it for good belongs to the toggle in the sidebar's own footer, where
|
||||
detaching it happened — a control that hides the thing it sits on cannot also be the
|
||||
way to bring it back. Docked, the sidebar speaks for itself and nothing leads the bar. -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
data-nav-handle
|
||||
onmouseenter={() => navHandleSlot.open()}
|
||||
onmouseleave={() => navHandleSlot.scheduleClose()}
|
||||
>
|
||||
<!-- No tooltip: hovering here already slides the sidebar in, which says what the button
|
||||
does better than a label popping up over it. -->
|
||||
<button
|
||||
class="flex items-center p-1.5 rounded hover:bg-surface-hover"
|
||||
aria-label="Show sidebar"
|
||||
onclick={() => navHandleSlot.open()}
|
||||
>
|
||||
<PanelLeft size={16} class="flex-shrink-0 text-hint" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if onUnpin}
|
||||
<!-- After the sidebar's handle: the sidebar is the outer thing, the band sits inside it. -->
|
||||
<button
|
||||
class="flex items-center p-1.5 rounded hover:bg-surface-hover"
|
||||
aria-label="Unpin the header from deployed apps"
|
||||
title="Unpin the header from deployed apps"
|
||||
onclick={() => onUnpin?.()}
|
||||
>
|
||||
<!-- Dashed while the band is shown, solid while it is away: the pair the sidebar's own
|
||||
toggle uses, so the two controls read as one idea. -->
|
||||
<PanelTopDashed size={16} class="flex-shrink-0 text-hint" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- The breadcrumb yields width grudgingly (shrink-[0.1]): when a page fills the bar with
|
||||
controls, they are what should narrow, not the name of where the user is. It still gives
|
||||
way rather than pushing them off the bar once there is nothing left to take.
|
||||
An embed (`navHidden`) gets the page's controls without the trail that would offer to
|
||||
navigate the host's workspace. -->
|
||||
{#if !navHidden}
|
||||
<div class="flex min-w-0 shrink-[0.1]">
|
||||
<NavBreadcrumb
|
||||
{item}
|
||||
{section}
|
||||
afterName={content?.afterName}
|
||||
actingWorkspaceId={content?.actingWorkspaceId}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if item && !navHidden && (item.summaryContent || item.summary)}
|
||||
<!-- A dot rather than a slash: the summary names the same item the path just located, it is
|
||||
not another level of it. -->
|
||||
<span class="shrink-0 text-hint/40 text-xs px-0.5" aria-hidden="true">·</span>
|
||||
<!-- No kind icon: the page below is the item, and saying "this is a flow" above a flow
|
||||
editor tells the reader what they can already see. -->
|
||||
<div class="flex items-center gap-1 min-w-0">
|
||||
{#if item.summaryContent}
|
||||
{@render item.summaryContent()}
|
||||
{:else}
|
||||
<span class="min-w-0 truncate text-xs font-medium text-emphasis">{item.summary}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if actions.length > 0}
|
||||
<!-- min-w-0, not shrink-0: a page whose actions are a filter row (Runs) puts a control in
|
||||
here that can give width back, and it can only do that if this box may shrink. -->
|
||||
<div class="ml-auto flex items-center gap-2 min-w-0 pl-4">
|
||||
{#each actions as entry, i (i)}
|
||||
{#key entry.contexts}
|
||||
<ContextBridge contexts={entry.contexts}>
|
||||
{@render entry.render()}
|
||||
</ContextBridge>
|
||||
{/key}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,39 @@
|
||||
<!--
|
||||
@component
|
||||
Fills the layout's page header from the route that mounts it: the breadcrumb (an item's path, or a
|
||||
section name for a list) and the page's own buttons. Renders nothing itself.
|
||||
|
||||
Only a route page mounts this. An editor nested in a session pane or a drawer must not take over
|
||||
the header, so nesting is decided by the caller rather than guessed from context.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { pageHeader, type PageHeaderContent } from './pageHeaderRegistry.svelte'
|
||||
|
||||
let {
|
||||
item,
|
||||
section,
|
||||
afterName,
|
||||
actions,
|
||||
actionsOrder,
|
||||
contexts,
|
||||
actingWorkspaceId,
|
||||
barRightInset,
|
||||
fullBleed
|
||||
}: PageHeaderContent = $props()
|
||||
|
||||
onMount(() => {
|
||||
const id = pageHeader.register(() => ({
|
||||
item,
|
||||
section,
|
||||
afterName,
|
||||
actions,
|
||||
actionsOrder,
|
||||
contexts,
|
||||
actingWorkspaceId,
|
||||
barRightInset,
|
||||
fullBleed
|
||||
}))
|
||||
return () => pageHeader.release(id)
|
||||
})
|
||||
</script>
|
||||
@@ -43,6 +43,7 @@
|
||||
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 { pageHref, RUNS_PATH } from '$lib/components/sessions/previewPaths'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { computeJobKinds, useJobsLoader } from '$lib/components/runs/useJobsLoader.svelte'
|
||||
@@ -581,6 +582,222 @@
|
||||
let resolutionNote = $state('')
|
||||
</script>
|
||||
|
||||
{#snippet runsHint()}
|
||||
<Tooltip
|
||||
documentationLink="https://www.windmill.dev/docs/core_concepts/monitor_past_and_future_runs"
|
||||
>
|
||||
All past and schedule executions of scripts and flows, including previews. You only see your own
|
||||
runs or runs of groups you belong to unless you are an admin.
|
||||
</Tooltip>
|
||||
{/snippet}
|
||||
|
||||
{#snippet headerActions()}
|
||||
<!-- Always the icon form: the bar has room for two counters, never for the two labelled
|
||||
blocks. pr-2 on top of the row's own gap because the counts hang outside their icons,
|
||||
so without it the badge butts against the first filter. -->
|
||||
<div class="flex items-center pr-2">
|
||||
<RunsQueue
|
||||
success={filters.val.status ?? null}
|
||||
{queue_count}
|
||||
{suspended_count}
|
||||
onJobsWaiting={() => {
|
||||
jobsFilter('waiting')
|
||||
}}
|
||||
onJobsSuspended={() => {
|
||||
jobsFilter('suspended')
|
||||
}}
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
<!-- The two groups are the widest thing in the bar: they appear only once the bar is wide
|
||||
enough to hold them next to the search field, below which the same filtering is reachable
|
||||
from the search field's own `kind` and `status` chips. -->
|
||||
<div class="hidden min-[1500px]:flex gap-2">
|
||||
<ToggleButtonGroup
|
||||
tabListClass="hidden min-[1500px]:flex"
|
||||
bind:selected={
|
||||
() => filters.val.job_kinds ?? 'runs',
|
||||
(v) => (v === 'runs' ? delete filters.val.job_kinds : (filters.val.job_kinds = v))
|
||||
}
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton size="sm" value="all" label="All" {item} />
|
||||
<ToggleButton
|
||||
size="sm"
|
||||
value="runs"
|
||||
label="Runs"
|
||||
tooltip="Runs are jobs that have no parent jobs (flows are jobs that are parent of the jobs they start), they have been triggered through the UI, a schedule or webhook"
|
||||
{item}
|
||||
/>
|
||||
<ToggleButton
|
||||
size="sm"
|
||||
value="dependencies"
|
||||
label="Deps"
|
||||
tooltip="Deploying a script, flow or an app launch a dependency job that create and then attach the lockfile to the deployed item. This mechanism ensure that logic is always executed with the exact same direct and indirect dependencies."
|
||||
{item}
|
||||
/>
|
||||
<ToggleButtonMore
|
||||
small
|
||||
hideSelectedOption={innerWidth < smallScreenWidth}
|
||||
togglableItems={[
|
||||
{
|
||||
label: 'Previews',
|
||||
value: 'previews',
|
||||
tooltip: "Previews are jobs that have been started in the editor as 'Tests'"
|
||||
},
|
||||
{
|
||||
label: 'Sync',
|
||||
value: 'deploymentcallbacks',
|
||||
tooltip:
|
||||
'Sync jobs that are triggered on every script deployment to sync the workspace with the Git repository configured in the the workspace settings'
|
||||
}
|
||||
]}
|
||||
{item}
|
||||
bind:selected={filters.val.job_kinds}
|
||||
/>
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
<ToggleButtonGroup
|
||||
bind:selected={
|
||||
() => filters.val.status ?? 'all',
|
||||
(v) => (v === 'all' ? delete filters.val.status : (filters.val.status = v))
|
||||
}
|
||||
id="status"
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton size="sm" value={'all'} label="All" {item} />
|
||||
<ToggleButton
|
||||
size="sm"
|
||||
value={'running'}
|
||||
tooltip="Running"
|
||||
class="whitespace-nowrap"
|
||||
icon={CirclePlay}
|
||||
iconProps={{
|
||||
class:
|
||||
'group-data-[state=on]:text-yellow-600 dark:group-data-[state=on]:text-yellow-400'
|
||||
}}
|
||||
{item}
|
||||
/>
|
||||
<ToggleButton
|
||||
size="sm"
|
||||
value={'success'}
|
||||
tooltip="Success"
|
||||
class="whitespace-nowrap"
|
||||
icon={CircleCheck}
|
||||
iconProps={{
|
||||
class: 'group-data-[state=on]:text-green-500 dark:group-data-[state=on]:text-green-300'
|
||||
}}
|
||||
{item}
|
||||
/>
|
||||
<ToggleButton
|
||||
size="sm"
|
||||
value={'failure'}
|
||||
tooltip="Failure"
|
||||
class="whitespace-nowrap"
|
||||
icon={CircleAlert}
|
||||
iconProps={{
|
||||
class: 'group-data-[state=on]:text-red-500 dark:group-data-[state=on]:text-red-300'
|
||||
}}
|
||||
{item}
|
||||
/>
|
||||
<ToggleButton
|
||||
size="sm"
|
||||
value={'canceled'}
|
||||
tooltip="Canceled"
|
||||
class="whitespace-nowrap"
|
||||
icon={Hourglass}
|
||||
selectedColor="gray"
|
||||
{item}
|
||||
/>
|
||||
{#if filters.val.status == 'waiting'}
|
||||
<ToggleButton
|
||||
size="sm"
|
||||
value={'waiting'}
|
||||
tooltip="Waiting"
|
||||
class="whitespace-nowrap"
|
||||
icon={Hourglass}
|
||||
selectedColor="blue"
|
||||
{item}
|
||||
/>
|
||||
{:else if filters.val.status == 'suspended'}
|
||||
<ToggleButton
|
||||
size="sm"
|
||||
value={'suspended'}
|
||||
tooltip="Suspended"
|
||||
class="whitespace-nowrap"
|
||||
icon={Hourglass}
|
||||
selectedColor="purple"
|
||||
{item}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
|
||||
<div class="hidden xl:flex gap-2 items-center">
|
||||
{#if !filters.val.job_trigger_kind || filters.val.job_trigger_kind === '!schedule'}
|
||||
<div class="flex items-center gap-1" title="Show schedules">
|
||||
<Toggle
|
||||
size="xs"
|
||||
id="show-schedules"
|
||||
bind:checked={
|
||||
() => filters.val.job_trigger_kind !== '!schedule',
|
||||
(v) =>
|
||||
v ? delete filters.val.job_trigger_kind : (filters.val.job_trigger_kind = '!schedule')
|
||||
}
|
||||
/>
|
||||
<Calendar size={14} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex items-center gap-1" title="Show future jobs">
|
||||
<Toggle
|
||||
size="xs"
|
||||
id="show-future-jobs"
|
||||
bind:checked={
|
||||
() => filters.val.show_future_jobs !== false,
|
||||
(v) => (v ? delete filters.val.show_future_jobs : (filters.val.show_future_jobs = false))
|
||||
}
|
||||
/>
|
||||
<Clock size={14} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The one control in the band that gives way: everything else keeps its width and the
|
||||
search field takes what is left, down to a width that still shows a filter chip. -->
|
||||
<FilterSearchbar
|
||||
class={twMerge(
|
||||
'relative flex-1 min-w-[8rem]',
|
||||
Object.keys(filters.val).length <= 3 ? 'max-w-[20rem]' : 'max-w-[26rem]',
|
||||
ButtonType.UnifiedMinHeightClasses.sm
|
||||
)}
|
||||
schema={runsFilterSearchbarSchema}
|
||||
presets={buildRunsFilterPresets({
|
||||
isSuperAdminOrDevops: !!$superadmin || !!$devopsRole,
|
||||
isAdminsWorkspace: $workspaceStore === 'admins'
|
||||
})}
|
||||
bind:value={filters.val}
|
||||
placeholder="Filter runs..."
|
||||
autofocus
|
||||
/>
|
||||
<TimeframeSelect
|
||||
unifiedSize="sm"
|
||||
onClick={() => jobsLoader?.loadJobs(true)}
|
||||
loading={jobsLoader?.loading}
|
||||
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. -->
|
||||
<OpenInSessionButton
|
||||
source={{
|
||||
page: () => pageHref(RUNS_PATH) + window.location.search,
|
||||
workspaceId: $workspaceStore ?? undefined
|
||||
}}
|
||||
btnProps={{ unifiedSize: 'sm' }}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
<ConfirmationModal
|
||||
title={askingForConfirmation?.title ?? ''}
|
||||
confirmationText={askingForConfirmation?.confirmBtnText ?? ''}
|
||||
@@ -649,226 +866,12 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="w-full h-screen flex flex-col" bind:clientWidth={innerWidth}>
|
||||
<!-- Header and filters -->
|
||||
<div id="runs-filters-bar" class="flex flex-row items-start w-full px-4 gap-3 py-4 flex-wrap">
|
||||
<div class="flex flex-row items-center gap-6">
|
||||
<h1
|
||||
class={twMerge(
|
||||
'!text-2xl font-semibold leading-6 tracking-tight',
|
||||
$userStore?.operator ? 'pl-10' : ''
|
||||
)}
|
||||
>
|
||||
Runs
|
||||
</h1>
|
||||
|
||||
<Tooltip
|
||||
documentationLink="https://www.windmill.dev/docs/core_concepts/monitor_past_and_future_runs"
|
||||
>
|
||||
All past and schedule executions of scripts and flows, including previews. You only see
|
||||
your own runs or runs of groups you belong to unless you are an admin.
|
||||
</Tooltip>
|
||||
|
||||
<!-- Queue -->
|
||||
<RunsQueue
|
||||
success={filters.val.status ?? null}
|
||||
{queue_count}
|
||||
{suspended_count}
|
||||
onJobsWaiting={() => {
|
||||
jobsFilter('waiting')
|
||||
}}
|
||||
onJobsSuspended={() => {
|
||||
jobsFilter('suspended')
|
||||
}}
|
||||
small={innerWidth < smallScreenWidth}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="hidden xl:flex gap-2 ml-6">
|
||||
<ToggleButtonGroup
|
||||
tabListClass="hidden xl:flex"
|
||||
bind:selected={
|
||||
() => filters.val.job_kinds ?? 'runs',
|
||||
(v) => (v === 'runs' ? delete filters.val.job_kinds : (filters.val.job_kinds = v))
|
||||
}
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="all" label="All" {item} />
|
||||
<ToggleButton
|
||||
value="runs"
|
||||
label="Runs"
|
||||
tooltip="Runs are jobs that have no parent jobs (flows are jobs that are parent of the jobs they start), they have been triggered through the UI, a schedule or webhook"
|
||||
{item}
|
||||
/>
|
||||
<ToggleButton
|
||||
value="dependencies"
|
||||
label="Deps"
|
||||
tooltip="Deploying a script, flow or an app launch a dependency job that create and then attach the lockfile to the deployed item. This mechanism ensure that logic is always executed with the exact same direct and indirect dependencies."
|
||||
{item}
|
||||
/>
|
||||
<ToggleButtonMore
|
||||
hideSelectedOption={innerWidth < smallScreenWidth}
|
||||
togglableItems={[
|
||||
{
|
||||
label: 'Previews',
|
||||
value: 'previews',
|
||||
tooltip: "Previews are jobs that have been started in the editor as 'Tests'"
|
||||
},
|
||||
{
|
||||
label: 'Sync',
|
||||
value: 'deploymentcallbacks',
|
||||
tooltip:
|
||||
'Sync jobs that are triggered on every script deployment to sync the workspace with the Git repository configured in the the workspace settings'
|
||||
}
|
||||
]}
|
||||
{item}
|
||||
bind:selected={filters.val.job_kinds}
|
||||
/>
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
<ToggleButtonGroup
|
||||
bind:selected={
|
||||
() => filters.val.status ?? 'all',
|
||||
(v) => (v === 'all' ? delete filters.val.status : (filters.val.status = v))
|
||||
}
|
||||
id="status"
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value={'all'} label="All" {item} />
|
||||
<ToggleButton
|
||||
value={'running'}
|
||||
tooltip="Running"
|
||||
class="whitespace-nowrap"
|
||||
icon={CirclePlay}
|
||||
iconProps={{
|
||||
class:
|
||||
'group-data-[state=on]:text-yellow-600 dark:group-data-[state=on]:text-yellow-400'
|
||||
}}
|
||||
{item}
|
||||
/>
|
||||
<ToggleButton
|
||||
value={'success'}
|
||||
tooltip="Success"
|
||||
class="whitespace-nowrap"
|
||||
icon={CircleCheck}
|
||||
iconProps={{
|
||||
class:
|
||||
'group-data-[state=on]:text-green-500 dark:group-data-[state=on]:text-green-300'
|
||||
}}
|
||||
{item}
|
||||
/>
|
||||
<ToggleButton
|
||||
value={'failure'}
|
||||
tooltip="Failure"
|
||||
class="whitespace-nowrap"
|
||||
icon={CircleAlert}
|
||||
iconProps={{
|
||||
class: 'group-data-[state=on]:text-red-500 dark:group-data-[state=on]:text-red-300'
|
||||
}}
|
||||
{item}
|
||||
/>
|
||||
<ToggleButton
|
||||
value={'canceled'}
|
||||
tooltip="Canceled"
|
||||
class="whitespace-nowrap"
|
||||
icon={Hourglass}
|
||||
selectedColor="gray"
|
||||
{item}
|
||||
/>
|
||||
{#if filters.val.status == 'waiting'}
|
||||
<ToggleButton
|
||||
value={'waiting'}
|
||||
tooltip="Waiting"
|
||||
class="whitespace-nowrap"
|
||||
icon={Hourglass}
|
||||
selectedColor="blue"
|
||||
{item}
|
||||
/>
|
||||
{:else if filters.val.status == 'suspended'}
|
||||
<ToggleButton
|
||||
value={'suspended'}
|
||||
tooltip="Suspended"
|
||||
class="whitespace-nowrap"
|
||||
icon={Hourglass}
|
||||
selectedColor="purple"
|
||||
{item}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
|
||||
<div class="hidden xl:flex gap-2 items-center min-h-8 ml-2">
|
||||
{#if !filters.val.job_trigger_kind || filters.val.job_trigger_kind === '!schedule'}
|
||||
<div class="flex items-center gap-1" title="Show schedules">
|
||||
<Toggle
|
||||
size="xs"
|
||||
color="nord"
|
||||
id="show-schedules"
|
||||
bind:checked={
|
||||
() => filters.val.job_trigger_kind !== '!schedule',
|
||||
(v) =>
|
||||
v
|
||||
? delete filters.val.job_trigger_kind
|
||||
: (filters.val.job_trigger_kind = '!schedule')
|
||||
}
|
||||
/>
|
||||
<Calendar size={14} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex items-center gap-1" title="Show future jobs">
|
||||
<Toggle
|
||||
size="xs"
|
||||
color="nord"
|
||||
id="show-future-jobs"
|
||||
bind:checked={
|
||||
() => filters.val.show_future_jobs !== false,
|
||||
(v) =>
|
||||
v ? delete filters.val.show_future_jobs : (filters.val.show_future_jobs = false)
|
||||
}
|
||||
/>
|
||||
<Clock size={14} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TimeframeSelect
|
||||
wrapperClasses="ml-auto"
|
||||
onClick={() => jobsLoader?.loadJobs(true)}
|
||||
loading={jobsLoader?.loading}
|
||||
items={runsTimeframes}
|
||||
bind:value={_timeframe.val}
|
||||
/>
|
||||
<FilterSearchbar
|
||||
class={twMerge(
|
||||
'flex-1 relative min-w-[18rem]',
|
||||
Object.keys(filters.val).length <= 3
|
||||
? 'max-w-[20rem] 2xl:max-w-[28rem]'
|
||||
: 'max-w-[34rem]',
|
||||
ButtonType.UnifiedMinHeightClasses.md
|
||||
)}
|
||||
schema={runsFilterSearchbarSchema}
|
||||
presets={buildRunsFilterPresets({
|
||||
isSuperAdminOrDevops: !!$superadmin || !!$devopsRole,
|
||||
isAdminsWorkspace: $workspaceStore === 'admins'
|
||||
})}
|
||||
bind:value={filters.val}
|
||||
placeholder="Filter runs..."
|
||||
autofocus
|
||||
/>
|
||||
<!-- 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. -->
|
||||
<OpenInSessionButton
|
||||
source={{
|
||||
page: () => pageHref(RUNS_PATH) + window.location.search,
|
||||
workspaceId: $workspaceStore ?? undefined
|
||||
}}
|
||||
btnProps={{ unifiedSize: 'md' }}
|
||||
/>
|
||||
</div>
|
||||
<!-- Everything that used to sit in a row above the graph — the page's name, its queue, the
|
||||
filters and the timeframe — is in the page header. -->
|
||||
<PageHeaderContent afterName={runsHint} actions={headerActions} />
|
||||
|
||||
<!-- Graph -->
|
||||
<div id="runs-chart" class="p-2 px-4 bg-surface-tertiary mx-4 border rounded-md">
|
||||
<div id="runs-chart" class="p-2 px-4 bg-surface-tertiary mx-4 mt-2 border rounded-md">
|
||||
<div class="relative z-10 mb-2 flex gap-2">
|
||||
<Tabs bind:selected={graph}>
|
||||
<Tab value="RunChart" label="Duration" id="runs-chart-duration-tab" />
|
||||
|
||||
@@ -35,6 +35,8 @@
|
||||
summary: string
|
||||
labels: string[] | undefined
|
||||
}) => string | undefined
|
||||
/** Header variant: no path line (the host shows it) and a lighter summary. */
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -45,7 +47,8 @@
|
||||
editable = false,
|
||||
onSaved,
|
||||
kind = 'flow',
|
||||
saveOverride = undefined
|
||||
saveOverride = undefined,
|
||||
compact = false
|
||||
}: Props = $props()
|
||||
|
||||
let editSummary = $state('')
|
||||
@@ -128,14 +131,21 @@
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<div
|
||||
class={'min-w-0 truncate flex flex-col items-start px-2 py-1 rounded-md transition-colors cursor-pointer hover:bg-surface-hover'}
|
||||
class="min-w-0 truncate flex rounded-md transition-colors cursor-pointer hover:bg-surface-hover {compact
|
||||
? 'items-center px-1 py-0.5'
|
||||
: 'flex-col items-start px-2 py-1'}"
|
||||
>
|
||||
<span class="text-2xs leading-tight text-tertiary font-mono font-normal truncate max-w-full"
|
||||
>{path}</span
|
||||
>
|
||||
{#if !compact}
|
||||
<span
|
||||
class="text-2xs leading-tight text-tertiary font-mono font-normal truncate max-w-full"
|
||||
>{path}</span
|
||||
>
|
||||
{/if}
|
||||
<div class="flex items-center gap-3 max-w-full">
|
||||
<span
|
||||
class="text-sm font-semibold truncate {emptyString(summary)
|
||||
class="{compact ? 'text-xs font-medium' : 'text-sm font-semibold'} truncate {emptyString(
|
||||
summary
|
||||
)
|
||||
? 'text-tertiary italic font-normal'
|
||||
: 'text-emphasis'}"
|
||||
>
|
||||
@@ -252,11 +262,13 @@
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{:else}
|
||||
<div class="min-w-0 truncate flex flex-col px-2">
|
||||
{#if !emptyString(summary)}
|
||||
<div class="min-w-0 truncate flex items-center {compact ? '' : 'flex-col px-2'}">
|
||||
{#if !emptyString(summary) && !compact}
|
||||
<span class="text-[10px] leading-tight text-tertiary font-mono truncate">{path}</span>
|
||||
{/if}
|
||||
<span class="text-sm font-semibold text-emphasis truncate">
|
||||
<span
|
||||
class="{compact ? 'text-xs font-medium' : 'text-sm font-semibold'} text-emphasis truncate"
|
||||
>
|
||||
{emptyString(summary) ? (path ?? '') : summary}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { workspaceStore, userWorkspaces } from '$lib/stores'
|
||||
import { workspaceIsFork } from '$lib/utils/workspaceHierarchy'
|
||||
import { Button } from './common'
|
||||
import { Pencil } from 'lucide-svelte'
|
||||
import { Badge, Button } from './common'
|
||||
import { goto } from '$app/navigation'
|
||||
import { useWorkspaceDrafts } from '$lib/workspaceDrafts.svelte'
|
||||
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
|
||||
|
||||
// Surfaces pending drafts (scripts/flows/apps) for the current workspace and
|
||||
// links to the compare page in draft mode. Mutually exclusive with
|
||||
@@ -29,23 +29,18 @@
|
||||
</script>
|
||||
|
||||
{#if !isFork && draftCount > 0}
|
||||
<!-- Side padding mirrors the page content container below, so the banner
|
||||
stays aligned with it instead of bleeding to the viewport edges. -->
|
||||
<div class="w-full text-xs max-w-7xl mx-auto px-4 sm:px-8 pt-2">
|
||||
<div class="bg-blue-50 dark:bg-blue-900 rounded-md px-4 py-2">
|
||||
<div class="flex flex-wrap items-center justify-between gap-x-3 gap-y-2">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<Pencil class="w-4 h-4 text-accent shrink-0" />
|
||||
<span class="text-xs font-medium text-blue-900 dark:text-blue-100">
|
||||
This workspace has {draftCount} draft{draftCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Same button as the sibling ForkWorkspaceBanner CTA (they sit on the
|
||||
same home page), kept visually identical on purpose. -->
|
||||
<Button variant="default" unifiedSize="sm" onclick={openDraftCompare}>
|
||||
Review & deploy drafts
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- In the band rather than a banner over the page: it is a standing fact about the workspace,
|
||||
not an interruption, and one button says it and acts on it. -->
|
||||
<PageHeaderContent actions={draftsAction} actionsOrder={-100} />
|
||||
{/if}
|
||||
|
||||
{#snippet draftsAction()}
|
||||
<!-- A count on its own leaves the reader to work out what it counts; the sentence says it. -->
|
||||
<span class="shrink-0 text-2xs text-tertiary">This workspace has</span>
|
||||
<Badge color="blue" small>
|
||||
{draftCount} draft{draftCount !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
<Button variant="subtle" unifiedSize="sm" onclick={openDraftCompare}>
|
||||
Review & deploy drafts
|
||||
</Button>
|
||||
{/snippet}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<!--
|
||||
@component
|
||||
The glyph the rest of the app gives a workspace item's kind (see RowIcon), at the caller's size.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Code2, LayoutDashboard } from 'lucide-svelte'
|
||||
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
|
||||
import type { WorkspaceItemKind } from '$lib/components/workspacePicker'
|
||||
|
||||
let { kind, size = 14 }: { kind: WorkspaceItemKind; size?: number } = $props()
|
||||
</script>
|
||||
|
||||
{#if kind === 'flow'}
|
||||
<BarsStaggered {size} class="flex-shrink-0 text-teal-500" />
|
||||
{:else if kind === 'app'}
|
||||
<LayoutDashboard {size} class="flex-shrink-0 text-orange-500" />
|
||||
{:else}
|
||||
<Code2 {size} class="flex-shrink-0 text-blue-500" />
|
||||
{/if}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useLocalStorageValue } from '$lib/svelte5Utils.svelte'
|
||||
|
||||
// A deployed app is the page, not a page inside Windmill's chrome, so it opens with the header out
|
||||
// of the way. Pinning brings the header back for good — one flag for every app rather than one per
|
||||
// app: someone who wants the breadcrumb over a dashboard wants it over the next one too.
|
||||
const pref = useLocalStorageValue<boolean>('app_header_pinned', false, 'boolean')
|
||||
|
||||
/** Whether the page header stays in flow on a deployed app's page. */
|
||||
export const appHeaderPinned = {
|
||||
get val(): boolean {
|
||||
return pref.val === true
|
||||
},
|
||||
set val(pinned: boolean) {
|
||||
pref.val = pinned
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@
|
||||
import { base } from '$lib/base'
|
||||
import PublicApp from '$lib/components/apps/editor/PublicApp.svelte'
|
||||
import PublicAppFrame from '$lib/components/apps/editor/PublicAppFrame.svelte'
|
||||
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
|
||||
import { appHeaderPinned } from '$lib/components/apps/appHeaderPin.svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import { AppService, OpenAPI } from '$lib/gen'
|
||||
import { userStore } from '$lib/stores'
|
||||
@@ -124,30 +126,52 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
<PublicAppFrame
|
||||
{fetchEmbedToken}
|
||||
{viewerUrl}
|
||||
onViewerReady={(_token, requestTokenRefresh) => {
|
||||
refresh = requestTokenRefresh
|
||||
loadApp()
|
||||
}}
|
||||
<!-- The line separating the app from the band exists only while the band does: unpinned there is
|
||||
nothing above the app to separate it from. It goes on the app's wrapper rather than on the
|
||||
frame, which the header-less public page shares — and as an inset shadow rather than a border,
|
||||
which would take a pixel off an app sized to fill the page exactly and leave it scrolling. -->
|
||||
<div
|
||||
class="h-full {appHeaderPinned.val
|
||||
? 'shadow-[inset_0_1px_0_0_rgb(var(--color-border-light))]'
|
||||
: ''}"
|
||||
>
|
||||
{#snippet viewer()}
|
||||
<PublicApp
|
||||
{app}
|
||||
{workspace}
|
||||
{notExists}
|
||||
{noPermission}
|
||||
jwtError={false}
|
||||
inWorkspace
|
||||
{hideRefreshBar}
|
||||
onLoginSuccess={() => loadApp()}
|
||||
></PublicApp>
|
||||
{/snippet}
|
||||
</PublicAppFrame>
|
||||
<PublicAppFrame
|
||||
{fetchEmbedToken}
|
||||
{viewerUrl}
|
||||
onViewerReady={(_token, requestTokenRefresh) => {
|
||||
refresh = requestTokenRefresh
|
||||
loadApp()
|
||||
}}
|
||||
>
|
||||
{#snippet viewer()}
|
||||
<PublicApp
|
||||
{app}
|
||||
{workspace}
|
||||
{notExists}
|
||||
{noPermission}
|
||||
jwtError={false}
|
||||
inWorkspace
|
||||
{hideRefreshBar}
|
||||
onLoginSuccess={() => loadApp()}
|
||||
></PublicApp>
|
||||
{/snippet}
|
||||
</PublicAppFrame>
|
||||
</div>
|
||||
|
||||
{#if canWriteApp && !hideEditBtn}
|
||||
<div id="app-edit-btn" class="absolute bottom-4 z-50 right-4">
|
||||
<Button size="sm" startIcon={{ icon: Pen }} variant="subtle" href={editHref}>Edit</Button>
|
||||
</div>
|
||||
<!-- The page header carries it, with the app's name: a floating button over the app's own
|
||||
canvas lands on whatever the app draws there. -->
|
||||
<!-- Edit sits with the app's name rather than at the far end of the bar: on a page whose header
|
||||
is only there while hovered, the far end is a journey across the window. -->
|
||||
<PageHeaderContent item={{ kind: 'app', path }} afterName={editAction} fullBleed />
|
||||
{/if}
|
||||
|
||||
{#snippet editAction()}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: Pen }}
|
||||
variant="subtle"
|
||||
href={editHref}
|
||||
id="app-edit-btn">Edit</Button
|
||||
>
|
||||
{/snippet}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/** A directory level of an item's path, with the full path down to it. */
|
||||
export type PathDir = { name: string; fullPath: string }
|
||||
|
||||
/**
|
||||
* Splits `f/demo/weather_report` into the levels a breadcrumb walks: the scope (`f/<folder>` or
|
||||
* `u/<user>`), then each subfolder, then the item itself. Each level carries the cumulative path,
|
||||
* which is what the picker opens and highlights.
|
||||
*
|
||||
* Returns undefined for anything shorter than scope + name, such as a path still being typed.
|
||||
*/
|
||||
export function splitItemPath(path: string): { dirs: PathDir[]; leaf: PathDir } | undefined {
|
||||
const parts = path.split('/')
|
||||
if (parts.length < 3) return undefined
|
||||
const scope = parts.slice(0, 2).join('/')
|
||||
const slug = parts.slice(2)
|
||||
const dirs: PathDir[] = [{ name: scope, fullPath: scope }]
|
||||
let acc = scope
|
||||
for (let i = 0; i < slug.length - 1; i++) {
|
||||
acc = `${acc}/${slug[i]}`
|
||||
dirs.push({ name: slug[i], fullPath: acc })
|
||||
}
|
||||
return { dirs, leaf: { name: slug[slug.length - 1], fullPath: path } }
|
||||
}
|
||||
@@ -70,7 +70,14 @@
|
||||
<div class="flex items-center">
|
||||
<!-- The tooltip fixes a bug where the other tooltips won't disappear -->
|
||||
<Tooltip disablePopup>
|
||||
<DropdownV2 {btnText} enableFlyTransition {items} size={small ? 'sm' : 'md'} />
|
||||
<!-- fixedHeight pins the wrapper at h-8, which overhangs a small group's 28px row. -->
|
||||
<DropdownV2
|
||||
{btnText}
|
||||
enableFlyTransition
|
||||
{items}
|
||||
size={small ? 'sm' : 'md'}
|
||||
fixedHeight={!small}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
import { AIBtnClasses } from './chat/AIButtonStyle'
|
||||
import { prefersSessionHandoff } from './chat/global/gate'
|
||||
import { startSessionWithPrompt } from '$lib/components/sessions/sessionSwitch.svelte'
|
||||
import { userStore } from '$lib/stores'
|
||||
interface Props {
|
||||
label?: string
|
||||
initialInput?: string
|
||||
@@ -16,7 +15,7 @@
|
||||
|
||||
// The label stays short ("Ask AI") for the search bar's inline row; the hover
|
||||
// text is where "new AI session" fits.
|
||||
const handsOffToSession = $derived(prefersSessionHandoff($userStore?.operator))
|
||||
const handsOffToSession = $derived(prefersSessionHandoff())
|
||||
|
||||
export function onClick() {
|
||||
// No item to preview here — this carries a question, not a target — so the
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import SessionsBetaBanner from '$lib/components/sessions/SessionsBetaBanner.svelte'
|
||||
import { zIndexes } from '$lib/zIndexes'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { chatState } from './sharedChatState.svelte'
|
||||
import { loadCopilot } from '$lib/components/copilot/loadCopilot'
|
||||
import { copilotInfo } from '$lib/aiStore'
|
||||
@@ -47,8 +47,8 @@
|
||||
}: Props = $props()
|
||||
|
||||
// The desktop rail is fixed-positioned, so the content is offset by a matching
|
||||
// left padding (in rem, matching the rail). Mobile/operator/borderless: no rail.
|
||||
let contentPadLeft = $derived(noBorder || $userStore?.operator || isMobile ? 0 : sidebarWidth)
|
||||
// left padding (in rem, matching the rail). Drawer (mobile or detached)/borderless: no rail.
|
||||
let contentPadLeft = $derived(noBorder || isMobile ? 0 : sidebarWidth)
|
||||
|
||||
$effect(() => {
|
||||
chatState.dockedChatAvailable = !disableAi
|
||||
@@ -87,7 +87,7 @@
|
||||
<div
|
||||
class={classNames(
|
||||
'py-0.5 px-4 sm:px-4 shadow-sm max-w-7xl md:hidden justify-start flex',
|
||||
noBorder || $userStore?.operator ? 'hidden' : ''
|
||||
noBorder ? 'hidden' : ''
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
|
||||
@@ -2657,7 +2657,7 @@ function allowedTriggerKinds(): PageTriggerKind[] {
|
||||
}
|
||||
|
||||
// Pages an operator may reach: exactly the ones enabled in the workspace's
|
||||
// operator_settings, the same source OperatorMenu gates on. Operators are never admins, so
|
||||
// operator_settings, the same source the sidebar gates on. Operators are never admins, so
|
||||
// workspace_settings is excluded whatever the settings say.
|
||||
function operatorOpenPages(workspaceId: string | undefined): OpenPageName[] {
|
||||
const settings = get(userWorkspaces).find((w) => w.id === workspaceId)?.operator_settings
|
||||
|
||||
@@ -29,10 +29,9 @@ export function isGlobalAiEnabled(): boolean {
|
||||
* Whether an AI entry point hands off to a session instead of driving the docked
|
||||
* chat. Deliberately the same condition as the root layout's `disableAi`, so a
|
||||
* caller falling back on `false` always has a mounted pane to fall back to.
|
||||
* Operators keep that pane (`/sessions` refuses them) until the operator chat ships.
|
||||
*/
|
||||
export function prefersSessionHandoff(isOperator: boolean | undefined): boolean {
|
||||
return isGlobalAiEnabled() && !isOperator
|
||||
export function prefersSessionHandoff(): boolean {
|
||||
return isGlobalAiEnabled()
|
||||
}
|
||||
|
||||
/** Clear the opt-out, then hard-reload so every gated site re-reads it. */
|
||||
|
||||
@@ -3,11 +3,10 @@
|
||||
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import ErrorHandlerToggleButton from './ErrorHandlerToggleButton.svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { userStore } from '$lib/stores'
|
||||
import { createEventDispatcher, getContext, tick } from 'svelte'
|
||||
import { MediaQuery } from 'svelte/reactivity'
|
||||
import SummaryPathDisplay from '$lib/components/SummaryPathDisplay.svelte'
|
||||
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
|
||||
import type { TriggerContext } from '../triggers'
|
||||
import type { Item } from '$lib/utils'
|
||||
import { Bell, BellOff, Calendar } from 'lucide-svelte'
|
||||
@@ -33,7 +32,10 @@
|
||||
color?: 'red'
|
||||
}
|
||||
|
||||
const { triggersCount, triggersState } = $state(getContext<TriggerContext>('TriggerContext'))
|
||||
const triggerContext = getContext<TriggerContext>('TriggerContext')
|
||||
const { triggersCount, triggersState } = $state(triggerContext)
|
||||
// The buttons below render in the page header, away from this page's tree.
|
||||
const headerContexts = new Map<any, any>([['TriggerContext', triggerContext]])
|
||||
|
||||
interface Props {
|
||||
mainButtons?: MainButton[]
|
||||
@@ -134,73 +136,73 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="border-b">
|
||||
<div class="mx-auto">
|
||||
<div
|
||||
class="flex w-full flex-wrap md:flex-nowrap justify-end gap-x-2 gap-y-4 items-center min-h-12 py-2 md:py-0"
|
||||
{#snippet summaryContent()}
|
||||
<SummaryPathDisplay
|
||||
{summary}
|
||||
{path}
|
||||
bind:labels
|
||||
{inheritedLabels}
|
||||
{onSaved}
|
||||
kind={errorHandlerKind}
|
||||
compact
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#snippet actions()}
|
||||
{#if tag}
|
||||
<Badge>tag: {tag}</Badge>
|
||||
{/if}
|
||||
{@render children?.()}
|
||||
{#if triggersState?.triggers?.some((t) => t.isPrimary && !t.isDraft)}
|
||||
{@const primarySchedule = triggersState.triggers.findIndex((t) => t.isPrimary && !t.isDraft)}
|
||||
<Button
|
||||
btnClasses="inline-flex"
|
||||
startIcon={{ icon: Calendar }}
|
||||
variant="default"
|
||||
unifiedSize="sm"
|
||||
on:click={async () => {
|
||||
dispatch('seeTriggers')
|
||||
await tick()
|
||||
triggersState.selectedTriggerIndex = primarySchedule
|
||||
}}
|
||||
>
|
||||
<div class="grow px-2 inline-flex items-center gap-4 min-w-0">
|
||||
<div class={twMerge('min-w-0', $userStore?.operator ? 'pl-10' : '')}>
|
||||
<SummaryPathDisplay
|
||||
{summary}
|
||||
{path}
|
||||
bind:labels
|
||||
{inheritedLabels}
|
||||
{onSaved}
|
||||
kind={errorHandlerKind}
|
||||
/>
|
||||
</div>
|
||||
{#if tag}
|
||||
<Badge>tag: {tag}</Badge>
|
||||
{/if}
|
||||
{@render children?.()}
|
||||
{#if triggersState?.triggers?.some((t) => t.isPrimary && !t.isDraft)}
|
||||
{@const primarySchedule = triggersState.triggers.findIndex(
|
||||
(t) => t.isPrimary && !t.isDraft
|
||||
)}
|
||||
<Button
|
||||
btnClasses="inline-flex"
|
||||
startIcon={{ icon: Calendar }}
|
||||
variant="contained"
|
||||
color="light"
|
||||
size="xs"
|
||||
on:click={async () => {
|
||||
dispatch('seeTriggers')
|
||||
await tick()
|
||||
triggersState.selectedTriggerIndex = primarySchedule
|
||||
}}
|
||||
>
|
||||
{$triggersCount?.primary_schedule?.schedule ?? ''}
|
||||
</Button>
|
||||
{/if}
|
||||
{@render trigger_badges?.()}
|
||||
</div>
|
||||
<div class="flex gap-1 items-center pr-4">
|
||||
{#if allMenuItems.length > 0}
|
||||
{#key allMenuItems}
|
||||
<DropdownV2 items={allMenuItems} placement="bottom-end" size="md" />
|
||||
{/key}
|
||||
{/if}
|
||||
{#if wide.current}
|
||||
<ErrorHandlerToggleButton
|
||||
kind={errorHandlerKind}
|
||||
{scriptOrFlowPath}
|
||||
bind:errorHandlerMuted
|
||||
/>
|
||||
{/if}
|
||||
{#each barButtons as btn (btn.label)}
|
||||
{@const dropdownItems = dropdownItemsOf(btn)}
|
||||
<Button
|
||||
{...btn.buttonProps}
|
||||
startIcon={{ icon: btn.buttonProps.startIcon }}
|
||||
{dropdownItems}
|
||||
dropdownWidth={dropdownItems?.some((i) => i.description) ? 288 : undefined}
|
||||
btnClasses="flex items-center gap-1 whitespace-nowrap"
|
||||
>
|
||||
{btn.label}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{$triggersCount?.primary_schedule?.schedule ?? ''}
|
||||
</Button>
|
||||
{/if}
|
||||
{@render trigger_badges?.()}
|
||||
{#if allMenuItems.length > 0}
|
||||
{#key allMenuItems}
|
||||
<DropdownV2 items={allMenuItems} placement="bottom-end" size="sm" />
|
||||
{/key}
|
||||
{/if}
|
||||
{#if wide.current}
|
||||
<ErrorHandlerToggleButton
|
||||
kind={errorHandlerKind}
|
||||
{scriptOrFlowPath}
|
||||
bind:errorHandlerMuted
|
||||
unifiedSize="sm"
|
||||
/>
|
||||
{/if}
|
||||
{#each barButtons as btn (btn.label)}
|
||||
{@const dropdownItems = dropdownItemsOf(btn)}
|
||||
<Button
|
||||
{...btn.buttonProps}
|
||||
startIcon={{ icon: btn.buttonProps.startIcon }}
|
||||
unifiedSize="sm"
|
||||
{dropdownItems}
|
||||
dropdownWidth={dropdownItems?.some((i) => i.description) ? 288 : undefined}
|
||||
btnClasses="flex items-center gap-1 whitespace-nowrap"
|
||||
>
|
||||
{btn.label}
|
||||
</Button>
|
||||
{/each}
|
||||
{/snippet}
|
||||
|
||||
<!-- The bar this page's header used to draw now belongs to the layout: the path becomes the
|
||||
breadcrumb, the summary keeps its rename-and-labels popover, and everything else rides along
|
||||
as the bar's actions. -->
|
||||
<PageHeaderContent
|
||||
item={{ kind: errorHandlerKind, path, summaryContent }}
|
||||
{actions}
|
||||
contexts={headerContexts}
|
||||
/>
|
||||
|
||||
@@ -71,7 +71,9 @@
|
||||
{#if useDesktopLayout}
|
||||
<div class="h-full w-full flex flex-col">
|
||||
{@render header?.()}
|
||||
<div class="grow min-h-0 w-full">
|
||||
<!-- The band draws no edge of its own, so the separation from it is this page's first
|
||||
line, above the run form and the detail panel. -->
|
||||
<div class="grow min-h-0 w-full border-t">
|
||||
<Splitpanes>
|
||||
<Pane size={65} minSize={50}>
|
||||
{@render form?.({ graphInline: true })}
|
||||
@@ -102,7 +104,7 @@
|
||||
{:else}
|
||||
<div class="h-full w-full flex flex-col">
|
||||
{@render header?.()}
|
||||
<div class="grow min-h-0 w-full flex flex-col">
|
||||
<div class="grow min-h-0 w-full flex flex-col border-t">
|
||||
<!-- no-scrollbar: at phone widths the tabs overflow their strip, and a browser with
|
||||
classic scrollbars would spend a track under them, opening a band between the tabs
|
||||
and the content. Wheel, trackpad and drag still scroll the strip. -->
|
||||
|
||||
@@ -10,9 +10,16 @@
|
||||
scriptOrFlowPath: string
|
||||
errorHandlerMuted: boolean | undefined
|
||||
iconOnly?: boolean
|
||||
unifiedSize?: 'xs' | 'sm' | 'md' | 'lg'
|
||||
}
|
||||
|
||||
let { kind, scriptOrFlowPath, errorHandlerMuted = $bindable(), iconOnly = true }: Props = $props()
|
||||
let {
|
||||
kind,
|
||||
scriptOrFlowPath,
|
||||
errorHandlerMuted = $bindable(),
|
||||
iconOnly = true,
|
||||
unifiedSize = 'md'
|
||||
}: Props = $props()
|
||||
|
||||
async function toggleErrorHandler(): Promise<void> {
|
||||
const next = await toggleWorkspaceErrorHandler(kind, scriptOrFlowPath, errorHandlerMuted)
|
||||
@@ -24,7 +31,7 @@
|
||||
title={errorHandlerMuted === undefined || !errorHandlerMuted
|
||||
? 'Disable workspace error handler for this script'
|
||||
: 'Enable workspace error handler for this script'}
|
||||
unifiedSize="md"
|
||||
{unifiedSize}
|
||||
on:click={toggleErrorHandler}
|
||||
variant="subtle"
|
||||
startIcon={{
|
||||
|
||||
@@ -75,4 +75,7 @@ export type FlowBuilderProps = {
|
||||
// EditorHeader's path/breadcrumb row dropped (summary only). Used by the
|
||||
// session preview to save vertical room.
|
||||
condensedHeader?: boolean
|
||||
/** The editor's path/summary and actions go to the route's page header instead of a bar of
|
||||
* its own. Only the full-page editor sets it; embedders keep their own bar. */
|
||||
ownsPageHeader?: boolean
|
||||
}
|
||||
|
||||
@@ -40,14 +40,9 @@
|
||||
import FlowPanelPlacementPicker from './common/FlowPanelPlacementPicker.svelte'
|
||||
import { prefersSessionHandoff } from '../copilot/chat/global/gate'
|
||||
import { openSourceInSession } from '$lib/components/sessions/sessionSwitch.svelte'
|
||||
import {
|
||||
useOperatingUser,
|
||||
useOperatingWorkspace
|
||||
} from '$lib/components/operatingWorkspace.svelte'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
const operatingUser = useOperatingUser()
|
||||
const actingUser = $derived(operatingUser.current)
|
||||
const { flowStore, selectionManager, pathStore, opWorkspace } =
|
||||
getContext<FlowEditorContext>('FlowEditorContext')
|
||||
// Flow paths repeat across workspaces, and a session keeps every tab it has visited alive, so two
|
||||
@@ -408,11 +403,7 @@
|
||||
// contain. Hand it to a session opened on that step rather than the
|
||||
// docked chat, which sessions leave unmounted. Sent on arrival: the
|
||||
// user already said what they wanted in the description field.
|
||||
if (
|
||||
!sessionScopedManager &&
|
||||
sessionOpen &&
|
||||
prefersSessionHandoff(actingUser?.operator)
|
||||
) {
|
||||
if (!sessionScopedManager && sessionOpen && prefersSessionHandoff()) {
|
||||
void openSourceInSession(sessionOpen, {
|
||||
previewParams: { selected: detail.moduleId },
|
||||
seedPrompt: stepInstructionsPrompt(detail.moduleId, detail.instructions),
|
||||
|
||||
@@ -20,15 +20,7 @@
|
||||
|
||||
<script lang="ts">
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import {
|
||||
ArrowUp,
|
||||
ExternalLink,
|
||||
Globe2,
|
||||
KeyRound,
|
||||
PlugZap,
|
||||
Settings,
|
||||
WandSparkles
|
||||
} from 'lucide-svelte'
|
||||
import { ArrowUp, Globe2, KeyRound, Pin, PinOff, PlugZap, Settings } from 'lucide-svelte'
|
||||
import Button from '../common/button/Button.svelte'
|
||||
import { Badge } from '../common'
|
||||
import CloseButton from '../common/CloseButton.svelte'
|
||||
@@ -46,6 +38,8 @@
|
||||
import HomeConnectDrawer from './HomeConnectDrawer.svelte'
|
||||
import { USER_SETTINGS_HASH } from '../sidebar/settings'
|
||||
import { prefersSessionHandoff } from '../copilot/chat/global/gate'
|
||||
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import { onboardingProfile } from '$lib/onboardingProfile'
|
||||
|
||||
const COLLAPSED_SETTING = 'home-ai-composer-collapsed'
|
||||
@@ -107,16 +101,14 @@
|
||||
// unlocked, so the far commoner unlocked workspace never pops the composer in mid-load.
|
||||
let runOnlyWorkspace = $derived(isRuleActive('DisableDirectDeployment'))
|
||||
|
||||
// The composer hands off to /sessions, which refuses operators — so hide it from them (the
|
||||
// prompt would be silently dropped) while the AI-independent CLI/MCP row below stays.
|
||||
// The composer hands off to /sessions, so users opted out of the sessions beta don't get it
|
||||
// (the prompt would be silently dropped) while the AI-independent CLI/MCP row below stays.
|
||||
let showComposer = $derived(
|
||||
prefersSessionHandoff($userStore?.operator) &&
|
||||
!runOnlyWorkspace &&
|
||||
!$copilotInfo.workspaceDisabled
|
||||
prefersSessionHandoff() && !runOnlyWorkspace && !$copilotInfo.workspaceDisabled
|
||||
)
|
||||
|
||||
// The hero's margins and centered column are for the full block. The lone button row left
|
||||
// by a collapsed, operator, run-only or hidden-assistant view is a hint line and should
|
||||
// by a collapsed, opted-out, run-only or hidden-assistant view is a hint line and should
|
||||
// cost the page almost nothing: no top margin, and the content column's full width so it
|
||||
// hugs the right edge instead of floating centered in empty space.
|
||||
let hero = $derived(showComposer && !collapsed)
|
||||
@@ -149,7 +141,7 @@
|
||||
const FADE_MS = 600
|
||||
|
||||
// Rotate the example prompt every CYCLE_MS: fade the placeholder out, swap it, fade it back in.
|
||||
// Only while the composer is shown — otherwise (operators) it would loop forever driving an
|
||||
// Only while the composer is shown — otherwise it would loop forever driving an
|
||||
// unrendered input — and not under reduced motion, where the first prompt simply stays put.
|
||||
// The index lives outside the effect so re-showing the composer resumes the rotation from the
|
||||
// prompt currently displayed rather than restarting it.
|
||||
@@ -295,49 +287,56 @@
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if showComposer}
|
||||
<!-- All that is left of the composer once dismissed: sits with the CLI/MCP row so the
|
||||
collapsed home page is one quiet line. -->
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="xs"
|
||||
btnClasses="!text-2xs !text-hint"
|
||||
startIcon={{ icon: WandSparkles }}
|
||||
onClick={() => setCollapsed(false)}
|
||||
>
|
||||
Build with AI
|
||||
</Button>
|
||||
{:else}
|
||||
<div></div>
|
||||
{/if}
|
||||
|
||||
<!-- Not AI-related, so shown even to operators / when the composer is hidden. -->
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="xs"
|
||||
btnClasses="!text-2xs !text-hint"
|
||||
startIcon={{ icon: PlugZap }}
|
||||
onClick={() => homeConnectDrawer?.openDrawer?.()}
|
||||
>
|
||||
CLI / MCP
|
||||
</Button>
|
||||
{#if !$userStore?.operator && HOME_SHOW_HUB}
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="xs"
|
||||
btnClasses="!text-2xs !text-hint"
|
||||
startIcon={{ icon: Globe2 }}
|
||||
endIcon={{ icon: ExternalLink }}
|
||||
href={$hubBaseUrlStore}
|
||||
target="_blank"
|
||||
>
|
||||
Hub
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HomeConnectDrawer bind:this={homeConnectDrawer} />
|
||||
|
||||
<!-- Everything the hero offered beside the composer lives in the band's menu: the connect helper,
|
||||
the hub, and the way back to the composer once it has been put away. -->
|
||||
<!-- Last in the bar whatever else the page registers: a menu of side trips belongs after the
|
||||
buttons that act on what is on screen. -->
|
||||
<PageHeaderContent actions={homeMenu} actionsOrder={100} />
|
||||
|
||||
{#snippet homeMenu()}
|
||||
<!-- The band's own menu: the connect helper, the hub, and whether the composer is pinned to
|
||||
this page — all of them preferences or side trips, none of them the page's work. -->
|
||||
<DropdownV2
|
||||
placement="bottom-end"
|
||||
size="sm"
|
||||
items={[
|
||||
...(showComposer
|
||||
? [
|
||||
collapsed
|
||||
? {
|
||||
displayName: 'Pin session chat to the page',
|
||||
icon: Pin,
|
||||
action: () => setCollapsed(false)
|
||||
}
|
||||
: {
|
||||
displayName: 'Unpin session chat',
|
||||
icon: PinOff,
|
||||
action: () => setCollapsed(true)
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: 'CLI / MCP',
|
||||
icon: PlugZap,
|
||||
action: () => homeConnectDrawer?.openDrawer?.()
|
||||
},
|
||||
...(!$userStore?.operator && HOME_SHOW_HUB
|
||||
? [
|
||||
{
|
||||
displayName: 'Hub',
|
||||
icon: Globe2,
|
||||
href: $hubBaseUrlStore,
|
||||
hrefTarget: '_blank' as const
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
|
||||
interface Props {
|
||||
placement?: Placement
|
||||
/** Inline style on the panel. A margin here shifts it after floating-ui has placed it,
|
||||
* which is the only way to anchor it to a box wider than the trigger: melt's menubar
|
||||
* menu ignores `positioning.offset`. */
|
||||
contentStyle?: string
|
||||
justifyEnd?: boolean
|
||||
lightMode?: boolean
|
||||
maxHeight?: number
|
||||
@@ -45,6 +49,7 @@
|
||||
|
||||
let {
|
||||
placement = 'right-start',
|
||||
contentStyle = '',
|
||||
justifyEnd = false,
|
||||
lightMode = false,
|
||||
maxHeight = 900,
|
||||
@@ -221,6 +226,7 @@
|
||||
onmouseenter={cancelPendingClose}
|
||||
onmouseleave={scheduleClose}
|
||||
transition:placementFly={{ duration: 100, placement }}
|
||||
style={contentStyle}
|
||||
class={twMerge(
|
||||
'z-[6000] border w-56 origin-top-right rounded-md shadow-md focus:outline-none',
|
||||
// Default: scroll on the melt element. submenuSafe moves it to the inner
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { Snippet } from 'svelte'
|
||||
import type { WorkspaceItemKind } from './workspacePicker'
|
||||
|
||||
/** An item the page is working on: the breadcrumb walks its path and ends with its summary. */
|
||||
export type PageHeaderItem = {
|
||||
kind: WorkspaceItemKind
|
||||
/** Shown whole, and copied on click. */
|
||||
path: string | undefined
|
||||
summary?: string
|
||||
/** Rendered in place of the plain summary, for a page whose summary is itself a control —
|
||||
* a detail page's rename-and-labels popover, an editor's editable title. */
|
||||
summaryContent?: Snippet
|
||||
}
|
||||
|
||||
/** A page with no item of its own, such as a list: the breadcrumb is the section alone. */
|
||||
export type PageHeaderSection = {
|
||||
label: string
|
||||
/** Rendered in place of `label` when the name is itself a control, such as an editable title. */
|
||||
content?: Snippet
|
||||
/** Scopes the segment's picker, when the section maps to a kind of item. */
|
||||
kind?: WorkspaceItemKind
|
||||
}
|
||||
|
||||
export type PageHeaderContent = {
|
||||
item?: PageHeaderItem
|
||||
section?: PageHeaderSection
|
||||
/** Rendered at the right end of the bar — the page's own buttons. */
|
||||
actions?: Snippet
|
||||
/** 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
|
||||
/** 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. */
|
||||
afterName?: Snippet
|
||||
/** The workspace this page acts on, when it is not the one the app is pointed at — a session
|
||||
* running in a fork. The breadcrumb then names that workspace, and its fork part shows only
|
||||
* when it really is a fork. */
|
||||
actingWorkspaceId?: string
|
||||
/** The page is the whole viewport — a deployed app. The band leaves the flow and waits behind
|
||||
* a handle in the corner, until the viewer pins it. */
|
||||
fullBleed?: boolean
|
||||
/** Width in px, at the right of the viewport, that this page owns from the top down — a
|
||||
* session's side panel. The band stops there instead of running over it, and the band floats
|
||||
* above the page rather than pushing it down, so that column starts at the top. */
|
||||
barRightInset?: number
|
||||
/** Contexts those buttons look up. They render under the header, not under the page that
|
||||
* wrote them, so anything the page's tree provides has to travel with them. */
|
||||
contexts?: Map<any, any>
|
||||
}
|
||||
|
||||
// The bar belongs to the root layout and is always on screen; a route page fills its breadcrumb
|
||||
// and actions by registering here.
|
||||
//
|
||||
// A registration is a getter, not a snapshot: the layout calls it while rendering, so a path or
|
||||
// summary the page edits reaches the bar through the layout's own dependency tracking, with no
|
||||
// effect keeping two copies in step.
|
||||
//
|
||||
// Registrations stack, and what the bar shows is their merge (see `content`). Pages register on
|
||||
// mount, so the first entry is the outermost one — an editor nested in a session pane or a drawer
|
||||
// is asked to register nothing.
|
||||
let entries = $state<{ id: number; get: () => PageHeaderContent }[]>([])
|
||||
let nextId = 0
|
||||
|
||||
export const pageHeader = {
|
||||
/**
|
||||
* The registrations merged, later ones winning field by field: a route sets the frame (where
|
||||
* the bar goes) and something nested in it — the active session, say — fills the breadcrumb
|
||||
* and the actions.
|
||||
*/
|
||||
get content(): PageHeaderContent | undefined {
|
||||
if (entries.length === 0) return undefined
|
||||
const merged: PageHeaderContent = {}
|
||||
for (const e of entries) {
|
||||
const c = e.get()
|
||||
if (c.item !== undefined) merged.item = c.item
|
||||
if (c.section !== undefined) merged.section = c.section
|
||||
if (c.afterName !== undefined) merged.afterName = c.afterName
|
||||
if (c.contexts !== undefined) merged.contexts = c.contexts
|
||||
if (c.actingWorkspaceId !== undefined) merged.actingWorkspaceId = c.actingWorkspaceId
|
||||
if (c.barRightInset !== undefined) merged.barRightInset = c.barRightInset
|
||||
if (c.fullBleed !== undefined) merged.fullBleed = c.fullBleed
|
||||
}
|
||||
return merged
|
||||
},
|
||||
/**
|
||||
* Every registration's actions, in registration order: a page and something inside it can both
|
||||
* put buttons in the band, and each renders with the contexts its own registration named.
|
||||
*/
|
||||
get actions(): { render: Snippet; contexts?: Map<any, any> }[] {
|
||||
return entries
|
||||
.flatMap((e, i) => {
|
||||
const c = e.get()
|
||||
return c.actions
|
||||
? [{ render: c.actions, contexts: c.contexts, order: c.actionsOrder ?? 0, i }]
|
||||
: []
|
||||
})
|
||||
.sort((a, b) => a.order - b.order || a.i - b.i)
|
||||
},
|
||||
/** Registers on mount and returns the id to release on destroy. */
|
||||
register(get: () => PageHeaderContent): number {
|
||||
const id = nextId++
|
||||
entries = [...entries, { id, get }]
|
||||
return id
|
||||
},
|
||||
release(id: number) {
|
||||
entries = entries.filter((e) => e.id !== id)
|
||||
}
|
||||
}
|
||||
@@ -286,6 +286,6 @@
|
||||
src={iframeSrc}
|
||||
sandbox={sandboxAttr}
|
||||
referrerpolicy={unsandboxed ? undefined : 'no-referrer'}
|
||||
class="w-full h-full min-h-screen bg-white border-none"
|
||||
class="w-full h-full bg-white border-none"
|
||||
></iframe>
|
||||
{/if}
|
||||
|
||||
@@ -266,7 +266,7 @@
|
||||
const aiConfigLoaded = $derived(!!opWs && $copilotWorkspace === opWs)
|
||||
// Say where the button leads: the route hands this prompt to a fresh AI
|
||||
// session for everyone who has one, and drives the docked chat for the rest.
|
||||
const handsOffToSession = $derived(prefersSessionHandoff($userStore?.operator))
|
||||
const handsOffToSession = $derived(prefersSessionHandoff())
|
||||
const isAiEnabled = $derived(aiConfigLoaded && $copilotInfo.enabled)
|
||||
|
||||
$effect(() => {
|
||||
|
||||
@@ -170,6 +170,9 @@ export function unsandboxedRawAppHtml(
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>App</title>
|
||||
<!-- Before the app's own stylesheet, so a bundle that wants a margin can still set one: the
|
||||
browser default of 8px would otherwise frame every app with a gap it never asked for. -->
|
||||
<style>html, body { margin: 0; }</style>
|
||||
<link rel="stylesheet" href="${baseUrl}/api/w/${workspace}/apps_u/get_data/v/${secret}.css" />
|
||||
<script>
|
||||
window.ctx = ${ctx ? JSON.stringify(ctx).replace(/</g, '\\u003c') : 'undefined'};
|
||||
|
||||
@@ -28,7 +28,9 @@
|
||||
{#if small}
|
||||
<Popover contentClasses="p-4" openOnHover debounceDelay={100}>
|
||||
{#snippet trigger()}
|
||||
<div class="relative">
|
||||
<!-- mr-2: the count badge hangs half outside this box, so the margin — not the row's
|
||||
gap — is what keeps it off whatever sits to the right. -->
|
||||
<div class="relative mr-2">
|
||||
<ServerCog size={16} />
|
||||
{#if queue_count && ($queue_count ?? 0) > 0}
|
||||
<div
|
||||
@@ -49,14 +51,11 @@
|
||||
{/if}
|
||||
|
||||
{#if small}
|
||||
<Popover
|
||||
contentClasses="p-4"
|
||||
openOnHover
|
||||
debounceDelay={100}
|
||||
disablePopup={!suspended_count || ($suspended_count ?? 0) <= 0}
|
||||
>
|
||||
<!-- The icon is on the bar whatever the count, so it answers a hover whatever the count —
|
||||
a disabled trigger takes no pointer events and reads as a dead icon. -->
|
||||
<Popover contentClasses="p-4" openOnHover debounceDelay={100}>
|
||||
{#snippet trigger()}
|
||||
<div class="relative">
|
||||
<div class="relative mr-2">
|
||||
<Hourglass size={16} />
|
||||
<div
|
||||
class="absolute top-0 right-0 translate-x-1/2 -translate-y-1/2 bg-surface-secondary-inverse rounded-full text-primary-inverse text-2xs h-4 min-w-4 px-1"
|
||||
@@ -69,7 +68,7 @@
|
||||
{@render suspendedContent()}
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{:else}
|
||||
{:else if suspended_count && ($suspended_count ?? 0) > 0}
|
||||
{@render suspendedContent()}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -103,28 +102,28 @@
|
||||
{/snippet}
|
||||
|
||||
{#snippet suspendedContent()}
|
||||
{#if suspended_count && ($suspended_count ?? 0) > 0}
|
||||
<RunOption label="Suspended">
|
||||
{#snippet tooltip()}
|
||||
Jobs waiting for an event or approval before being resumed
|
||||
{/snippet}
|
||||
<div
|
||||
class={suspended_count && ($suspended_count ?? 0) > 0
|
||||
? 'bg-surface-secondary-inverse text-primary-inverse rounded-full min-w-6 h-6 flex center-center'
|
||||
: ''}>{suspended_count ? ($suspended_count ?? 0).toFixed(0) : '...'}</div
|
||||
>
|
||||
<div class="truncate text-2xs !text-secondary">
|
||||
<Button unifiedSize="md" variant="subtle" on:click={() => onJobsSuspended?.()}>
|
||||
{#if success == 'suspended'}
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
Reset filter
|
||||
<X size={12} />
|
||||
</div>
|
||||
{:else}
|
||||
<ListFilterPlus size={14} />
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</RunOption>
|
||||
{/if}
|
||||
<RunOption label="Suspended">
|
||||
{#snippet tooltip()}
|
||||
Jobs waiting for an event or approval before being resumed
|
||||
{/snippet}
|
||||
<div
|
||||
class={suspended_count && ($suspended_count ?? 0) > 0
|
||||
? 'bg-surface-secondary-inverse text-primary-inverse rounded-full min-w-6 h-6 flex center-center'
|
||||
: ''}
|
||||
>
|
||||
{suspended_count ? ($suspended_count ?? 0).toFixed(0) : '...'}
|
||||
</div>
|
||||
<div class="truncate text-2xs !text-secondary">
|
||||
<Button unifiedSize="md" variant="subtle" on:click={() => onJobsSuspended?.()}>
|
||||
{#if success == 'suspended'}
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
Reset filter
|
||||
<X size={12} />
|
||||
</div>
|
||||
{:else}
|
||||
<ListFilterPlus size={14} />
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</RunOption>
|
||||
{/snippet}
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { CalendarIcon, RefreshCw } from 'lucide-svelte'
|
||||
import { Button } from '../common'
|
||||
import { Button, type ButtonType } from '../common'
|
||||
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
|
||||
import Popover from '../meltComponents/Popover.svelte'
|
||||
@@ -94,9 +94,18 @@
|
||||
value: Timeframe
|
||||
wrapperClasses?: string
|
||||
onClick?: () => void
|
||||
/** Sizes the three buttons of the control itself; the presets inside the popover keep md. */
|
||||
unifiedSize?: ButtonType.UnifiedSize
|
||||
}
|
||||
|
||||
let { loading = false, onClick, items, value = $bindable(), wrapperClasses }: Props = $props()
|
||||
let {
|
||||
loading = false,
|
||||
onClick,
|
||||
items,
|
||||
value = $bindable(),
|
||||
wrapperClasses,
|
||||
unifiedSize = 'md'
|
||||
}: Props = $props()
|
||||
|
||||
let isOpen = $state(false)
|
||||
|
||||
@@ -141,7 +150,7 @@
|
||||
|
||||
<div class="relative flex {wrapperClasses}">
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
{unifiedSize}
|
||||
wrapperClasses="flex-1"
|
||||
btnClasses="!rounded-r-none whitespace-nowrap"
|
||||
onClick={() => onClick?.()}
|
||||
@@ -154,7 +163,7 @@
|
||||
{#if value.type === 'manual'}
|
||||
<Button
|
||||
btnClasses="!rounded-none border-l-0"
|
||||
unifiedSize="md"
|
||||
{unifiedSize}
|
||||
onClick={() => (value = { ...items[0] })}
|
||||
>
|
||||
Reset
|
||||
@@ -168,7 +177,7 @@
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
{unifiedSize}
|
||||
iconOnly
|
||||
btnClasses="!rounded-l-none border-l-0"
|
||||
endIcon={{ icon: CalendarIcon }}
|
||||
|
||||
@@ -43,10 +43,6 @@
|
||||
import { copilotInfo } from '$lib/aiStore'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { openSourceInSession } from './sessionSwitch.svelte'
|
||||
import { useOperatingUser } from '$lib/components/operatingWorkspace.svelte'
|
||||
|
||||
const operatingUser = useOperatingUser()
|
||||
const actingUser = $derived(operatingUser.current)
|
||||
|
||||
let {
|
||||
source,
|
||||
@@ -69,7 +65,7 @@
|
||||
* says that clicking it leaves for a session. */
|
||||
tooltip?: string
|
||||
/** Rendered instead when the caller keeps a docked chat to drive — an
|
||||
* opted-out user or an operator (typically the editor's inline-chat
|
||||
* opted-out user (typically the editor's inline-chat
|
||||
* toggle). Never rendered inside the session panel. */
|
||||
fallback?: Snippet
|
||||
} = $props()
|
||||
@@ -80,13 +76,8 @@
|
||||
// SessionEditorTarget / the session wrapper); iframe preview tabs are not
|
||||
// the top window.
|
||||
const inSessionPanel = !!getContext('aiChatManager') || (BROWSER && window.self !== window.top)
|
||||
// prefersSessionHandoff carries the operator clause: the sessions page refuses
|
||||
// them, so an entry point on a page they can reach (Runs, the trigger lists)
|
||||
// would only route them into that refusal.
|
||||
const show = $derived(
|
||||
!inSessionPanel &&
|
||||
!!(source?.target || source?.page) &&
|
||||
prefersSessionHandoff(actingUser?.operator)
|
||||
!inSessionPanel && !!(source?.target || source?.page) && prefersSessionHandoff()
|
||||
)
|
||||
|
||||
// Not $state: only read inside open() as a re-entrancy latch, never rendered.
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
iconOnly={isCollapsed}
|
||||
tooltip={isCollapsed ? 'Workspace' : undefined}
|
||||
size="sm"
|
||||
class="w-full justify-center"
|
||||
class="w-full justify-center whitespace-nowrap"
|
||||
onActivate={onNavActivate}
|
||||
/>
|
||||
<ToggleButton
|
||||
@@ -80,7 +80,7 @@
|
||||
iconOnly={isCollapsed}
|
||||
tooltip={isCollapsed ? 'AI Sessions (beta)' : undefined}
|
||||
size="sm"
|
||||
class="w-full justify-center"
|
||||
class="w-full justify-center whitespace-nowrap"
|
||||
/>
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import AIChat from '$lib/components/copilot/chat/AIChat.svelte'
|
||||
import EditableInput from '$lib/components/common/EditableInput.svelte'
|
||||
import { Button, NameIdTooltip } from '$lib/components/common'
|
||||
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'
|
||||
import { AIChatManager } from '$lib/components/copilot/chat/AIChatManager.svelte'
|
||||
@@ -17,17 +19,12 @@
|
||||
import {
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
ArrowUpRight,
|
||||
EllipsisVertical,
|
||||
ExternalLink,
|
||||
MessageSquareDashed,
|
||||
Pencil,
|
||||
Settings,
|
||||
Trash2,
|
||||
X
|
||||
} from 'lucide-svelte'
|
||||
import { type Item } from '$lib/utils'
|
||||
import WorkspaceScopeTrigger from '$lib/components/WorkspaceScopeTrigger.svelte'
|
||||
import SessionWorkspaceBar from './SessionWorkspaceBar.svelte'
|
||||
import SessionChangesBar from './SessionChangesBar.svelte'
|
||||
import { clearSessionRecovered, isSessionRecovered } from './sessionRecoveryNotice.svelte'
|
||||
@@ -53,17 +50,15 @@
|
||||
} from './sessionState.svelte'
|
||||
import { getOrCreateRuntime, removeSession } from './sessionRuntime.svelte'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { base } from '$app/paths'
|
||||
import { splitterPointerCapture } from '$lib/utils/splitterPointerCapture'
|
||||
|
||||
// headerInset: extra left padding on the chat header so it clears a floating
|
||||
// control (the collapsed-rail launcher) sitting at the screen's top-left.
|
||||
let {
|
||||
sessionId,
|
||||
headerInset = false
|
||||
headerInPage = false
|
||||
}: {
|
||||
sessionId: string
|
||||
headerInset?: boolean
|
||||
/** True for the session the page is showing: its header rides the page header bar. */
|
||||
headerInPage?: boolean
|
||||
} = $props()
|
||||
|
||||
// Parent keys by sessionId; this wrapper only mounts when the session exists.
|
||||
@@ -112,34 +107,12 @@
|
||||
})
|
||||
})
|
||||
|
||||
// The workspace the session acts on, shown in the header "Acting on" strip via the shared
|
||||
// WorkspaceScopeTrigger chip. `targetId` is also the workspace the chip's ellipsis menu targets.
|
||||
const acting = $derived.by(() => {
|
||||
const wsId = session ? getEffectiveWorkspaceId(session) : undefined
|
||||
if (!wsId) return undefined
|
||||
const name = $userWorkspaces.find((w) => w.id === wsId)?.name ?? wsId
|
||||
return { targetId: wsId, name }
|
||||
})
|
||||
|
||||
// Ellipsis menu on the "Acting on" chip. Both entries are real links (so
|
||||
// modifier/middle clicks open a new tab); the `workspace` query param
|
||||
// points the navigation at the acting workspace — the layout applies it on
|
||||
// both full loads and client-side query changes. The trailing external-link
|
||||
// glyphs make the leave-the-session navigation explicit.
|
||||
const actingMenu = $derived<Item[]>([
|
||||
{
|
||||
displayName: 'Workspace settings',
|
||||
icon: Settings,
|
||||
href: `${base}/workspace_settings?workspace=${acting?.targetId ?? ''}`,
|
||||
extra: externalLinkHint
|
||||
},
|
||||
{
|
||||
displayName: 'Go to this workspace',
|
||||
icon: ArrowUpRight,
|
||||
href: `${base}/?workspace=${acting?.targetId ?? ''}`,
|
||||
extra: externalLinkHint
|
||||
}
|
||||
])
|
||||
// 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
|
||||
@@ -150,7 +123,7 @@
|
||||
// otherwise race to clobber the active chat's model config.
|
||||
$effect(() => {
|
||||
if (sessionState.currentSessionId !== sessionId) return
|
||||
const ws = acting?.targetId ?? $workspaceStore
|
||||
const ws = actingWorkspaceId ?? $workspaceStore
|
||||
if (ws) {
|
||||
loadCopilot(ws)
|
||||
}
|
||||
@@ -334,10 +307,6 @@
|
||||
'flex flex-row items-center justify-between gap-2 py-2 px-3 text-xs border rounded-md'
|
||||
</script>
|
||||
|
||||
{#snippet externalLinkHint()}
|
||||
<ExternalLink size={12} class="shrink-0 text-tertiary" />
|
||||
{/snippet}
|
||||
|
||||
{#if !session || !runtime}
|
||||
<div class="p-8 text-secondary text-sm">Session not found</div>
|
||||
{:else}
|
||||
@@ -411,95 +380,93 @@
|
||||
sessions have their own empty-state affordances above. -->
|
||||
{#snippet sessionEmptyHint()}{/snippet}
|
||||
|
||||
{#snippet sessionHeader()}
|
||||
<EditableInput
|
||||
bind:this={summaryInput}
|
||||
value={session.summary ?? ''}
|
||||
placeholder="Untitled session"
|
||||
onSave={(v) => renameSession(session.id, v)}
|
||||
class="text-xs font-normal"
|
||||
inputClass="!text-xs !font-normal"
|
||||
/>
|
||||
|
||||
<DropdownV2
|
||||
fixedHeight={false}
|
||||
placement="bottom-start"
|
||||
enableFlyTransition
|
||||
items={[
|
||||
{
|
||||
displayName: 'Rename',
|
||||
icon: Pencil,
|
||||
action: () => summaryInput?.edit()
|
||||
},
|
||||
...(session.archived
|
||||
? // No Unarchive when the workspace is gone — it can't persist
|
||||
// (putSession guard) and reconcile would re-archive it.
|
||||
isUnavailable
|
||||
? []
|
||||
: [
|
||||
{
|
||||
displayName: 'Unarchive',
|
||||
icon: ArchiveRestore,
|
||||
action: () => setSessionArchived(session.id, false)
|
||||
}
|
||||
]
|
||||
: [
|
||||
{
|
||||
displayName: 'Archive',
|
||||
icon: Archive,
|
||||
action: () => archiveAndReset()
|
||||
}
|
||||
]),
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: Trash2,
|
||||
type: 'delete',
|
||||
action: () => (deleteConfirmOpen = true)
|
||||
}
|
||||
]}
|
||||
>
|
||||
{#snippet buttonReplacement()}
|
||||
<span
|
||||
class="inline-flex items-center justify-center w-5 h-5 rounded text-tertiary hover:bg-surface-hover hover:text-primary"
|
||||
title="More"
|
||||
>
|
||||
<EllipsisVertical size={14} />
|
||||
</span>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
|
||||
{#if actingIsFork}
|
||||
<!-- Only a fork earns this: on the root the breadcrumb already names the workspace the
|
||||
chat writes to, and repeating it says nothing. The fork is the case worth calling
|
||||
out, and it belongs with the session's name rather than in the breadcrumb, where it
|
||||
would read as if the user had switched workspace. -->
|
||||
<span class="shrink-0 text-2xs text-tertiary pl-1">Acting on</span>
|
||||
<WorkspaceScopeTrigger
|
||||
workspaceId={actingWorkspaceId}
|
||||
showChevron={false}
|
||||
interactive={false}
|
||||
class="min-w-0"
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#if headerInPage}
|
||||
<!-- The chat's own header row is the page header on this route: the session's name is its
|
||||
breadcrumb, and the menu is its action. -->
|
||||
<PageHeaderContent
|
||||
section={{ label: session.summary ?? 'Untitled session', content: sessionHeader }}
|
||||
{actingWorkspaceId}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- The wrapper contributes only the chat column; edited items are shown in the
|
||||
page's preview tabs (PreviewTabHost) beside it, not a second pane here. The
|
||||
single Pane fills 100% (no explicit `size`, so Splitpanes auto-distributes). -->
|
||||
<div class="flex-1 min-h-0 flex flex-col" use:splitterPointerCapture>
|
||||
<Splitpanes horizontal={false} class="flex-1 min-h-0 splitter-hidden">
|
||||
<Pane minSize={25} class="flex flex-col min-h-0 pb-2">
|
||||
<header
|
||||
class="flex flex-row items-center gap-1 {headerInset
|
||||
? 'pl-11'
|
||||
: 'pl-4'} pr-4 py-2 shrink-0"
|
||||
>
|
||||
<EditableInput
|
||||
bind:this={summaryInput}
|
||||
value={session.summary ?? ''}
|
||||
placeholder="Untitled session"
|
||||
onSave={(v) => renameSession(session.id, v)}
|
||||
class="text-sm font-semibold"
|
||||
inputClass="!text-sm !font-semibold"
|
||||
/>
|
||||
<DropdownV2
|
||||
fixedHeight={false}
|
||||
placement="bottom-start"
|
||||
enableFlyTransition
|
||||
items={[
|
||||
{
|
||||
displayName: 'Rename',
|
||||
icon: Pencil,
|
||||
action: () => summaryInput?.edit()
|
||||
},
|
||||
...(session.archived
|
||||
? // No Unarchive when the workspace is gone — it can't persist
|
||||
// (putSession guard) and reconcile would re-archive it.
|
||||
isUnavailable
|
||||
? []
|
||||
: [
|
||||
{
|
||||
displayName: 'Unarchive',
|
||||
icon: ArchiveRestore,
|
||||
action: () => setSessionArchived(session.id, false)
|
||||
}
|
||||
]
|
||||
: [
|
||||
{
|
||||
displayName: 'Archive',
|
||||
icon: Archive,
|
||||
action: () => archiveAndReset()
|
||||
}
|
||||
]),
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: Trash2,
|
||||
type: 'delete',
|
||||
action: () => (deleteConfirmOpen = true)
|
||||
}
|
||||
]}
|
||||
>
|
||||
{#snippet buttonReplacement()}
|
||||
<span
|
||||
class="inline-flex items-center justify-center w-5 h-5 rounded text-tertiary hover:bg-surface-hover hover:text-primary"
|
||||
title="More"
|
||||
>
|
||||
<EllipsisVertical size={14} />
|
||||
</span>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
{#if acting && hasFirstUserMessage}
|
||||
<!-- "Acting on" context: workspace root (avatar + name) and, when the
|
||||
session runs in a fork, the fork name (accent pill) — with a button
|
||||
to jump into that workspace. Compact; sits right after the title.
|
||||
Hidden until the session has started — a new (un-sent) session shows
|
||||
the "Run in" picker (SessionWorkspaceBar) instead. -->
|
||||
<div class="flex items-center gap-1 min-w-0 text-2xs text-tertiary">
|
||||
<span class="shrink-0">Acting on</span>
|
||||
<!-- Hover reveals the workspace name + id + copy button (shared
|
||||
NameIdTooltip, same as the sidebar family picker), so the chip
|
||||
carries the copy affordance without an inline button. -->
|
||||
<NameIdTooltip name={acting.name} id={acting.targetId}>
|
||||
<WorkspaceScopeTrigger
|
||||
workspaceId={acting.targetId}
|
||||
showChevron={false}
|
||||
interactive={false}
|
||||
disableTitle
|
||||
class="max-w-[16rem]"
|
||||
menuItems={actingMenu}
|
||||
/>
|
||||
</NameIdTooltip>
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
<div class="flex-1 min-h-0 w-full flex flex-col {hasFirstUserMessage ? '' : 'pt-8'}">
|
||||
<AIChat
|
||||
bind:this={aiChat}
|
||||
|
||||
@@ -54,6 +54,8 @@
|
||||
// Classes for the label line only — `class` reaches the button, the label and the
|
||||
// sublabel alike, which is the wrong tool for colouring one line of the two.
|
||||
labelClass?: string | undefined
|
||||
// Draw the colour disc at 20px rather than 26px, for denser rows such as the page header.
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -79,7 +81,8 @@
|
||||
emphasizeLabel = false,
|
||||
disableTitle = false,
|
||||
ariaLabel = undefined,
|
||||
labelClass = undefined
|
||||
labelClass = undefined,
|
||||
compact = false
|
||||
}: Props = $props()
|
||||
|
||||
let buttonRef: HTMLButtonElement | HTMLAnchorElement | undefined = $state(undefined)
|
||||
@@ -94,7 +97,7 @@
|
||||
<Popover
|
||||
appearTimeout={0}
|
||||
disappearTimeout={0}
|
||||
class="w-full"
|
||||
class={compact ? 'w-auto' : 'w-full'}
|
||||
disablePopup={!isCollapsed}
|
||||
placement="right"
|
||||
>
|
||||
@@ -119,7 +122,8 @@
|
||||
{href}
|
||||
data-light-mode={lightMode}
|
||||
class={twMerge(
|
||||
'group flex items-center px-2 py-2 font-light rounded-md gap-2 w-full',
|
||||
'group flex items-center px-2 py-2 font-light rounded-md gap-2',
|
||||
compact ? 'w-auto' : 'w-full',
|
||||
sidebarClasses.hoverBg,
|
||||
'transition-all relative',
|
||||
sublabel ? 'h-10' : 'h-8',
|
||||
@@ -134,11 +138,23 @@
|
||||
{#if icon}
|
||||
{@const SvelteComponent = icon}
|
||||
{#if color}
|
||||
<svg width="26" height="26" viewBox="0 0 26 26" class="flex-shrink-0 -ml-[5px]">
|
||||
<circle cx="13" cy="13" r="13" fill={color} />
|
||||
<foreignObject x="5" y="5" width="16" height="16">
|
||||
{@const disc = compact ? 20 : 26}
|
||||
{@const glyph = compact ? 12 : 16}
|
||||
<svg
|
||||
width={disc}
|
||||
height={disc}
|
||||
viewBox="0 0 {disc} {disc}"
|
||||
class="flex-shrink-0 {compact ? '-ml-[3px]' : '-ml-[5px]'}"
|
||||
>
|
||||
<circle cx={disc / 2} cy={disc / 2} r={disc / 2} fill={color} />
|
||||
<foreignObject
|
||||
x={(disc - glyph) / 2}
|
||||
y={(disc - glyph) / 2}
|
||||
width={glyph}
|
||||
height={glyph}
|
||||
>
|
||||
<SvelteComponent
|
||||
size={16}
|
||||
size={glyph}
|
||||
class={twMerge(sidebarClasses.iconText, 'transition-colors', iconClasses)}
|
||||
{...iconProps}
|
||||
/>
|
||||
@@ -158,7 +174,7 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col text-left grow min-w-0">
|
||||
<div class="flex flex-col text-left min-w-0 {compact ? '' : 'grow'}">
|
||||
{#if !isCollapsed && label}
|
||||
<div
|
||||
class={twMerge(
|
||||
|
||||
@@ -1,337 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Home,
|
||||
Menu as MenuIcon,
|
||||
Play,
|
||||
Settings,
|
||||
LogOut,
|
||||
Moon,
|
||||
Sun,
|
||||
Code2,
|
||||
LayoutDashboard,
|
||||
Calendar,
|
||||
ServerCog,
|
||||
Table2,
|
||||
GraduationCap
|
||||
} from 'lucide-svelte'
|
||||
import SwitchWorkspaceSubmenu from './SwitchWorkspaceSubmenu.svelte'
|
||||
import {
|
||||
OPERATOR_MAIN_LINKS,
|
||||
OPERATOR_SECONDARY_LINKS,
|
||||
OPERATOR_TRIGGER_LINKS,
|
||||
type OperatorMenuLink,
|
||||
type OperatorTriggerLink
|
||||
} from './operatorRoutes'
|
||||
import { base } from '$lib/base'
|
||||
import { page } from '$app/state'
|
||||
import { TOUR_PARAM, TOUR_PARAM_VALUE } from '$lib/components/tutorials/operatorTour'
|
||||
|
||||
import MultiplayerMenu from './MultiplayerMenu.svelte'
|
||||
import { Plus } from 'lucide-svelte'
|
||||
import {
|
||||
enterpriseLicense,
|
||||
superadmin,
|
||||
usedTriggerKinds,
|
||||
userWorkspaces,
|
||||
workspaceColor,
|
||||
workspaceStore
|
||||
} from '$lib/stores'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { getContrastTextColor } from '$lib/utils'
|
||||
import { USER_SETTINGS_HASH } from './settings'
|
||||
import { logout } from '$lib/logoutKit'
|
||||
import DarkModeObserver from '../DarkModeObserver.svelte'
|
||||
import BarsStaggered from '../icons/BarsStaggered.svelte'
|
||||
import { Menu, Menubar, MenuItem } from '$lib/components/meltComponents'
|
||||
import MenuButton, { sidebarClasses } from './MenuButton.svelte'
|
||||
import MenuLink from './MenuLink.svelte'
|
||||
import type { FavoriteKind } from './FavoriteMenu.svelte'
|
||||
let darkMode: boolean = $state(false)
|
||||
let showExtraTriggers = $state(false)
|
||||
// The menu opens on hover, so this doubles as "the pointer is on the button":
|
||||
// it slides the workspace name out beside the icon, and keeps it out once a
|
||||
// click has pinned the menu open and the pointer has moved away.
|
||||
let menuOpen = $state(false)
|
||||
|
||||
interface Props {
|
||||
isCollapsed?: boolean
|
||||
favoriteLinks?: {
|
||||
label: string
|
||||
href: string
|
||||
kind: FavoriteKind
|
||||
}[]
|
||||
}
|
||||
|
||||
let { isCollapsed = false, favoriteLinks = [] }: Props = $props()
|
||||
|
||||
const MAIN_LINK_ICONS: Record<string, any> = { home: Home, runs: Play, schedules: Calendar }
|
||||
let activeWorkspace = $derived($userWorkspaces?.find((w) => w.id === $workspaceStore))
|
||||
let workspaceName = $derived(activeWorkspace?.name ?? $workspaceStore ?? '')
|
||||
// Home is where an operator lands and gets their bearings, so the name stays out there
|
||||
// rather than waiting for a hover. Everywhere else the button is the page's own chrome
|
||||
// and stays out of the way until asked.
|
||||
let showWorkspaceName = $derived(menuOpen || page.url.pathname === `${base}/`)
|
||||
// Most workspaces never get a colour (it is only ever set explicitly), and this button
|
||||
// floats over page content with nothing else behind it — so the disc falls back to a
|
||||
// neutral rather than vanishing, keeping the glyph on a ground of its own everywhere.
|
||||
// A token rather than a hex so it follows the theme; getContrastTextColor only parses
|
||||
// hex, so it returns undefined here and the glyph keeps its themed `text-hint`.
|
||||
const NEUTRAL_DISC = 'rgb(var(--color-surface-sunken))'
|
||||
|
||||
let mainMenuLinks = $derived(
|
||||
OPERATOR_MAIN_LINKS.map((link) => ({ ...link, icon: MAIN_LINK_ICONS[link.id] })).filter(
|
||||
(link) => link.id === 'home' || filterLink(link)
|
||||
)
|
||||
)
|
||||
|
||||
function filterLink(link: OperatorMenuLink) {
|
||||
if (!$userWorkspaces || !$workspaceStore) return false
|
||||
return activeWorkspace?.operator_settings?.[link.id] === true
|
||||
}
|
||||
let secondMenuLinks: OperatorMenuLink[] = $derived(OPERATOR_SECONDARY_LINKS.filter(filterLink))
|
||||
let allTriggerLinks: OperatorTriggerLink[] = $derived(OPERATOR_TRIGGER_LINKS.filter(filterLink))
|
||||
let secondMenuTriggerLinks = $derived(
|
||||
allTriggerLinks.filter((link) => $usedTriggerKinds.includes(link.kind))
|
||||
)
|
||||
let extraTriggerLinks = $derived(
|
||||
allTriggerLinks.filter((link) => !$usedTriggerKinds.includes(link.kind))
|
||||
)
|
||||
</script>
|
||||
|
||||
<Menubar>
|
||||
{#snippet children({ createMenu })}
|
||||
<Menu
|
||||
{createMenu}
|
||||
placement="bottom-start"
|
||||
openOnHover
|
||||
usePointerDownOutside
|
||||
submenuSafe
|
||||
bind:open={menuOpen}
|
||||
on:close={() => (showExtraTriggers = false)}
|
||||
>
|
||||
{#snippet triggr({ trigger, pinned })}
|
||||
{@const iconColor = getContrastTextColor($workspaceColor)}
|
||||
<!-- Ground for the expanded state only: the button floats over page content and its
|
||||
hover tint is translucent, so the name would otherwise sit on whatever is
|
||||
underneath. Icon-only needs none — the disc is always drawn. -->
|
||||
<div class="flex rounded-md {showWorkspaceName ? 'bg-surface' : ''}">
|
||||
<MenuButton
|
||||
class="!text-xs"
|
||||
buttonClass={twMerge(
|
||||
'!pl-3.5 !pr-2 !w-auto',
|
||||
// A hover-opened menu leaves the button plain once the pointer moves on;
|
||||
// keeping the tint is what tells you the click pinned it.
|
||||
pinned ? sidebarClasses.selectedBg : ''
|
||||
)}
|
||||
icon={MenuIcon}
|
||||
isCollapsed={false}
|
||||
lightMode
|
||||
color={$workspaceColor ?? NEUTRAL_DISC}
|
||||
iconProps={iconColor ? { style: `color: ${iconColor}` } : undefined}
|
||||
label={showWorkspaceName ? workspaceName : undefined}
|
||||
ariaLabel={workspaceName ? `Menu — ${workspaceName}` : 'Menu'}
|
||||
{trigger}
|
||||
/>
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet children({ item, builders })}
|
||||
<div class="w-full max-w-full">
|
||||
{#each favoriteLinks ?? [] as favorite (favorite.href)}
|
||||
<MenuItem
|
||||
href={favorite.href}
|
||||
{item}
|
||||
class={twMerge(
|
||||
'w-full inline-flex flex-row px-2 py-2',
|
||||
'data-[highlighted]:bg-surface-hover'
|
||||
)}
|
||||
>
|
||||
<span class="center-center">
|
||||
{#if favorite.kind == 'script'}
|
||||
<Code2 size={16} />
|
||||
{:else if favorite.kind == 'flow'}
|
||||
<BarsStaggered size={16} />
|
||||
{:else if favorite.kind == 'app' || favorite.kind == 'raw_app'}
|
||||
<LayoutDashboard size={16} />
|
||||
{:else if favorite.kind == 'asset'}
|
||||
<Table2 size={16} />
|
||||
{/if}
|
||||
</span>
|
||||
<span class="text-primary ml-2 grow min-w-0 text-xs truncate">
|
||||
{favorite.label}
|
||||
</span>
|
||||
</MenuItem>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#each mainMenuLinks as menuLink (menuLink.href ?? menuLink.label)}
|
||||
<MenuLink class="!text-xs" {...menuLink} {isCollapsed} {item} lightMode />
|
||||
{/each}
|
||||
|
||||
<div class="divide-y" role="none">
|
||||
<div role="none">
|
||||
<MenuItem
|
||||
href={USER_SETTINGS_HASH}
|
||||
class={twMerge(
|
||||
'flex flex-row gap-3.5 items-center px-2 py-2',
|
||||
sidebarClasses.text,
|
||||
'transition-colors',
|
||||
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
|
||||
)}
|
||||
lightMode
|
||||
{item}
|
||||
>
|
||||
<Settings size={14} />
|
||||
Account settings
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem
|
||||
href="{base}/?{TOUR_PARAM}={TOUR_PARAM_VALUE}"
|
||||
class={twMerge(
|
||||
'flex flex-row gap-3.5 items-center px-2 py-2',
|
||||
sidebarClasses.text,
|
||||
'transition-colors',
|
||||
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
|
||||
)}
|
||||
lightMode
|
||||
{item}
|
||||
>
|
||||
<GraduationCap size={14} />
|
||||
Take the tour
|
||||
</MenuItem>
|
||||
</div>
|
||||
|
||||
<div role="none">
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
if (!document.documentElement.classList.contains('dark')) {
|
||||
document.documentElement.classList.add('dark')
|
||||
window.localStorage.setItem('dark-mode', 'dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
window.localStorage.setItem('dark-mode', 'light')
|
||||
}
|
||||
}}
|
||||
lightMode
|
||||
class={twMerge(
|
||||
'w-full flex gap-3.5 px-2 py-2',
|
||||
'transition-colors',
|
||||
sidebarClasses.text,
|
||||
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
|
||||
)}
|
||||
{item}
|
||||
>
|
||||
{#if darkMode}
|
||||
<Sun size={14} />
|
||||
{:else}
|
||||
<Moon size={14} />
|
||||
{/if}
|
||||
Switch theme
|
||||
</MenuItem>
|
||||
<SwitchWorkspaceSubmenu {builders} {item} />
|
||||
|
||||
{#if $superadmin}
|
||||
<MenuItem
|
||||
href="#superadmin-settings"
|
||||
class={twMerge(
|
||||
'flex flex-row gap-3.5 items-center px-2 py-2 ',
|
||||
'text-secondary text-xs',
|
||||
'cursor-pointer',
|
||||
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
|
||||
)}
|
||||
{item}
|
||||
>
|
||||
<ServerCog size={14} />
|
||||
Instance settings
|
||||
</MenuItem>
|
||||
{/if}
|
||||
|
||||
<MenuItem
|
||||
onClick={() => logout()}
|
||||
class={twMerge(
|
||||
'flex flex-row gap-3.5 items-center px-2 py-2 w-full',
|
||||
'text-primary text-xs',
|
||||
'cursor-pointer',
|
||||
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
|
||||
)}
|
||||
{item}
|
||||
>
|
||||
<LogOut size={14} />
|
||||
Sign out
|
||||
</MenuItem>
|
||||
</div>
|
||||
<div role="none">
|
||||
{#snippet renderSecondMenuLinks(menuLinks: OperatorMenuLink[])}
|
||||
{#each menuLinks as menuLink (menuLink.href ?? menuLink.label)}
|
||||
<MenuItem
|
||||
href={menuLink.href}
|
||||
class={twMerge(
|
||||
'flex flex-row gap-3.5 items-center px-2 py-2 text-secondary text-2xs cursor-pointer',
|
||||
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
|
||||
)}
|
||||
{item}
|
||||
>
|
||||
{menuLink.label}
|
||||
</MenuItem>
|
||||
{/each}
|
||||
{/snippet}
|
||||
{#if secondMenuLinks.length || secondMenuTriggerLinks.length || extraTriggerLinks.length}
|
||||
<div class="divide-y">
|
||||
{#if secondMenuLinks.length}<div
|
||||
>{@render renderSecondMenuLinks(secondMenuLinks)}</div
|
||||
>{/if}
|
||||
{#if secondMenuTriggerLinks.length}<div
|
||||
>{@render renderSecondMenuLinks(secondMenuTriggerLinks)}</div
|
||||
>{/if}
|
||||
{#if extraTriggerLinks.length}<div>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
role="none"
|
||||
onclickcapture={(e) => {
|
||||
// This row expands the list below it instead of acting on the selection, and
|
||||
// melt keeps the menu open only for a click it sees as defaultPrevented.
|
||||
// Svelte delegates onclick to the root, which runs after melt's own listener,
|
||||
// and a capture listener on the item itself would be ordered only by
|
||||
// registration, so an ancestor's capture phase is what reliably wins.
|
||||
e.preventDefault()
|
||||
showExtraTriggers = !showExtraTriggers
|
||||
}}
|
||||
>
|
||||
<MenuItem
|
||||
class={twMerge(
|
||||
'flex flex-row gap-3.5 items-center px-2 py-2 w-full text-secondary text-2xs cursor-pointer',
|
||||
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
|
||||
)}
|
||||
{item}
|
||||
>
|
||||
<Plus size={12} />
|
||||
<span class="text-2xs">More triggers</span>
|
||||
</MenuItem>
|
||||
</div>
|
||||
{#if showExtraTriggers}
|
||||
{#each extraTriggerLinks as menuLink (menuLink.href)}
|
||||
<MenuItem
|
||||
href={menuLink.href}
|
||||
class={twMerge(
|
||||
'flex flex-row gap-3.5 items-center px-2 py-2 pl-6 text-tertiary text-2xs cursor-pointer',
|
||||
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
|
||||
)}
|
||||
{item}
|
||||
>
|
||||
{menuLink.label}
|
||||
</MenuItem>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if $enterpriseLicense}
|
||||
<MultiplayerMenu />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Menu>
|
||||
{/snippet}
|
||||
</Menubar>
|
||||
|
||||
<DarkModeObserver bind:darkMode />
|
||||
@@ -18,7 +18,8 @@
|
||||
Crown,
|
||||
Gauge,
|
||||
Trash2,
|
||||
KeyRound
|
||||
KeyRound,
|
||||
GraduationCap
|
||||
} from 'lucide-svelte'
|
||||
import { base } from '$app/paths'
|
||||
import { goto } from '$lib/navigation'
|
||||
@@ -38,6 +39,8 @@
|
||||
import { USER_SETTINGS_HASH, SUPERADMIN_SETTINGS_HASH } from './settings'
|
||||
import { accountSetup } from './accountSetup.svelte'
|
||||
import { EXECUTIONS_HINT } from './executionsHint'
|
||||
import { sidebarPageAllowed } from './operatorRoutes'
|
||||
import { TOUR_PARAM, TOUR_PARAM_VALUE } from '$lib/components/tutorials/operatorTour'
|
||||
import {
|
||||
userWorkspaces,
|
||||
workspaceStore,
|
||||
@@ -248,6 +251,15 @@
|
||||
icon: Settings,
|
||||
action: () => goto(USER_SETTINGS_HASH)
|
||||
},
|
||||
...($userStore?.operator
|
||||
? [
|
||||
{
|
||||
displayName: 'Take the tour',
|
||||
icon: GraduationCap,
|
||||
action: () => goto(`${base}/?${TOUR_PARAM}=${TOUR_PARAM_VALUE}`)
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...(cloudHosted && $isPremiumStore === false
|
||||
? [
|
||||
{
|
||||
@@ -307,6 +319,7 @@
|
||||
label="Workers"
|
||||
href="{base}/workers"
|
||||
icon={ServerCog}
|
||||
disabled={!sidebarPageAllowed($userStore?.operator, currentWs, 'workers')}
|
||||
{isCollapsed}
|
||||
aiId="sidebar-menu-link-workers"
|
||||
aiDescription="Button to navigate to workers"
|
||||
@@ -352,7 +365,7 @@
|
||||
label="Audit logs"
|
||||
href="{base}/audit_logs"
|
||||
icon={Eye}
|
||||
disabled={$userStore?.operator}
|
||||
disabled={!sidebarPageAllowed($userStore?.operator, currentWs, 'audit_logs')}
|
||||
{isCollapsed}
|
||||
aiId="sidebar-menu-link-audit-logs"
|
||||
aiDescription="Button to navigate to audit logs"
|
||||
|
||||
@@ -12,31 +12,25 @@
|
||||
} from '$lib/stores'
|
||||
import { isForkOwner } from '$lib/utils/workspaceHierarchy'
|
||||
import { SIDEBAR_SHOW_SCHEDULES } from '$lib/consts'
|
||||
import { NAV_PAGES } from './navPages'
|
||||
import {
|
||||
BookOpen,
|
||||
ServerCog,
|
||||
Boxes,
|
||||
Calendar,
|
||||
DollarSign,
|
||||
Eye,
|
||||
Logs,
|
||||
FolderCog,
|
||||
FolderOpen,
|
||||
Github,
|
||||
HelpCircle,
|
||||
Home,
|
||||
LogOut,
|
||||
Newspaper,
|
||||
Play,
|
||||
Route,
|
||||
Settings,
|
||||
UserCog,
|
||||
Users,
|
||||
Plus,
|
||||
Unplug,
|
||||
AlertCircle,
|
||||
Database,
|
||||
Pyramid,
|
||||
MailIcon,
|
||||
ChevronDown,
|
||||
ChevronRight
|
||||
@@ -72,6 +66,7 @@
|
||||
import GoogleCloudIcon from '../icons/triggers/GoogleCloudIcon.svelte'
|
||||
import AzureIcon from '../icons/triggers/AzureIcon.svelte'
|
||||
import { leaveCurrentWorkspace } from './leaveWorkspace'
|
||||
import { sidebarPageAllowed, type OperatorPageKey } from './operatorRoutes'
|
||||
import { markChangelogsOpened, readRecentChangelogs } from './changelogs'
|
||||
|
||||
const { recent: recentChangelogs, hasNew } = readRecentChangelogs()
|
||||
@@ -188,23 +183,24 @@
|
||||
return count
|
||||
}
|
||||
|
||||
const currentWs = $derived($userWorkspaces?.find((w) => w.id === $workspaceStore))
|
||||
function allows(key: OperatorPageKey) {
|
||||
return sidebarPageAllowed($userStore?.operator, currentWs, key)
|
||||
}
|
||||
|
||||
const itemClass = twMerge(
|
||||
'text-secondary font-normal w-full block px-4 py-2 text-2xs data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
|
||||
)
|
||||
let mainMenuLinks = $derived(
|
||||
[
|
||||
{
|
||||
label: 'Home',
|
||||
href: `${base}/`,
|
||||
icon: Home,
|
||||
// Labels, paths and icons come from the shared page table, so the nav and the page header's
|
||||
// breadcrumb always call a page the same thing. Everything else here is nav-only.
|
||||
const MAIN_MENU_EXTRAS: Record<string, { aiId: string; aiDescription: string; onclick?: () => void }> =
|
||||
{
|
||||
Home: {
|
||||
aiId: 'sidebar-menu-link-home',
|
||||
aiDescription:
|
||||
"Button to navigate to home which contains all the user's scripts, flows and apps"
|
||||
},
|
||||
{
|
||||
label: 'Runs',
|
||||
href: `${base}/runs`,
|
||||
icon: Play,
|
||||
Runs: {
|
||||
aiId: 'sidebar-menu-link-runs',
|
||||
aiDescription: 'Button to navigate to runs',
|
||||
onclick: () => {
|
||||
@@ -213,53 +209,35 @@
|
||||
}, 100)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Variables',
|
||||
href: `${base}/variables`,
|
||||
icon: DollarSign,
|
||||
disabled: $userStore?.operator,
|
||||
Variables: {
|
||||
aiId: 'sidebar-menu-link-variables',
|
||||
aiDescription: 'Button to navigate to variables'
|
||||
},
|
||||
{
|
||||
label: 'Resources',
|
||||
href: `${base}/resources`,
|
||||
icon: Boxes,
|
||||
disabled: $userStore?.operator,
|
||||
Resources: {
|
||||
aiId: 'sidebar-menu-link-resources',
|
||||
aiDescription: 'Button to navigate to resources'
|
||||
},
|
||||
{
|
||||
label: 'Assets',
|
||||
href: `${base}/assets`,
|
||||
icon: Pyramid,
|
||||
aiId: 'sidebar-menu-link-assets',
|
||||
aiDescription: 'Button to navigate to assets'
|
||||
},
|
||||
{
|
||||
label: 'Folders',
|
||||
href: `${base}/folders`,
|
||||
icon: FolderOpen,
|
||||
disabled: $userStore?.operator,
|
||||
aiId: 'sidebar-menu-link-folders',
|
||||
aiDescription: 'Button to navigate to folders'
|
||||
},
|
||||
{
|
||||
label: 'Groups',
|
||||
href: `${base}/groups`,
|
||||
icon: Users,
|
||||
disabled: $userStore?.operator,
|
||||
aiId: 'sidebar-menu-link-groups',
|
||||
aiDescription: 'Button to navigate to groups'
|
||||
}
|
||||
].filter((l) => !excludeMainLabels.includes(l.label))
|
||||
Assets: { aiId: 'sidebar-menu-link-assets', aiDescription: 'Button to navigate to assets' },
|
||||
Folders: { aiId: 'sidebar-menu-link-folders', aiDescription: 'Button to navigate to folders' },
|
||||
Groups: { aiId: 'sidebar-menu-link-groups', aiDescription: 'Button to navigate to groups' }
|
||||
}
|
||||
let mainMenuLinks = $derived(
|
||||
NAV_PAGES.filter((p) => p.label in MAIN_MENU_EXTRAS)
|
||||
.map((p) => ({
|
||||
label: p.label,
|
||||
href: p.path === '/' ? `${base}/` : `${base}${p.path}`,
|
||||
icon: p.icon,
|
||||
disabled: p.operatorKey ? !allows(p.operatorKey) : false,
|
||||
...MAIN_MENU_EXTRAS[p.label]
|
||||
}))
|
||||
.filter((l) => !excludeMainLabels.includes(l.label))
|
||||
)
|
||||
let defaultExtraTriggerLinks = $derived([
|
||||
{
|
||||
label: 'HTTP',
|
||||
href: '/routes',
|
||||
icon: Route,
|
||||
disabled: $userStore?.operator,
|
||||
disabled: !allows('triggers'),
|
||||
kind: 'http',
|
||||
aiId: 'sidebar-menu-link-http',
|
||||
aiDescription: 'Button to navigate to HTTP routes'
|
||||
@@ -268,7 +246,7 @@
|
||||
label: 'WebSockets',
|
||||
href: '/websocket_triggers',
|
||||
icon: Unplug,
|
||||
disabled: $userStore?.operator,
|
||||
disabled: !allows('triggers'),
|
||||
kind: 'ws',
|
||||
aiId: 'sidebar-menu-link-ws',
|
||||
aiDescription: 'Button to navigate to websocket triggers'
|
||||
@@ -277,7 +255,7 @@
|
||||
label: 'Postgres',
|
||||
href: '/postgres_triggers',
|
||||
icon: Database,
|
||||
disabled: $userStore?.operator,
|
||||
disabled: !allows('triggers'),
|
||||
kind: 'postgres',
|
||||
aiId: 'sidebar-menu-link-postgres',
|
||||
aiDescription: 'Button to navigate to Postgres triggers'
|
||||
@@ -286,7 +264,7 @@
|
||||
label: 'Kafka' + ($enterpriseLicense ? '' : ' (EE)'),
|
||||
href: '/kafka_triggers',
|
||||
icon: KafkaIcon,
|
||||
disabled: $userStore?.operator || !$enterpriseLicense,
|
||||
disabled: !allows('triggers') || !$enterpriseLicense,
|
||||
kind: 'kafka',
|
||||
aiId: 'sidebar-menu-link-kafka',
|
||||
aiDescription: 'Button to navigate to Kafka triggers'
|
||||
@@ -295,7 +273,7 @@
|
||||
label: 'NATS' + ($enterpriseLicense ? '' : ' (EE)'),
|
||||
href: '/nats_triggers',
|
||||
icon: NatsIcon,
|
||||
disabled: $userStore?.operator || !$enterpriseLicense,
|
||||
disabled: !allows('triggers') || !$enterpriseLicense,
|
||||
kind: 'nats',
|
||||
aiId: 'sidebar-menu-link-nats',
|
||||
aiDescription: 'Button to navigate to NATS triggers'
|
||||
@@ -304,7 +282,7 @@
|
||||
label: 'SQS' + ($enterpriseLicense ? '' : ' (EE)'),
|
||||
href: '/sqs_triggers',
|
||||
icon: AwsIcon,
|
||||
disabled: $userStore?.operator || !$enterpriseLicense,
|
||||
disabled: !allows('triggers') || !$enterpriseLicense,
|
||||
kind: 'sqs',
|
||||
aiId: 'sidebar-menu-link-sqs',
|
||||
aiDescription: 'Button to navigate to SQS triggers'
|
||||
@@ -313,7 +291,7 @@
|
||||
label: 'GCP Pub/Sub' + ($enterpriseLicense ? '' : ' (EE)'),
|
||||
href: '/gcp_triggers',
|
||||
icon: GoogleCloudIcon,
|
||||
disabled: $userStore?.operator || !$enterpriseLicense,
|
||||
disabled: !allows('triggers') || !$enterpriseLicense,
|
||||
kind: 'gcp',
|
||||
aiId: 'sidebar-menu-link-gcp',
|
||||
aiDescription: 'Button to navigate to GCP Pub/Sub triggers'
|
||||
@@ -322,7 +300,7 @@
|
||||
label: 'Azure Event Grid' + ($enterpriseLicense ? '' : ' (EE)'),
|
||||
href: '/azure_triggers',
|
||||
icon: AzureIcon,
|
||||
disabled: $userStore?.operator || !$enterpriseLicense,
|
||||
disabled: !allows('triggers') || !$enterpriseLicense,
|
||||
kind: 'azure',
|
||||
aiId: 'sidebar-menu-link-azure',
|
||||
aiDescription: 'Button to navigate to Azure Event Grid triggers'
|
||||
@@ -331,7 +309,7 @@
|
||||
label: 'MQTT',
|
||||
href: '/mqtt_triggers',
|
||||
icon: MqttIcon,
|
||||
disabled: $userStore?.operator,
|
||||
disabled: !allows('triggers'),
|
||||
kind: 'mqtt',
|
||||
aiId: 'sidebar-menu-link-mqtt',
|
||||
aiDescription: 'Button to navigate to MQTT triggers'
|
||||
@@ -340,7 +318,7 @@
|
||||
label: 'AMQP',
|
||||
href: '/amqp_triggers',
|
||||
icon: AmqpIcon,
|
||||
disabled: $userStore?.operator,
|
||||
disabled: !allows('triggers'),
|
||||
kind: 'amqp',
|
||||
aiId: 'sidebar-menu-link-amqp',
|
||||
aiDescription: 'Button to navigate to AMQP triggers'
|
||||
@@ -349,7 +327,7 @@
|
||||
label: 'Email',
|
||||
href: '/email_triggers',
|
||||
icon: MailIcon,
|
||||
disabled: $userStore?.operator,
|
||||
disabled: !allows('triggers'),
|
||||
kind: 'email',
|
||||
aiId: 'sidebar-menu-link-email',
|
||||
aiDescription: 'Button to navigate to Email triggers'
|
||||
@@ -375,7 +353,7 @@
|
||||
label: 'Schedules',
|
||||
href: `${base}/schedules`,
|
||||
icon: Calendar,
|
||||
disabled: !SIDEBAR_SHOW_SCHEDULES || $userStore?.operator,
|
||||
disabled: !SIDEBAR_SHOW_SCHEDULES || !allows('schedules'),
|
||||
aiId: 'sidebar-menu-link-schedules',
|
||||
aiDescription: 'Button to navigate to schedules'
|
||||
},
|
||||
@@ -390,7 +368,6 @@
|
||||
)
|
||||
// Admins, superadmins, and fork creators reach workspace settings (the latter
|
||||
// for the fork members screen; see isForkOwner / backend authorize_fork_owner_add_user).
|
||||
const currentWs = $derived($userWorkspaces?.find((w) => w.id === $workspaceStore))
|
||||
const canManageWorkspace = $derived(
|
||||
$userStore?.is_admin || $superadmin || isForkOwner(currentWs, $userStore?.email)
|
||||
)
|
||||
@@ -460,7 +437,7 @@
|
||||
label: 'Workers',
|
||||
href: `${base}/workers`,
|
||||
icon: ServerCog,
|
||||
disabled: $userStore?.operator,
|
||||
disabled: !allows('workers'),
|
||||
aiId: 'sidebar-menu-link-workers',
|
||||
aiDescription: 'Button to navigate to workers'
|
||||
},
|
||||
@@ -509,7 +486,7 @@
|
||||
label: 'Audit logs',
|
||||
href: `${base}/audit_logs`,
|
||||
icon: Eye,
|
||||
disabled: $userStore?.operator,
|
||||
disabled: !allows('audit_logs'),
|
||||
aiId: 'sidebar-menu-link-audit-logs',
|
||||
aiDescription: 'Button to navigate to audit logs'
|
||||
}
|
||||
@@ -551,7 +528,7 @@
|
||||
{#each triggerMenuLinks as menuLink (menuLink.href ?? menuLink.label)}
|
||||
<MenuLink class="!text-xs" {...menuLink} {isCollapsed} />
|
||||
{/each}
|
||||
{#if extraTriggerLinks.length > 0 && !$userStore?.operator}
|
||||
{#if extraTriggerLinks.length > 0 && allows('triggers')}
|
||||
<Menu {createMenu} usePointerDownOutside>
|
||||
{#snippet triggr({ trigger })}
|
||||
<MeltButton
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte'
|
||||
import { melt } from '@melt-ui/svelte'
|
||||
import type { MenubarMenuBuilders, MenubarMenuElements } from '@melt-ui/svelte'
|
||||
import { Building, ChevronRight } from 'lucide-svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { sidebarClasses } from './MenuButton.svelte'
|
||||
import WorkspacePickerBody from './WorkspacePickerBody.svelte'
|
||||
import { MenuItem } from '$lib/components/meltComponents'
|
||||
import { clearWorkspaceFromStorage } from '$lib/stores'
|
||||
import { base } from '$lib/base'
|
||||
|
||||
interface Props {
|
||||
// Submenu builders from the enclosing melt Menu — createSubmenu must be
|
||||
// called against this specific menu instance (same pattern as DropdownSubmenuItem).
|
||||
builders: MenubarMenuBuilders
|
||||
// Melt uses the parent menu's item builder for submenu rows too, so the
|
||||
// picker body's MenuItems work here unchanged.
|
||||
item: MenubarMenuElements['item']
|
||||
}
|
||||
|
||||
let { builders, item }: Props = $props()
|
||||
|
||||
const {
|
||||
elements: { subTrigger, subMenu },
|
||||
states: { subOpen }
|
||||
} = untrack(() => builders).createSubmenu()
|
||||
</script>
|
||||
|
||||
<button
|
||||
use:melt={$subTrigger}
|
||||
class={twMerge(
|
||||
'flex flex-row gap-3.5 items-center px-2 py-2 w-full',
|
||||
sidebarClasses.text,
|
||||
'transition-colors',
|
||||
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
|
||||
)}
|
||||
>
|
||||
<Building size={14} class="shrink-0" />
|
||||
<span class="grow text-left">Switch workspace</span>
|
||||
<ChevronRight size={14} class="shrink-0 text-tertiary" />
|
||||
</button>
|
||||
|
||||
{#if $subOpen}
|
||||
<div
|
||||
use:melt={$subMenu}
|
||||
class="z-[6001] w-56 bg-surface border rounded-md shadow-md focus:outline-none"
|
||||
>
|
||||
<WorkspacePickerBody {item} strictWorkspaceSelect />
|
||||
<!-- The picker's list scrolls inside itself, so this sits below it and stays reachable
|
||||
however many workspaces the operator belongs to. -->
|
||||
<div class="border-t border-border-light" role="none">
|
||||
<MenuItem
|
||||
href="{base}/user/workspaces"
|
||||
onClick={() => clearWorkspaceFromStorage()}
|
||||
class={twMerge(
|
||||
'w-full flex flex-row gap-2 px-4 py-2 font-normal',
|
||||
sidebarClasses.text,
|
||||
'hover:bg-surface-hover hover:text-primary',
|
||||
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
|
||||
)}
|
||||
{item}
|
||||
>
|
||||
All workspaces
|
||||
</MenuItem>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -14,9 +14,19 @@
|
||||
createMenu: MenubarBuilders['createMenu']
|
||||
// When used outside of the side bar, where links to workspace settings and such don't make as much sense.
|
||||
strictWorkspaceSelect?: boolean
|
||||
// Denser trigger for the page header bar: a smaller disc and a lighter name, so the
|
||||
// workspace reads as the scope of the breadcrumb beside it rather than a heading.
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
let { isCollapsed = false, createMenu, strictWorkspaceSelect = false }: Props = $props()
|
||||
let {
|
||||
isCollapsed = false,
|
||||
createMenu,
|
||||
strictWorkspaceSelect = false,
|
||||
compact = false
|
||||
}: Props = $props()
|
||||
|
||||
let menu: Menu | undefined = $state(undefined)
|
||||
|
||||
// The active workspace's family root — shown in the trigger so a forked active workspace still
|
||||
// surfaces its family name here (the fork itself is shown in the breadcrumb). Resolved exactly
|
||||
@@ -29,7 +39,7 @@
|
||||
const ambiguousNames = $derived(ambiguousWorkspaceNames($userWorkspaces))
|
||||
</script>
|
||||
|
||||
<Menu {createMenu} usePointerDownOutside placement="bottom-start">
|
||||
<Menu bind:this={menu} {createMenu} usePointerDownOutside placement="bottom-start">
|
||||
{#snippet triggr({ trigger })}
|
||||
<!-- Family header reflects the family (root) color, not the active
|
||||
workspace's — switching into a fork must not recolor it. -->
|
||||
@@ -41,7 +51,7 @@
|
||||
name={currentFamily?.name ?? $workspaceStore ?? ''}
|
||||
id={currentFamily?.id ?? $workspaceStore ?? ''}
|
||||
disablePopup={isCollapsed}
|
||||
class="block w-full"
|
||||
class={compact ? 'block' : 'block w-full'}
|
||||
>
|
||||
<MenuButton
|
||||
icon={Building}
|
||||
@@ -53,8 +63,10 @@
|
||||
{isCollapsed}
|
||||
color={familyColor}
|
||||
showChevron
|
||||
emphasizeLabel
|
||||
emphasizeLabel={!compact}
|
||||
disableTitle
|
||||
{compact}
|
||||
labelClass={compact ? 'wm-workspace-name text-primary font-medium' : 'wm-workspace-name'}
|
||||
{trigger}
|
||||
/>
|
||||
</NameIdTooltip>
|
||||
@@ -63,6 +75,11 @@
|
||||
{#snippet children({ item })}
|
||||
<!-- The only strict caller is a standalone page (svix webhook creation) that owns its
|
||||
own navigation, so there a switch must leave the page where it is. -->
|
||||
<WorkspacePickerBody {item} {strictWorkspaceSelect} keepPageOnSwitch={strictWorkspaceSelect} />
|
||||
<WorkspacePickerBody
|
||||
{item}
|
||||
{strictWorkspaceSelect}
|
||||
keepPageOnSwitch={strictWorkspaceSelect}
|
||||
closeMenu={() => menu?.close()}
|
||||
/>
|
||||
{/snippet}
|
||||
</Menu>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte'
|
||||
import { tick, untrack } from 'svelte'
|
||||
import type { MenubarMenuElements } from '@melt-ui/svelte'
|
||||
import { workspaceMenuHref } from './workspaceMenuHref'
|
||||
import { isOperatorInWorkspace } from './operatorRoutes'
|
||||
@@ -48,14 +48,16 @@
|
||||
item: MenubarMenuElements['item']
|
||||
// When used outside of the side bar, where links to workspace settings and such don't make as much sense.
|
||||
strictWorkspaceSelect?: boolean
|
||||
/** Closes the menu this list lives in, so a workspace switch does not run under an open
|
||||
* panel. The host owns the menu, so only it can. */
|
||||
closeMenu?: () => void
|
||||
// Set by a picker embedded in a page that drives its own navigation, so switching
|
||||
// workspaces must not navigate away from it. Separate from strictWorkspaceSelect,
|
||||
// which only strips the sidebar-specific rows — the operator submenu sets that one
|
||||
// and still wants the switch to move the page.
|
||||
// which only strips the sidebar-specific rows.
|
||||
keepPageOnSwitch?: boolean
|
||||
}
|
||||
|
||||
let { item, strictWorkspaceSelect = false, keepPageOnSwitch = false }: Props = $props()
|
||||
let { item, strictWorkspaceSelect = false, keepPageOnSwitch = false, closeMenu }: Props = $props()
|
||||
|
||||
async function toggleSwitchWorkspace(id: string) {
|
||||
if ($workspaceStore === id) {
|
||||
@@ -107,8 +109,13 @@
|
||||
if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) {
|
||||
return
|
||||
}
|
||||
// This click is handled here rather than by melt, so melt never closes the menu for it and
|
||||
// the panel would sit through the switch — which renames the workspace in the header,
|
||||
// moving the very trigger the panel hangs from, with floating-ui chasing it across the
|
||||
// viewport. Close first, let that reach the DOM, then switch.
|
||||
e.preventDefault()
|
||||
toggleSwitchWorkspace(workspace.id)
|
||||
closeMenu?.()
|
||||
void tick().then(() => toggleSwitchWorkspace(workspace.id))
|
||||
}
|
||||
|
||||
// Family-first picker: list the workspace families (roots) with their forks
|
||||
@@ -117,7 +124,7 @@
|
||||
// scope header's WorkspaceFamilyPicker remains the primary fork surface.
|
||||
//
|
||||
// strictWorkspaceSelect is used where this list has no scope header beside it
|
||||
// (standalone pages such as svix webhook creation, the operator menu), so
|
||||
// (standalone pages such as svix webhook creation), so
|
||||
// there forks must stay directly selectable — list the full hierarchy
|
||||
// unconditionally.
|
||||
const hierarchy = $derived($userWorkspaces ? buildWorkspaceHierarchy($userWorkspaces) : [])
|
||||
@@ -140,16 +147,41 @@
|
||||
const canForkHere = $derived(
|
||||
(!isCloudHosted() || $maybePremium) && $workspaceStore !== 'admins' && canCreateFork($userStore)
|
||||
)
|
||||
// The row mechanics below — which family to expand, which collapsed row carries the tick — key on
|
||||
// a depth-0 row of the list, which `buildWorkspaceHierarchy` puts at the highest workspace the
|
||||
// caller can see: parentless, or with a parent absent from their list. `findWorkspaceAncestors`
|
||||
// stops at that same visibility boundary, so it lands on the same row.
|
||||
const lineageRoot = $derived.by(() => {
|
||||
const id = $workspaceStore ?? undefined
|
||||
if (!id) return undefined
|
||||
const ancestors = findWorkspaceAncestors(id, $userWorkspaces ?? [])
|
||||
return ancestors.at(-1) ?? $userWorkspaces?.find((w) => w.id === id)
|
||||
})
|
||||
|
||||
const familyWorkspaces = $derived.by(() => {
|
||||
if (strictWorkspaceSelect) return hierarchy
|
||||
let rootId: string | undefined
|
||||
return hierarchy.filter((h) => {
|
||||
if (h.depth === 0) {
|
||||
rootId = h.workspace.id
|
||||
return true
|
||||
}
|
||||
return !!rootId && expandedFamilies.has(rootId)
|
||||
})
|
||||
const visible = strictWorkspaceSelect
|
||||
? hierarchy
|
||||
: hierarchy.filter((h) => {
|
||||
if (h.depth === 0) {
|
||||
rootId = h.workspace.id
|
||||
return true
|
||||
}
|
||||
return !!rootId && expandedFamilies.has(rootId)
|
||||
})
|
||||
// The family the user is standing in leads the list, its forks travelling with it: the
|
||||
// workspace they are in is the one they look for first, and the one the tick is on.
|
||||
const activeRoot = lineageRoot?.id
|
||||
if (!activeRoot) return visible
|
||||
const families: { root: string | undefined; rows: typeof visible }[] = []
|
||||
for (const h of visible) {
|
||||
if (h.depth === 0 || families.length === 0) families.push({ root: h.workspace.id, rows: [h] })
|
||||
else families[families.length - 1].rows.push(h)
|
||||
}
|
||||
const at = families.findIndex((f) => f.root === activeRoot)
|
||||
if (at <= 0) return visible
|
||||
const [active] = families.splice(at, 1)
|
||||
return [active, ...families].flatMap((f) => f.rows)
|
||||
})
|
||||
|
||||
// ArrowRight/ArrowLeft expand/collapse the keyboard-highlighted family (melt
|
||||
@@ -173,27 +205,13 @@
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
// The row mechanics below — which family to expand, which collapsed row carries the tick — key on
|
||||
// a depth-0 row of the list, which `buildWorkspaceHierarchy` puts at the highest workspace the
|
||||
// caller can see: parentless, or with a parent absent from their list. `findWorkspaceAncestors`
|
||||
// stops at that same visibility boundary, so it lands on the same row.
|
||||
const lineageRoot = $derived.by(() => {
|
||||
const id = $workspaceStore ?? undefined
|
||||
if (!id) return undefined
|
||||
const ancestors = findWorkspaceAncestors(id, $userWorkspaces ?? [])
|
||||
return ancestors.at(-1) ?? $userWorkspaces?.find((w) => w.id === id)
|
||||
})
|
||||
|
||||
const ambiguousNames = $derived(ambiguousWorkspaceNames($userWorkspaces))
|
||||
|
||||
// Seeded once per instance: opening while a fork is active expands that fork's family,
|
||||
// putting the tick on the active fork's own row instead of on its collapsed root. The
|
||||
// host menu renders this only while open, so in practice each open gets a fresh
|
||||
// instance and therefore a fresh expansion state — except across a close and re-open
|
||||
// inside the menu's 100ms outro, where Svelte resumes the same instance and whatever
|
||||
// was expanded by hand survives.
|
||||
// Seeded once per instance: the family the user is standing in opens expanded, so its forks —
|
||||
// the ones they are most likely to switch between — are there without a click, and the tick
|
||||
// sits on the actual workspace rather than on its collapsed root. Other families stay closed.
|
||||
untrack(() => {
|
||||
if (lineageRoot && lineageRoot.id !== $workspaceStore) {
|
||||
if (lineageRoot && familiesWithForks.has(lineageRoot.id)) {
|
||||
expandedFamilies.add(lineageRoot.id)
|
||||
}
|
||||
})
|
||||
@@ -345,11 +363,7 @@
|
||||
</MenuItem>
|
||||
{/if}
|
||||
{#if canForkHere}
|
||||
<MenuItem
|
||||
class={itemClass}
|
||||
onClick={() => (globalForkModal.val = { opened: true })}
|
||||
{item}
|
||||
>
|
||||
<MenuItem class={itemClass} onClick={() => (globalForkModal.val = { opened: true })} {item}>
|
||||
<Plus size={16} />
|
||||
Workspace fork
|
||||
</MenuItem>
|
||||
|
||||
@@ -19,7 +19,10 @@
|
||||
} from '$lib/utils/workspaceHierarchy'
|
||||
import { useForkableWorkspaces } from '$lib/utils/useForkableWorkspaces.svelte'
|
||||
|
||||
let { isCollapsed = false }: { isCollapsed?: boolean } = $props()
|
||||
// `compact` is the page header's variant: sized to its label on one line, rather than filling
|
||||
// a rail's width.
|
||||
let { isCollapsed = false, compact = false }: { isCollapsed?: boolean; compact?: boolean } =
|
||||
$props()
|
||||
|
||||
const effectiveId = $derived($workspaceStore ?? undefined)
|
||||
// Resolve once here and pass to the picker + trigger below, avoiding a duplicate lookup (see
|
||||
@@ -62,7 +65,11 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex items-center min-w-0 px-2 {isCollapsed ? 'justify-center' : ''} py-0.5 rounded-md">
|
||||
<div
|
||||
class="flex items-center min-w-0 {compact ? '' : 'px-2'} {isCollapsed
|
||||
? 'justify-center'
|
||||
: ''} py-0.5 rounded-md"
|
||||
>
|
||||
<WorkspaceFamilyPicker
|
||||
selectedId={effectiveId}
|
||||
{forkableWorkspaces}
|
||||
@@ -70,7 +77,7 @@
|
||||
onRequestCreateFork={openForkModal}
|
||||
{settingsHref}
|
||||
{settingsLabel}
|
||||
class="min-w-0 w-full"
|
||||
class={compact ? 'min-w-0' : 'min-w-0 w-full'}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<WorkspaceScopeTrigger
|
||||
@@ -78,8 +85,8 @@
|
||||
{forkableWorkspaces}
|
||||
{isCollapsed}
|
||||
{rootLabel}
|
||||
wrap
|
||||
class="w-full"
|
||||
wrap={!compact}
|
||||
class={compact ? '' : 'w-full'}
|
||||
/>
|
||||
{/snippet}
|
||||
</WorkspaceFamilyPicker>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { fromStore } from 'svelte/store'
|
||||
import { userStore } from '$lib/stores'
|
||||
import { useLocalStorageValue } from '$lib/svelte5Utils.svelte'
|
||||
|
||||
// Empty until the user presses detach or attach once. Until then operators start detached:
|
||||
// many of them navigate through a workspace's own app, and a docked sidebar would take its width.
|
||||
const pref = useLocalStorageValue<string>('nav_detached', '', 'string')
|
||||
const user = fromStore(userStore)
|
||||
|
||||
/** Whether the sidebar is detached: hidden behind a floating button that slides it in over the page. */
|
||||
export const navDetached = {
|
||||
get val(): boolean {
|
||||
if (pref.val === 'true') return true
|
||||
if (pref.val === 'false') return false
|
||||
return !!user.current?.operator
|
||||
},
|
||||
set val(detached: boolean) {
|
||||
pref.val = String(detached)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Opening the detached sidebar's card, from the handle or from anywhere else that reaches for the
|
||||
// nav (the left edge band, the AI chat layout's menu button).
|
||||
let opener: (() => void) | undefined
|
||||
// Closing it once the pointer is off both the handle and the card, which the layout owns because
|
||||
// the card is its markup.
|
||||
let hoverClose: { schedule: () => void; cancel: () => void } | undefined
|
||||
|
||||
// Where the card's top edge goes: the header's bottom edge, so the two meet in a line rather than
|
||||
// the card starting at whatever height the control that opened it happens to have. Measured at open
|
||||
// time rather than fixed, so the card follows the header's own height. On a page that hides the
|
||||
// header, the header is still what the card will sit under — it comes down with it — so its height
|
||||
// is read even while it is off-screen. The fallback matches the header's resting height.
|
||||
const FALLBACK_CARD_TOP = 44
|
||||
const GAP_UNDER_HANDLE = 6
|
||||
let cardTop = $state(FALLBACK_CARD_TOP)
|
||||
|
||||
export const navHandleSlot = {
|
||||
/** Distance from the top of the viewport to the card's top edge, in px. */
|
||||
get cardTop(): number {
|
||||
return cardTop
|
||||
},
|
||||
/** The root layout owns the card, so it registers how to open and hover-close it. */
|
||||
setOpener(open: () => void, close?: { schedule: () => void; cancel: () => void }) {
|
||||
opener = open
|
||||
hoverClose = close
|
||||
},
|
||||
/** Pointer left the handle: close, unless it lands on the card before the delay is up. */
|
||||
scheduleClose() {
|
||||
hoverClose?.schedule()
|
||||
},
|
||||
/**
|
||||
* Opens the card under the page header, or under `anchor` when there is no header to sit
|
||||
* beneath — an embed, where whatever reached for the nav is all the card has to hang from.
|
||||
*/
|
||||
open(anchor?: Element | null) {
|
||||
hoverClose?.cancel()
|
||||
const band = document.querySelector('[data-page-header]')
|
||||
const bandHeight = band?.getBoundingClientRect().height
|
||||
if (bandHeight) {
|
||||
cardTop = Math.round(bandHeight)
|
||||
} else {
|
||||
const bottom = anchor?.getBoundingClientRect().bottom
|
||||
cardTop =
|
||||
bottom != null && bottom > 0 ? Math.round(bottom) + GAP_UNDER_HANDLE : FALLBACK_CARD_TOP
|
||||
}
|
||||
opener?.()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
Boxes,
|
||||
Calendar,
|
||||
DollarSign,
|
||||
Eye,
|
||||
FolderOpen,
|
||||
Home,
|
||||
HardHat,
|
||||
Play,
|
||||
Pyramid,
|
||||
Unplug,
|
||||
Users
|
||||
} from 'lucide-svelte'
|
||||
import type { OperatorPageKey } from './operatorRoutes'
|
||||
|
||||
/**
|
||||
* The workspace pages the sidebar links to, and the names the page header's breadcrumb gives them.
|
||||
* One table so a page cannot be called one thing in the nav and another in the header.
|
||||
*/
|
||||
export type NavPage = {
|
||||
label: string
|
||||
/** Path under `base`, matched against the current route by longest prefix. */
|
||||
path: string
|
||||
icon: any
|
||||
/** The `operator_settings` key that admits an operator, when the page has one. */
|
||||
operatorKey?: OperatorPageKey
|
||||
}
|
||||
|
||||
export const NAV_PAGES: NavPage[] = [
|
||||
{ label: 'Home', path: '/', icon: Home },
|
||||
{ label: 'Runs', path: '/runs', icon: Play, operatorKey: 'runs' },
|
||||
{ label: 'Variables', path: '/variables', icon: DollarSign, operatorKey: 'variables' },
|
||||
{ label: 'Resources', path: '/resources', icon: Boxes, operatorKey: 'resources' },
|
||||
{ label: 'Assets', path: '/assets', icon: Pyramid, operatorKey: 'assets' },
|
||||
{ label: 'Folders', path: '/folders', icon: FolderOpen, operatorKey: 'folders' },
|
||||
{ label: 'Groups', path: '/groups', icon: Users, operatorKey: 'groups' },
|
||||
{ label: 'Schedules', path: '/schedules', icon: Calendar, operatorKey: 'schedules' },
|
||||
{ label: 'Triggers', path: '/routes', icon: Unplug, operatorKey: 'triggers' },
|
||||
{ label: 'Workers', path: '/workers', icon: HardHat, operatorKey: 'workers' },
|
||||
{ label: 'Audit logs', path: '/audit_logs', icon: Eye, operatorKey: 'audit_logs' }
|
||||
]
|
||||
|
||||
/**
|
||||
* The page a route belongs to. Longest match wins, so `/runs/f/demo/x` is still Runs, and a route
|
||||
* the table does not name falls back to its first segment (`/service_logs` → "Service logs").
|
||||
*/
|
||||
export function navPageFor(pathname: string, base: string): NavPage | undefined {
|
||||
const path = pathname.startsWith(base) ? pathname.slice(base.length) || '/' : pathname
|
||||
const match = NAV_PAGES.filter(
|
||||
(p) => p.path === path || (p.path !== '/' && path.startsWith(p.path + '/'))
|
||||
).sort((a, b) => b.path.length - a.path.length)[0]
|
||||
if (match) return match
|
||||
const first = path.split('/').filter(Boolean)[0]
|
||||
if (!first) return undefined
|
||||
const label = first.replace(/_/g, ' ')
|
||||
return {
|
||||
label: label.charAt(0).toUpperCase() + label.slice(1),
|
||||
path: `/${first}`,
|
||||
icon: undefined
|
||||
}
|
||||
}
|
||||
@@ -1,61 +1,22 @@
|
||||
import { base } from '$lib/base'
|
||||
import type { OperatorSettings } from '$lib/gen'
|
||||
import type { UserWorkspace } from '$lib/stores'
|
||||
|
||||
/** An `operator_settings` key: each admits an operator to one page (`triggers` to all of them). */
|
||||
export type OperatorPageKey = keyof NonNullable<OperatorSettings>
|
||||
|
||||
/**
|
||||
* The pages an operator can reach, each paired with the `operator_settings` key
|
||||
* that admits them — the operator menu lists only the entries the active
|
||||
* workspace permits.
|
||||
* Whether the sidebar shows the page behind `key`. Everyone but an operator sees every page
|
||||
* (other gates, such as admin-only entries, still apply); an operator sees only the pages the
|
||||
* workspace's `operator_settings` turn on.
|
||||
*/
|
||||
export type OperatorMenuLink = {
|
||||
label: string
|
||||
/** `operator_settings` key gating the page. */
|
||||
id: string
|
||||
href: string
|
||||
export function sidebarPageAllowed(
|
||||
isOperator: boolean | undefined,
|
||||
workspace: UserWorkspace | undefined,
|
||||
key: OperatorPageKey
|
||||
): boolean {
|
||||
return !isOperator || workspace?.operator_settings?.[key] === true
|
||||
}
|
||||
|
||||
export type OperatorTriggerLink = OperatorMenuLink & { kind: string }
|
||||
|
||||
/** Home needs no setting — it is the one page every operator always has. */
|
||||
export const OPERATOR_MAIN_LINKS: OperatorMenuLink[] = [
|
||||
{ label: 'Home', id: 'home', href: `${base}/` },
|
||||
{ label: 'Runs', id: 'runs', href: `${base}/runs` },
|
||||
{ label: 'Schedules', id: 'schedules', href: `${base}/schedules` }
|
||||
]
|
||||
|
||||
export const OPERATOR_SECONDARY_LINKS: OperatorMenuLink[] = [
|
||||
{ label: 'Resources', id: 'resources', href: `${base}/resources` },
|
||||
{ label: 'Variables', id: 'variables', href: `${base}/variables` },
|
||||
{ label: 'Assets', id: 'assets', href: `${base}/assets` },
|
||||
{ label: 'Groups', id: 'groups', href: `${base}/groups` },
|
||||
{ label: 'Folders', id: 'folders', href: `${base}/folders` },
|
||||
{ label: 'Workers', id: 'workers', href: `${base}/workers` },
|
||||
{ label: 'Audit logs', id: 'audit_logs', href: `${base}/audit_logs` }
|
||||
]
|
||||
|
||||
export const OPERATOR_TRIGGER_LINKS: OperatorTriggerLink[] = [
|
||||
{ label: 'Custom HTTP routes', id: 'triggers', href: `${base}/routes`, kind: 'http' },
|
||||
{ label: 'Websocket triggers', id: 'triggers', href: `${base}/websocket_triggers`, kind: 'ws' },
|
||||
{
|
||||
label: 'Postgres triggers',
|
||||
id: 'triggers',
|
||||
href: `${base}/postgres_triggers`,
|
||||
kind: 'postgres'
|
||||
},
|
||||
{ label: 'Kafka triggers', id: 'triggers', href: `${base}/kafka_triggers`, kind: 'kafka' },
|
||||
{ label: 'NATS triggers', id: 'triggers', href: `${base}/nats_triggers`, kind: 'nats' },
|
||||
{ label: 'SQS triggers', id: 'triggers', href: `${base}/sqs_triggers`, kind: 'sqs' },
|
||||
{ label: 'GCP Pub/Sub triggers', id: 'triggers', href: `${base}/gcp_triggers`, kind: 'gcp' },
|
||||
{
|
||||
label: 'Azure Event Grid triggers',
|
||||
id: 'triggers',
|
||||
href: `${base}/azure_triggers`,
|
||||
kind: 'azure'
|
||||
},
|
||||
{ label: 'MQTT triggers', id: 'triggers', href: `${base}/mqtt_triggers`, kind: 'mqtt' },
|
||||
{ label: 'AMQP triggers', id: 'triggers', href: `${base}/amqp_triggers`, kind: 'amqp' },
|
||||
{ label: 'Email triggers', id: 'triggers', href: `${base}/email_triggers`, kind: 'email' }
|
||||
]
|
||||
|
||||
/**
|
||||
* Whether the user is an operator in `workspace`. The server nulls
|
||||
* `operator_settings` for a non-operator, so a non-null value narrows an operator
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { DriveStep } from 'driver.js'
|
||||
import { wait } from '$lib/utils'
|
||||
import Tutorial from './Tutorial.svelte'
|
||||
import { markOperatorTourSeen, MENU_OPEN_DELAY_MS } from './operatorTour'
|
||||
import { markOperatorTourSeen } from './operatorTour'
|
||||
|
||||
let tutorial: Tutorial | undefined = $state(undefined)
|
||||
let running = false
|
||||
@@ -25,7 +24,7 @@
|
||||
<Tutorial
|
||||
bind:this={tutorial}
|
||||
{onDestroyed}
|
||||
getSteps={(driver) => {
|
||||
getSteps={() => {
|
||||
const steps: DriveStep[] = [
|
||||
{
|
||||
popover: {
|
||||
@@ -60,19 +59,12 @@
|
||||
},
|
||||
{
|
||||
popover: {
|
||||
title: 'Finally, the Menu section',
|
||||
title: 'Finally, the sidebar',
|
||||
description:
|
||||
'Explore available tabs where you can access your history of runs, your scheduled scripts, and your workspaces.<p style="margin-top: 12px; padding-top: 12px; border-top: 1px solid rgba(128,128,128,0.3); font-size: 0.9em; opacity: 0.9;"><strong>💡 Want to see this again?</strong> Pick <strong>Take the tour</strong> from that same menu.</p>',
|
||||
onNextClick: async () => {
|
||||
// The step points into the menu, so it has to be open before the popover
|
||||
// lands on it — and open is also where the entry to re-run the tour is.
|
||||
const menuButton = document.querySelector('[role="menuitem"]') as HTMLElement | null
|
||||
menuButton?.click()
|
||||
await wait(MENU_OPEN_DELAY_MS)
|
||||
driver.destroy()
|
||||
}
|
||||
'Explore the pages you can open from here: your history of runs, your scheduled scripts, AI sessions and your workspaces.<p style="margin-top: 12px; padding-top: 12px; border-top: 1px solid rgba(128,128,128,0.3); font-size: 0.9em; opacity: 0.9;"><strong>💡 Want to see this again?</strong> Pick <strong>Take the tour</strong> from your account menu, under <strong>Settings</strong>.</p>'
|
||||
},
|
||||
element: '[role="menuitem"]'
|
||||
// Whichever the sidebar currently is: the floating handle when detached, the rail when not.
|
||||
element: '[data-nav-handle], #sidebar'
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -13,8 +13,6 @@ export const TOUR_PARAM_VALUE = 'operator'
|
||||
|
||||
/** Long enough for the home page's tabs to exist before the first step points at one. */
|
||||
export const TOUR_START_DELAY_MS = 500
|
||||
/** Time for the sidebar to open before the last step points into it. */
|
||||
export const MENU_OPEN_DELAY_MS = 300
|
||||
|
||||
export async function hasSeenOperatorTour(): Promise<boolean> {
|
||||
// A failure answers "seen": the tour interrupts the page, and interrupting someone who
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,11 +19,13 @@
|
||||
import { writable } from 'svelte/store'
|
||||
import type { EditorBreakpoint } from '$lib/components/apps/types'
|
||||
import { setQuery } from '$lib/navigation'
|
||||
import { useReducedMotion } from '$lib/svelte5Utils.svelte'
|
||||
import { page } from '$app/state'
|
||||
import { goto, replaceState } from '$app/navigation'
|
||||
import ForkWorkspaceBanner from '$lib/components/ForkWorkspaceBanner.svelte'
|
||||
import WorkspaceDraftsBanner from '$lib/components/WorkspaceDraftsBanner.svelte'
|
||||
import NoDirectDeployAlert from '$lib/components/NoDirectDeployAlert.svelte'
|
||||
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
|
||||
import { useSearchParams } from '$lib/svelte5UtilsKit.svelte'
|
||||
import { z } from 'zod'
|
||||
import HomeAIChat from '$lib/components/home/HomeAIChat.svelte'
|
||||
@@ -92,6 +94,22 @@
|
||||
|
||||
let showCreateButtons = $state(false)
|
||||
|
||||
// A workspace switch keeps this page mounted and swaps its contents under the reader, which
|
||||
// the mount animation below never covers. Replay it by hand instead of keying the page on the
|
||||
// workspace: a remount would drop the list's own state and refetch what it is already loading.
|
||||
let pageEl: HTMLDivElement | undefined = $state(undefined)
|
||||
const reducedMotion = useReducedMotion()
|
||||
let animatedWorkspace = $workspaceStore
|
||||
$effect(() => {
|
||||
const ws = $workspaceStore
|
||||
if (ws === animatedWorkspace) return
|
||||
animatedWorkspace = ws
|
||||
untrack(() => {
|
||||
if (!pageEl || reducedMotion.val) return
|
||||
pageEl.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 500, easing: 'ease-out' })
|
||||
})
|
||||
})
|
||||
|
||||
let operatorTour: OperatorTour | undefined = $state(undefined)
|
||||
|
||||
// Delayed so the tabs the first steps point at exist. `runTutorial` refuses while a tour is
|
||||
@@ -262,16 +280,25 @@
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
{#snippet editRuleBadge()}
|
||||
<NoDirectDeployAlert onUpdateCanEditStatus={(v) => (showCreateButtons = v)} />
|
||||
{/snippet}
|
||||
|
||||
<div
|
||||
bind:this={pageEl}
|
||||
class="wm-page-in flex flex-col w-full h-full overflow-y-auto items-center"
|
||||
style="scrollbar-gutter: stable both-edges;"
|
||||
>
|
||||
<!-- Where edits to this workspace are made is a fact about the workspace, so it sits in the
|
||||
header with the workspace's own name rather than above the list. -->
|
||||
<PageHeaderContent actions={editRuleBadge} />
|
||||
|
||||
<ForkWorkspaceBanner />
|
||||
<WorkspaceDraftsBanner />
|
||||
<div class="max-w-7xl px-4 sm:px-8 md:px-8 h-fit w-full mb-6">
|
||||
<!-- HomeAIChat carries both the AI composer and the AI-independent CLI/MCP connect row,
|
||||
so it shows whenever the sessions beta is on; the composer itself is gated on operator
|
||||
status and on the workspace inside the component, which owns its own vertical spacing
|
||||
so it shows whenever the sessions beta is on; the composer itself is gated on the
|
||||
workspace inside the component, which owns its own vertical spacing
|
||||
because the hero and the bare connect row want different amounts of it. -->
|
||||
{#if isGlobalAiEnabled()}
|
||||
<HomeAIChat />
|
||||
@@ -285,8 +312,6 @@
|
||||
<div class="my-4"></div>
|
||||
{/if}
|
||||
|
||||
<NoDirectDeployAlert onUpdateCanEditStatus={(v) => (showCreateButtons = v)} />
|
||||
|
||||
{#if tab == 'hub'}
|
||||
<div class="flex flex-col gap-y-16">
|
||||
<div class="flex flex-col pb-8">
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* here — links to an app point at this route whatever its kind; the sibling
|
||||
* /apps_raw/get route wraps the same component.
|
||||
*/
|
||||
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
|
||||
import InWorkspaceAppViewer from '$lib/components/apps/editor/InWorkspaceAppViewer.svelte'
|
||||
import { Skeleton } from '$lib/components/common'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
@@ -18,6 +19,10 @@
|
||||
<!-- Wait for the active workspace before mounting the embedder: it's needed to
|
||||
mint the token and to build the viewer iframe URL, and the store is set
|
||||
asynchronously by the (logged) layout. -->
|
||||
<!-- Claimed here rather than in the viewer below: the viewer mounts once the workspace has
|
||||
resolved, and until then the band would be an ordinary header on screen, only to leave again. -->
|
||||
<PageHeaderContent fullBleed />
|
||||
|
||||
{#if workspace && path}
|
||||
<!-- Key by target: SvelteKit reuses this page component on in-route
|
||||
navigation (e.g. a navbar item linking to another app), so the viewer
|
||||
|
||||
@@ -545,7 +545,7 @@
|
||||
setTimeout(async () => {
|
||||
// Falls through when the hand-off has no path to open, so the click
|
||||
// still reaches the legacy path (or its toast) instead of vanishing.
|
||||
if (prefersSessionHandoff($userStore?.operator)) {
|
||||
if (prefersSessionHandoff()) {
|
||||
if (await rawAppEditor?.openInSession(prompt)) return
|
||||
}
|
||||
aiChatManager.changeMode(AIMode.APP)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* apps get the identical sandbox behavior. PublicAppFrame renders raw apps
|
||||
* inline with the bundle isolated in RawAppPreview's own opaque iframe.
|
||||
*/
|
||||
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
|
||||
import InWorkspaceAppViewer from '$lib/components/apps/editor/InWorkspaceAppViewer.svelte'
|
||||
import { Skeleton } from '$lib/components/common'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
@@ -14,6 +15,10 @@
|
||||
let path = $derived(page.params.path ?? '')
|
||||
</script>
|
||||
|
||||
<!-- Claimed here rather than in the viewer below: the viewer mounts once the workspace has
|
||||
resolved, and until then the band would be an ordinary header on screen, only to leave again. -->
|
||||
<PageHeaderContent fullBleed />
|
||||
|
||||
{#if workspace && path}
|
||||
<!-- Key by target: SvelteKit reuses this page component on in-route
|
||||
navigation, so the viewer must fully remount (see /apps/get). -->
|
||||
|
||||
@@ -552,6 +552,7 @@
|
||||
</div>
|
||||
{:else if renderEditor}
|
||||
<FlowBuilder
|
||||
ownsPageHeader
|
||||
onTakeLatest={draftBaseVersion
|
||||
? async (shown?: string) => {
|
||||
// The version the drawer showed as head; see /scripts/edit.
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
Loader2
|
||||
} from 'lucide-svelte'
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
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 Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
@@ -40,7 +41,7 @@
|
||||
import { withWorkspaceParam } from '$lib/components/sessions/sessionMode.svelte'
|
||||
import { enterSessionMode } from '$lib/components/sessions/sessionSwitch.svelte'
|
||||
import type { SessionPreviewTabs } from '$lib/components/sessions/sessionPreviewTabs.svelte'
|
||||
import { userStore, userWorkspaces, usersWorkspaceStore, workspaceStore } from '$lib/stores'
|
||||
import { userWorkspaces, usersWorkspaceStore, workspaceStore } from '$lib/stores'
|
||||
import {
|
||||
getOrCreateRuntime,
|
||||
getRuntime,
|
||||
@@ -196,7 +197,7 @@
|
||||
// load. `recovering` also guards re-entry: recovery mutates the session list
|
||||
// this effect tracks, while the URL that would stop it only updates on `goto`.
|
||||
$effect(() => {
|
||||
if (embedded || !globalEnabled || $userStore?.operator) return
|
||||
if (embedded || !globalEnabled) return
|
||||
if (!sessionState.hydrated || recovering) return
|
||||
// A deliberate delete removes the open session ahead of its own navigation.
|
||||
// Claiming that gap would take over the URL and tell the user the session
|
||||
@@ -434,6 +435,8 @@
|
||||
let emptyStateNewTabOpen = $state(false)
|
||||
|
||||
let fullscreen = $state(false)
|
||||
// Width of the preview panel, so the band can stop where it starts.
|
||||
let previewWidth = $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.
|
||||
@@ -867,6 +870,29 @@
|
||||
}}
|
||||
/>
|
||||
|
||||
<!-- The session showing fills the band's breadcrumb. The band is the layout's, but it stops
|
||||
where the preview panel starts so that panel can run from the top of the viewport. -->
|
||||
<PageHeaderContent
|
||||
barRightInset={previewCollapsed || fullscreen || previewWidth === 0 ? undefined : previewWidth}
|
||||
actions={sessionPageActions}
|
||||
/>
|
||||
|
||||
{#snippet sessionPageActions()}
|
||||
{#if previewCollapsed && !fullscreen}
|
||||
<!-- Collapsed preview: the way back to the side panel, in the band with the session's own
|
||||
controls rather than floating over the chat. -->
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: PanelRightOpen }}
|
||||
title="Open side panel"
|
||||
onclick={() => owner?.setCollapsed(false)}
|
||||
>
|
||||
Open side panel
|
||||
</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<div class="h-full flex flex-col min-h-0">
|
||||
{#if embedded}
|
||||
<!-- Rendered inside a preview iframe — opening the sessions UI here would
|
||||
@@ -884,24 +910,6 @@
|
||||
}}>Open sessions</Button
|
||||
>
|
||||
</div>
|
||||
{:else if $userStore?.operator}
|
||||
<!-- Operators are exempt from the sessions beta (the layout keeps their
|
||||
legacy docked chat); a direct URL must not bypass that. -->
|
||||
<div class="p-8 flex flex-col items-start gap-3 text-secondary text-sm">
|
||||
<p class="text-primary font-medium">AI Sessions are not available for operators</p>
|
||||
<p>Use the Ask AI chat instead.</p>
|
||||
<Button
|
||||
size="xs"
|
||||
onclick={() => {
|
||||
try {
|
||||
localStorage.setItem('ai-chat-open', 'true')
|
||||
} catch {}
|
||||
window.location.href = `${base}/`
|
||||
}}
|
||||
>
|
||||
Open Ask AI chat
|
||||
</Button>
|
||||
</div>
|
||||
{:else if !globalEnabled}
|
||||
<!-- Direct navigation (bookmark, shared link) while the user has opted out
|
||||
of the beta: offer the way back in instead of a dead end. -->
|
||||
@@ -945,7 +953,14 @@
|
||||
{#if !fullscreen}
|
||||
<!-- Chat column. Recently visited sessions stay mounted (stacked,
|
||||
visibility-toggled) — see mountChat. -->
|
||||
<Pane bind:size={chatPaneSize} minSize={25} class="flex flex-col min-h-0">
|
||||
<!-- The band floats over this column's top strip while a panel is beside it, so the
|
||||
column starts below it. The offset belongs on the pane, not on the stack's
|
||||
container: the session stacks are `absolute inset-0` and would ignore padding. -->
|
||||
<Pane
|
||||
bind:size={chatPaneSize}
|
||||
minSize={25}
|
||||
class="flex flex-col min-h-0 {previewCollapsed || fullscreen ? '' : 'pt-11'}"
|
||||
>
|
||||
<div class="relative flex-1 min-h-0">
|
||||
{#each mountedChatSessions as s (s.id)}
|
||||
<div
|
||||
@@ -954,7 +969,7 @@
|
||||
: 'z-0 opacity-0 pointer-events-none'}"
|
||||
aria-hidden={s.id !== activeSession?.id}
|
||||
>
|
||||
<SessionWrapper sessionId={s.id} />
|
||||
<SessionWrapper sessionId={s.id} headerInPage={s.id === activeSession?.id} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -970,7 +985,10 @@
|
||||
maxSize={previewCollapsed ? 0 : 100}
|
||||
class="flex flex-col min-h-0"
|
||||
>
|
||||
<div class="flex-1 min-h-0 flex flex-col {fullscreen ? 'p-0' : 'p-2 pl-0'}">
|
||||
<div
|
||||
bind:clientWidth={previewWidth}
|
||||
class="flex-1 min-h-0 flex flex-col {fullscreen ? 'p-0' : 'p-2 pl-0'}"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col flex-1 min-h-0 overflow-hidden relative bg-surface {fullscreen
|
||||
? ''
|
||||
@@ -1199,21 +1217,6 @@
|
||||
</div>
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
{#if previewCollapsed && !fullscreen}
|
||||
<!-- Collapsed preview: no rail — a floating launcher in the top-right to
|
||||
reopen the side panel. -->
|
||||
<div class="absolute top-2 right-3 z-50">
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: PanelRightOpen }}
|
||||
title="Open side panel"
|
||||
onclick={() => owner?.setCollapsed(false)}
|
||||
>
|
||||
Open side panel
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if aiHiddenVerdict === undefined}
|
||||
<div class="absolute inset-0 z-20 flex items-center justify-center bg-surface">
|
||||
|
||||
Reference in New Issue
Block a user