diff --git a/frontend/src/lib/components/BreadcrumbItemContent.svelte b/frontend/src/lib/components/BreadcrumbItemContent.svelte new file mode 100644 index 0000000000..9e945586c7 --- /dev/null +++ b/frontend/src/lib/components/BreadcrumbItemContent.svelte @@ -0,0 +1,16 @@ + + + +{@render icon?.()} +{label} diff --git a/frontend/src/lib/components/BreadcrumbSegment.svelte b/frontend/src/lib/components/BreadcrumbSegment.svelte index 8a266e4fc4..4d55675f26 100644 --- a/frontend/src/lib/components/BreadcrumbSegment.svelte +++ b/frontend/src/lib/components/BreadcrumbSegment.svelte @@ -6,7 +6,7 @@ state — switching popovers relies on melt-ui's `closeOnOtherPopoverOpen` to close siblings. --> + +{@render children()} diff --git a/frontend/src/lib/components/EditorHeader.svelte b/frontend/src/lib/components/EditorHeader.svelte index 25291fa5f0..82be29b658 100644 --- a/frontend/src/lib/components/EditorHeader.svelte +++ b/frontend/src/lib/components/EditorHeader.svelte @@ -1,5 +1,5 @@ -
+
{#if !hidePath}
@@ -162,9 +143,9 @@ {#if segments} {#each segments.dirs as dir, i (dir.fullPath)} {@const dKey = dirKey('all', dir.fullPath)} + ([ + ['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 @@
- -
-
- {#if customUi?.topBar?.path != false} -
- onNavigate?.(item)} + {#if ownsPageHeader} + + + {:else} + +
+
+ {#if customUi?.topBar?.path != false} +
+ onNavigate?.(item)} + /> +
+ {/if} + {#if opWorkspace && indicatorPath !== undefined} + -
- {/if} - {#if opWorkspace && indicatorPath !== undefined} - - {/if} -
-
- {#if $enterpriseLicense && !newFlow && !inSessionPane} - - {/if} - - {#if diffEnabled && !diffInMenu} - -
- -
- {/if} - {#if !compactTopbar} - {@render previewButtons()} - {/if} +
+ +
+ {/if} + {#if !compactTopbar} + {@render previewButtons()} + {/if} - await handleSaveFlow(detail)} - {loading} - {loadingSave} - unifiedSize={headerBtnSize} - {dropdownItems} - /> + await handleSaveFlow(detail)} + {loading} + {loadingSave} + unifiedSize={headerBtnSize} + {dropdownItems} + /> +
-
+ {/if} + + {#snippet flowSummary()} + + (flowStore.val.summary = v)} + textClass="text-xs font-medium text-emphasis leading-tight" + class="max-w-full min-w-0" + /> + {/snippet} + + {#snippet flowHeaderActions()} + + {#if opWorkspace && indicatorPath !== undefined} + + {/if} + {#if $enterpriseLicense && !newFlow && !inSessionPane} + + {/if} + + {#if diffEnabled && !diffInMenu} + +
+ +
+ {/if} + {#if !compactTopbar} + {@render previewButtons()} + {/if} + + await handleSaveFlow(detail)} + {loading} + {loadingSave} + unifiedSize={headerBtnSize} + {dropdownItems} + /> + {/snippet} + diff --git a/frontend/src/lib/components/ForkWorkspaceBanner.svelte b/frontend/src/lib/components/ForkWorkspaceBanner.svelte index 940c579fd7..3a28e37548 100644 --- a/frontend/src/lib/components/ForkWorkspaceBanner.svelte +++ b/frontend/src/lib/components/ForkWorkspaceBanner.svelte @@ -1,14 +1,14 @@ {#if showBanner} - -
-
- -
-
- -
- - {isDevWorkspace - ? `${devLabelWord(currentWorkspaceData?.dev_workspace_label)} workspace of` - : 'Fork of'} - {parentWorkspaceData?.name}{#if parentWorkspaceData?.name !== parentWorkspaceId} - ({parentWorkspaceId}){/if} - -
- - {#if loading} - Checking for changes... - {:else if error} - - {error} - - {:else if comparison} -
- {#if comparison.summary.total_diffs > 0} - - {forkAheadBehindMessage(changesAhead, changesBehind)} - {parentWorkspaceId} over {comparison - .summary.total_diffs} items - - - - - {#if ciTestTotal > 0} - - - {#if ciTestFailing > 0} -
- - CI: {ciTestFailing} failing -
- {:else if ciTestRunning > 0} -
- - CI: {ciTestRunning} running -
- {:else} -
- - CI: {ciTestPassing} passing -
- {/if} - {/if} - - {#if comparison.summary.conflicts > 0} - - -
- - {comparison.summary.conflicts} conflict{comparison.summary.conflicts !== 1 - ? 's' - : ''} -
- {/if} - {:else if comparison.skipped_comparison} - - This {currentNoun} was created before the addition of certain windmill features, and - therefore the changes with its parent workspace cannot be displayed. - {:else if showDraftsOnly} - - This workspace has {draftCount} draft{draftCount !== 1 ? 's' : ''} - - {:else} - Everything is up to date - {/if} -
- {/if} -
- -
- -
-
-
-
+ + {/if} + +{#snippet forkAction()} + {@const total = comparison?.summary.total_diffs ?? 0} + {@const conflicts = comparison?.summary.conflicts ?? 0} + {#if loading} + Checking for changes… + {:else if error} + Comparison failed + {:else if showDraftsOnly} + + {draftCount} draft{draftCount !== 1 ? 's' : ''} + + + {:else if total > 0} + + {#if changesAhead > 0} + + ↑ {changesAhead} + + {/if} + {#if changesBehind > 0} + + ↓ {changesBehind} + + {/if} + {#if conflicts > 0} + + {conflicts} conflict{conflicts !== 1 ? 's' : ''} + + {/if} + + {/if} +{/snippet} diff --git a/frontend/src/lib/components/NavBreadcrumb.svelte b/frontend/src/lib/components/NavBreadcrumb.svelte new file mode 100644 index 0000000000..4f1e578def --- /dev/null +++ b/frontend/src/lib/components/NavBreadcrumb.svelte @@ -0,0 +1,311 @@ + + + +{#snippet workspaceDisc()} + + + + + + + +{/snippet} + +{#snippet itemKindIcon()} + {#if item}{/if} +{/snippet} + +{#snippet sectionKindIcon()} + {#if section?.kind}{/if} +{/snippet} + +{#snippet folderIcon()} + +{/snippet} + +{#snippet userIcon()} + +{/snippet} + +{#snippet envBadgeMark()} + {#if envBadge} + {envBadge.text} + {/if} +{/snippet} + +{#snippet slash()} + +{/snippet} + + diff --git a/frontend/src/lib/components/NavBreadcrumbTrigger.svelte b/frontend/src/lib/components/NavBreadcrumbTrigger.svelte new file mode 100644 index 0000000000..f0b2084d46 --- /dev/null +++ b/frontend/src/lib/components/NavBreadcrumbTrigger.svelte @@ -0,0 +1,27 @@ + + + + diff --git a/frontend/src/lib/components/NoDirectDeployAlert.svelte b/frontend/src/lib/components/NoDirectDeployAlert.svelte index d1017f7347..a3a249caf5 100644 --- a/frontend/src/lib/components/NoDirectDeployAlert.svelte +++ b/frontend/src/lib/components/NoDirectDeployAlert.svelte @@ -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 + + {/if} + + {#if onUnpin} + + + {/if} + + + {#if !navHidden} +
+ +
+ {/if} + + {#if item && !navHidden && (item.summaryContent || item.summary)} + + + +
+ {#if item.summaryContent} + {@render item.summaryContent()} + {:else} + {item.summary} + {/if} +
+ {/if} + + {#if actions.length > 0} + +
+ {#each actions as entry, i (i)} + {#key entry.contexts} + + {@render entry.render()} + + {/key} + {/each} +
+ {/if} + diff --git a/frontend/src/lib/components/PageHeaderContent.svelte b/frontend/src/lib/components/PageHeaderContent.svelte new file mode 100644 index 0000000000..a9b7cabf91 --- /dev/null +++ b/frontend/src/lib/components/PageHeaderContent.svelte @@ -0,0 +1,39 @@ + + diff --git a/frontend/src/lib/components/RunsPage.svelte b/frontend/src/lib/components/RunsPage.svelte index 995517ef0d..5dd31772e4 100644 --- a/frontend/src/lib/components/RunsPage.svelte +++ b/frontend/src/lib/components/RunsPage.svelte @@ -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('') +{#snippet runsHint()} + + 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. + +{/snippet} + +{#snippet headerActions()} + +
+ { + jobsFilter('waiting') + }} + onJobsSuspended={() => { + jobsFilter('suspended') + }} + small + /> +
+ + + + + + + + jobsLoader?.loadJobs(true)} + loading={jobsLoader?.loading} + items={runsTimeframes} + bind:value={_timeframe.val} + /> + + pageHref(RUNS_PATH) + window.location.search, + workspaceId: $workspaceStore ?? undefined + }} + btnProps={{ unifiedSize: 'sm' }} + /> +{/snippet} + {:else}
- -
-
-

- 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. - - - - { - jobsFilter('waiting') - }} - onJobsSuspended={() => { - jobsFilter('suspended') - }} - small={innerWidth < smallScreenWidth} - /> -
- - - - - - jobsLoader?.loadJobs(true)} - loading={jobsLoader?.loading} - items={runsTimeframes} - bind:value={_timeframe.val} - /> - - - pageHref(RUNS_PATH) + window.location.search, - workspaceId: $workspaceStore ?? undefined - }} - btnProps={{ unifiedSize: 'md' }} - /> -
+ + -
+
diff --git a/frontend/src/lib/components/SummaryPathDisplay.svelte b/frontend/src/lib/components/SummaryPathDisplay.svelte index ea534bd917..1f5ac85f95 100644 --- a/frontend/src/lib/components/SummaryPathDisplay.svelte +++ b/frontend/src/lib/components/SummaryPathDisplay.svelte @@ -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()}
- {path} + {#if !compact} + {path} + {/if}
@@ -252,11 +262,13 @@ {/snippet} {:else} -
- {#if !emptyString(summary)} +
+ {#if !emptyString(summary) && !compact} {path} {/if} - + {emptyString(summary) ? (path ?? '') : summary}
diff --git a/frontend/src/lib/components/WorkspaceDraftsBanner.svelte b/frontend/src/lib/components/WorkspaceDraftsBanner.svelte index 2e0110f710..06dbf1a5df 100644 --- a/frontend/src/lib/components/WorkspaceDraftsBanner.svelte +++ b/frontend/src/lib/components/WorkspaceDraftsBanner.svelte @@ -1,10 +1,10 @@ {#if !isFork && draftCount > 0} - -
-
-
-
- - - This workspace has {draftCount} draft{draftCount !== 1 ? 's' : ''} - -
- - -
-
-
+ + {/if} + +{#snippet draftsAction()} + + This workspace has + + {draftCount} draft{draftCount !== 1 ? 's' : ''} + + +{/snippet} diff --git a/frontend/src/lib/components/WorkspaceItemKindIcon.svelte b/frontend/src/lib/components/WorkspaceItemKindIcon.svelte new file mode 100644 index 0000000000..9d07f6ef89 --- /dev/null +++ b/frontend/src/lib/components/WorkspaceItemKindIcon.svelte @@ -0,0 +1,19 @@ + + + +{#if kind === 'flow'} + +{:else if kind === 'app'} + +{:else} + +{/if} diff --git a/frontend/src/lib/components/apps/appHeaderPin.svelte.ts b/frontend/src/lib/components/apps/appHeaderPin.svelte.ts new file mode 100644 index 0000000000..9f889f9b4e --- /dev/null +++ b/frontend/src/lib/components/apps/appHeaderPin.svelte.ts @@ -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('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 + } +} diff --git a/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte b/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte index 99ea07c8fd..fa8420859b 100644 --- a/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte +++ b/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte @@ -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 @@ }) - { - refresh = requestTokenRefresh - loadApp() - }} + +
- {#snippet viewer()} - loadApp()} - > - {/snippet} - + { + refresh = requestTokenRefresh + loadApp() + }} + > + {#snippet viewer()} + loadApp()} + > + {/snippet} + +
{#if canWriteApp && !hideEditBtn} -
- -
+ + + {/if} + +{#snippet editAction()} + +{/snippet} diff --git a/frontend/src/lib/components/breadcrumbPath.ts b/frontend/src/lib/components/breadcrumbPath.ts new file mode 100644 index 0000000000..8c041518f5 --- /dev/null +++ b/frontend/src/lib/components/breadcrumbPath.ts @@ -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/` or + * `u/`), 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 } } +} diff --git a/frontend/src/lib/components/common/toggleButton-v2/ToggleButtonMore.svelte b/frontend/src/lib/components/common/toggleButton-v2/ToggleButtonMore.svelte index 1300dc4322..86f04dfa9f 100644 --- a/frontend/src/lib/components/common/toggleButton-v2/ToggleButtonMore.svelte +++ b/frontend/src/lib/components/common/toggleButton-v2/ToggleButtonMore.svelte @@ -70,7 +70,14 @@
- + +
diff --git a/frontend/src/lib/components/copilot/AskAiButton.svelte b/frontend/src/lib/components/copilot/AskAiButton.svelte index 95ff47fb52..a141ee3125 100644 --- a/frontend/src/lib/components/copilot/AskAiButton.svelte +++ b/frontend/src/lib/components/copilot/AskAiButton.svelte @@ -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 diff --git a/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte b/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte index 1a1412b9f8..f3584d1ca9 100644 --- a/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte +++ b/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte @@ -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 @@
- {/if} - {@render trigger_badges?.()} -
-
- {#if allMenuItems.length > 0} - {#key allMenuItems} - - {/key} - {/if} - {#if wide.current} - - {/if} - {#each barButtons as btn (btn.label)} - {@const dropdownItems = dropdownItemsOf(btn)} - - {/each} -
-
-
-
+ {$triggersCount?.primary_schedule?.schedule ?? ''} + + {/if} + {@render trigger_badges?.()} + {#if allMenuItems.length > 0} + {#key allMenuItems} + + {/key} + {/if} + {#if wide.current} + + {/if} + {#each barButtons as btn (btn.label)} + {@const dropdownItems = dropdownItemsOf(btn)} + + {/each} +{/snippet} + + + diff --git a/frontend/src/lib/components/details/DetailPageLayout.svelte b/frontend/src/lib/components/details/DetailPageLayout.svelte index a6a2aa7a8a..769b10e82e 100644 --- a/frontend/src/lib/components/details/DetailPageLayout.svelte +++ b/frontend/src/lib/components/details/DetailPageLayout.svelte @@ -71,7 +71,9 @@ {#if useDesktopLayout}
{@render header?.()} -
+ +
{@render form?.({ graphInline: true })} @@ -102,7 +104,7 @@ {:else}
{@render header?.()} -
+
diff --git a/frontend/src/lib/components/details/ErrorHandlerToggleButton.svelte b/frontend/src/lib/components/details/ErrorHandlerToggleButton.svelte index 6097a235ca..c68e2304da 100644 --- a/frontend/src/lib/components/details/ErrorHandlerToggleButton.svelte +++ b/frontend/src/lib/components/details/ErrorHandlerToggleButton.svelte @@ -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 { 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={{ diff --git a/frontend/src/lib/components/flow_builder.ts b/frontend/src/lib/components/flow_builder.ts index f763e92faa..1a4b7e5c26 100644 --- a/frontend/src/lib/components/flow_builder.ts +++ b/frontend/src/lib/components/flow_builder.ts @@ -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 } diff --git a/frontend/src/lib/components/flows/FlowEditor.svelte b/frontend/src/lib/components/flows/FlowEditor.svelte index a9c3a40e8a..9d98d0535e 100644 --- a/frontend/src/lib/components/flows/FlowEditor.svelte +++ b/frontend/src/lib/components/flows/FlowEditor.svelte @@ -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') // 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), diff --git a/frontend/src/lib/components/home/HomeAIChat.svelte b/frontend/src/lib/components/home/HomeAIChat.svelte index a8e4d8b973..0cfe3bbc09 100644 --- a/frontend/src/lib/components/home/HomeAIChat.svelte +++ b/frontend/src/lib/components/home/HomeAIChat.svelte @@ -20,15 +20,7 @@ -{#snippet externalLinkHint()} - -{/snippet} - {#if !session || !runtime}
Session not found
{:else} @@ -411,95 +380,93 @@ sessions have their own empty-state affordances above. --> {#snippet sessionEmptyHint()}{/snippet} + {#snippet sessionHeader()} + renameSession(session.id, v)} + class="text-xs font-normal" + inputClass="!text-xs !font-normal" + /> + + 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()} + + + + {/snippet} + + + {#if actingIsFork} + + Acting on + + {/if} + {/snippet} + + {#if headerInPage} + + + {/if} +
-
- renameSession(session.id, v)} - class="text-sm font-semibold" - inputClass="!text-sm !font-semibold" - /> - 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()} - - - - {/snippet} - - {#if acting && hasFirstUserMessage} - -
- Acting on - - - - -
- {/if} -
@@ -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} - - - + {@const disc = compact ? 20 : 26} + {@const glyph = compact ? 12 : 16} + + + @@ -158,7 +174,7 @@ {/if} {/if} -
+
{#if !isCollapsed && label}
- 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 = { 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)) - ) - - - - {#snippet children({ createMenu })} - (showExtraTriggers = false)} - > - {#snippet triggr({ trigger, pinned })} - {@const iconColor = getContrastTextColor($workspaceColor)} - -
- -
- {/snippet} - {#snippet children({ item, builders })} -
- {#each favoriteLinks ?? [] as favorite (favorite.href)} - - - {#if favorite.kind == 'script'} - - {:else if favorite.kind == 'flow'} - - {:else if favorite.kind == 'app' || favorite.kind == 'raw_app'} - - {:else if favorite.kind == 'asset'} - - {/if} - - - {favorite.label} - - - {/each} -
- - {#each mainMenuLinks as menuLink (menuLink.href ?? menuLink.label)} - - {/each} - -
-
- - - Account settings - - - - - Take the tour - -
- -
- { - 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} - - {:else} - - {/if} - Switch theme - - - - {#if $superadmin} - - - Instance settings - - {/if} - - 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} - > - - Sign out - -
-
- {#snippet renderSecondMenuLinks(menuLinks: OperatorMenuLink[])} - {#each menuLinks as menuLink (menuLink.href ?? menuLink.label)} - - {menuLink.label} - - {/each} - {/snippet} - {#if secondMenuLinks.length || secondMenuTriggerLinks.length || extraTriggerLinks.length} -
- {#if secondMenuLinks.length}
{@render renderSecondMenuLinks(secondMenuLinks)}
{/if} - {#if secondMenuTriggerLinks.length}
{@render renderSecondMenuLinks(secondMenuTriggerLinks)}
{/if} - {#if extraTriggerLinks.length}
- -
{ - // 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 - }} - > - - - More triggers - -
- {#if showExtraTriggers} - {#each extraTriggerLinks as menuLink (menuLink.href)} - - {menuLink.label} - - {/each} - {/if} -
{/if} -
- {/if} - {#if $enterpriseLicense} - - {/if} -
-
- {/snippet} -
- {/snippet} -
- - diff --git a/frontend/src/lib/components/sidebar/SettingsMenu.svelte b/frontend/src/lib/components/sidebar/SettingsMenu.svelte index e0951f37cd..fed773c84c 100644 --- a/frontend/src/lib/components/sidebar/SettingsMenu.svelte +++ b/frontend/src/lib/components/sidebar/SettingsMenu.svelte @@ -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" diff --git a/frontend/src/lib/components/sidebar/SidebarContent.svelte b/frontend/src/lib/components/sidebar/SidebarContent.svelte index b43bc2988e..98451045b1 100644 --- a/frontend/src/lib/components/sidebar/SidebarContent.svelte +++ b/frontend/src/lib/components/sidebar/SidebarContent.svelte @@ -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 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)} {/each} - {#if extraTriggerLinks.length > 0 && !$userStore?.operator} + {#if extraTriggerLinks.length > 0 && allows('triggers')} {#snippet triggr({ trigger })} - 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() - - - - -{#if $subOpen} -
- - -
- 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 - -
-
-{/if} diff --git a/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte b/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte index ddcbd9ca95..3902715d5a 100644 --- a/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte +++ b/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte @@ -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)) - + {#snippet triggr({ trigger })} @@ -41,7 +51,7 @@ name={currentFamily?.name ?? $workspaceStore ?? ''} id={currentFamily?.id ?? $workspaceStore ?? ''} disablePopup={isCollapsed} - class="block w-full" + class={compact ? 'block' : 'block w-full'} > @@ -63,6 +75,11 @@ {#snippet children({ item })} - + menu?.close()} + /> {/snippet} diff --git a/frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte b/frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte index fa4468fa6a..7d0f88670f 100644 --- a/frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte +++ b/frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte @@ -1,5 +1,5 @@ -
+
{#snippet trigger()} {/snippet} diff --git a/frontend/src/lib/components/sidebar/navDetached.svelte.ts b/frontend/src/lib/components/sidebar/navDetached.svelte.ts new file mode 100644 index 0000000000..a5213b0336 --- /dev/null +++ b/frontend/src/lib/components/sidebar/navDetached.svelte.ts @@ -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('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) + } +} diff --git a/frontend/src/lib/components/sidebar/navHandlePlacement.svelte.ts b/frontend/src/lib/components/sidebar/navHandlePlacement.svelte.ts new file mode 100644 index 0000000000..7b8c901369 --- /dev/null +++ b/frontend/src/lib/components/sidebar/navHandlePlacement.svelte.ts @@ -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?.() + } +} diff --git a/frontend/src/lib/components/sidebar/navPages.ts b/frontend/src/lib/components/sidebar/navPages.ts new file mode 100644 index 0000000000..3e655077e1 --- /dev/null +++ b/frontend/src/lib/components/sidebar/navPages.ts @@ -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 + } +} diff --git a/frontend/src/lib/components/sidebar/operatorRoutes.ts b/frontend/src/lib/components/sidebar/operatorRoutes.ts index 1abd7aeb93..9e67921939 100644 --- a/frontend/src/lib/components/sidebar/operatorRoutes.ts +++ b/frontend/src/lib/components/sidebar/operatorRoutes.ts @@ -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 + /** - * 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 diff --git a/frontend/src/lib/components/tutorials/OperatorTour.svelte b/frontend/src/lib/components/tutorials/OperatorTour.svelte index cc7f4ab4a5..51574ab96e 100644 --- a/frontend/src/lib/components/tutorials/OperatorTour.svelte +++ b/frontend/src/lib/components/tutorials/OperatorTour.svelte @@ -1,8 +1,7 @@ + + + {#if workspace && path} diff --git a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte index 4d240cc24b..149681f297 100644 --- a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte @@ -552,6 +552,7 @@
{:else if renderEditor} { // The version the drawer showed as head; see /scripts/edit. diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte index 577f7132ce..82fd3f4151 100644 --- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte @@ -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 @@ }} /> + + + +{#snippet sessionPageActions()} + {#if previewCollapsed && !fullscreen} + + + {/if} +{/snippet} +
{#if embedded} -
-

AI Sessions are not available for operators

-

Use the Ask AI chat instead.

- -
{:else if !globalEnabled} @@ -945,7 +953,14 @@ {#if !fullscreen} - + +
{#each mountedChatSessions as s (s.id)}
- +
{/each}
@@ -970,7 +985,10 @@ maxSize={previewCollapsed ? 0 : 100} class="flex flex-col min-h-0" > -
+
- {/if}
{#if aiHiddenVerdict === undefined}