wip: one sidebar for operators and developers, with a detach mode

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-09-21 11:09:56 +02:00
co-authored by Claude Opus 5
parent c4c9677982
commit e0a33dc3d7
24 changed files with 571 additions and 842 deletions
@@ -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
@@ -4,7 +4,7 @@
import AiChat from './AIChat.svelte'
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'
@@ -48,8 +48,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
@@ -88,7 +88,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
@@ -2492,7 +2492,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()
}
/** Persist the opt-out choice, then hard-reload so every gated site re-reads it. */
@@ -40,7 +40,7 @@
import FlowPanelPlacementPicker from './common/FlowPanelPlacementPicker.svelte'
import { prefersSessionHandoff } from '../copilot/chat/global/gate'
import { openSourceInSession } from '$lib/components/sessions/sessionSwitch.svelte'
import { userStore, workspaceStore } from '$lib/stores'
import { workspaceStore } from '$lib/stores'
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
@@ -404,7 +404,7 @@
if (
!sessionScopedManager &&
sessionOpen &&
prefersSessionHandoff($userStore?.operator)
prefersSessionHandoff()
) {
void openSourceInSession(sessionOpen, {
previewParams: { selected: detail.moduleId },
@@ -107,16 +107,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 +147,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.
@@ -311,7 +309,7 @@
<div></div>
{/if}
<!-- Not AI-related, so shown even to operators / when the composer is hidden. -->
<!-- Not AI-related, so shown even when the composer is hidden. -->
<div class="flex flex-row items-center gap-1">
<Button
variant="subtle"
@@ -128,7 +128,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(() => {
@@ -41,7 +41,6 @@
import { AIBtnClasses } from '$lib/components/copilot/chat/AIButtonStyle'
import { prefersSessionHandoff } from '$lib/components/copilot/chat/global/gate'
import { copilotInfo } from '$lib/aiStore'
import { userStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { openSourceInSession } from './sessionSwitch.svelte'
@@ -66,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()
@@ -77,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($userStore?.operator)
!inSessionPanel && !!(source?.target || source?.page) && prefersSessionHandoff()
)
// Not $state: only read inside open() as a re-entrancy latch, never rendered.
@@ -57,7 +57,7 @@
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.
// control (the detached sidebar's handle) sitting at the screen's top-left.
let {
sessionId,
headerInset = false
@@ -4,6 +4,7 @@
import { classes as alertClasses, icons as alertIcons } from '$lib/components/common/alert/model'
import { setSessionsBetaOptOut } from '$lib/components/copilot/chat/global/gate'
import { ExternalLink } from 'lucide-svelte'
import { userStore } from '$lib/stores'
// Prefilled title marks the issue as beta feedback for triage.
const FEEDBACK_ISSUE_URL =
@@ -42,15 +43,18 @@
>
Give feedback
</Button>
<span>·</span>
<Button
variant="subtle"
size="xs"
btnClasses="!py-0.5"
onclick={() => setSessionsBetaOptOut(true, `${base}/`)}
>
Switch back to legacy chat
</Button>
<!-- Operators have no legacy chat to fall back to. -->
{#if !$userStore?.operator}
<span>·</span>
<Button
variant="subtle"
size="xs"
btnClasses="!py-0.5"
onclick={() => setSessionsBetaOptOut(true, `${base}/`)}
>
Switch back to legacy chat
</Button>
{/if}
{:else}
<span class="font-medium">Try AI Sessions (beta)</span>
<span>·</span>
@@ -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"
@@ -72,6 +72,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,6 +189,11 @@
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'
)
@@ -205,6 +211,7 @@
label: 'Runs',
href: `${base}/runs`,
icon: Play,
disabled: !allows('runs'),
aiId: 'sidebar-menu-link-runs',
aiDescription: 'Button to navigate to runs',
onclick: () => {
@@ -217,7 +224,7 @@
label: 'Variables',
href: `${base}/variables`,
icon: DollarSign,
disabled: $userStore?.operator,
disabled: !allows('variables'),
aiId: 'sidebar-menu-link-variables',
aiDescription: 'Button to navigate to variables'
},
@@ -225,7 +232,7 @@
label: 'Resources',
href: `${base}/resources`,
icon: Boxes,
disabled: $userStore?.operator,
disabled: !allows('resources'),
aiId: 'sidebar-menu-link-resources',
aiDescription: 'Button to navigate to resources'
},
@@ -233,6 +240,7 @@
label: 'Assets',
href: `${base}/assets`,
icon: Pyramid,
disabled: !allows('assets'),
aiId: 'sidebar-menu-link-assets',
aiDescription: 'Button to navigate to assets'
},
@@ -240,7 +248,7 @@
label: 'Folders',
href: `${base}/folders`,
icon: FolderOpen,
disabled: $userStore?.operator,
disabled: !allows('folders'),
aiId: 'sidebar-menu-link-folders',
aiDescription: 'Button to navigate to folders'
},
@@ -248,7 +256,7 @@
label: 'Groups',
href: `${base}/groups`,
icon: Users,
disabled: $userStore?.operator,
disabled: !allows('groups'),
aiId: 'sidebar-menu-link-groups',
aiDescription: 'Button to navigate to groups'
}
@@ -259,7 +267,7 @@
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 +276,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 +285,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 +294,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 +303,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 +312,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 +321,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 +330,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 +339,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 +348,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 +357,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 +383,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 +398,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 +467,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 +516,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 +558,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}
@@ -55,6 +55,7 @@
showChevron
emphasizeLabel
disableTitle
labelClass="wm-workspace-name"
{trigger}
/>
</NameIdTooltip>
@@ -50,8 +50,7 @@
strictWorkspaceSelect?: boolean
// 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
}
@@ -117,7 +116,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) : [])
@@ -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)
}
}
@@ -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 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
+447 -281
View File
@@ -11,7 +11,13 @@
UserService,
WorkspaceService
} from '$lib/gen'
import { capitalize, classNames, getModifierKey, sendUserToast } from '$lib/utils'
import {
capitalize,
classNames,
getContrastTextColor,
getModifierKey,
sendUserToast
} from '$lib/utils'
import { useLocalStorageValue } from '$lib/svelte5Utils.svelte'
import WorkspaceMenu from '$lib/components/sidebar/WorkspaceMenu.svelte'
import SidebarContent from '$lib/components/sidebar/SidebarContent.svelte'
@@ -47,6 +53,7 @@
nonMemberWorkspaces,
setNonMemberWorkspaces,
clearNonMemberWorkspaces,
workspaceColor,
type UserWorkspace
} from '$lib/stores'
import CenteredModal from '$lib/components/CenteredModal.svelte'
@@ -64,12 +71,22 @@
} from '$lib/components/sidebar/FavoriteMenu.svelte'
import { SUPERADMIN_SETTINGS_HASH, USER_SETTINGS_HASH } from '$lib/components/sidebar/settings'
import { isCloudHosted } from '$lib/cloud'
import { PanelLeftClose, PanelLeftOpen, Home, Play, Search, WandSparkles } from 'lucide-svelte'
import {
PanelLeft,
PanelLeftClose,
PanelLeftDashed,
PanelLeftOpen,
Home,
Play,
Search,
WandSparkles
} from 'lucide-svelte'
import { getUserExt } from '$lib/user'
import { confirmPendingLoginMethod } from '$lib/lastLoginMethod'
import { deepEqual } from 'fast-equals'
import { twMerge } from 'tailwind-merge'
import OperatorMenu from '$lib/components/sidebar/OperatorMenu.svelte'
import { navDetached } from '$lib/components/sidebar/navDetached.svelte'
import { sidebarPageAllowed } from '$lib/components/sidebar/operatorRoutes'
import GlobalSearchModal from '$lib/components/search/GlobalSearchModal.svelte'
import MenuButton from '$lib/components/sidebar/MenuButton.svelte'
import MenuLink from '$lib/components/sidebar/MenuLink.svelte'
@@ -81,8 +98,9 @@
import DraftMigrationErrorModal from '$lib/components/DraftMigrationErrorModal.svelte'
import InstanceBanner from '$lib/components/InstanceBanner.svelte'
import { onDestroy, setContext, untrack } from 'svelte'
import { cubicInOut, cubicOut } from 'svelte/easing'
import { base } from '$app/paths'
import { Menubar } from '$lib/components/meltComponents'
import { Menubar, Tooltip } from '$lib/components/meltComponents'
import { aiChatManager } from '$lib/components/copilot/chat/AIChatManager.svelte'
import AiChatLayout from '$lib/components/copilot/chat/AiChatLayout.svelte'
import SessionPicker from '$lib/components/sessions/SessionPicker.svelte'
@@ -133,10 +151,13 @@
// Persisted nav-rail collapse preference. A deliberate collapse writes to it —
// the manual toggle and a drag past the collapse threshold. The contextual
// auto-collapse (app-mode routes, narrow widths) mutates the in-memory
// `isCollapsed` without persisting, so it stays transient and never gets
// `collapsedState` without persisting, so it stays transient and never gets
// "stuck" collapsed across reloads.
const collapsePref = useLocalStorageValue<boolean>('nav_menu_collapsed', false, 'boolean')
let isCollapsed = $state(collapsePref.val)
let collapsedState = $state(collapsePref.val)
// Operators never get the icon-only rail: their docked sidebar is always expanded (a sidebar
// they want out of the way is what detaching is for).
let isCollapsed = $derived(collapsedState && !$userStore?.operator)
// Resizable desktop rail, sized in REM so it scales with the root font-size the
// same way the old `w-52`/`w-12` classes did — `:root` jumps to 18px past 1760px
@@ -470,6 +491,73 @@
})
let innerWidth = $state(BROWSER ? window.innerWidth : 2000)
// The drawer holds the sidebar below 768px and, at any width, while it is detached.
let useDrawer = $derived(innerWidth < 768 || navDetached.val)
// Below 768px the burger row opens the drawer; wider, a detached sidebar has a floating handle.
let detachedFloating = $derived(navDetached.val && innerWidth >= 768)
let handleIconColor = $derived(getContrastTextColor($workspaceColor))
let onHome = $derived(page.url.pathname === `${base}/`)
let currentWorkspaceName = $derived(
$userWorkspaces?.find((w) => w.id === $workspaceStore)?.name ?? $workspaceStore ?? ''
)
// Set once the user docks or detaches, so the rail that mounts from then on skips its
// app-entry animation (`wm-sidebar-in`) and only plays `railMorph`.
let dockToggled = $state(false)
function setDetached(detached: boolean) {
dockToggled = true
menuOpen = false
navDetached.val = detached
}
// Docking and detaching move and resize the rail itself between its docked box and the
// detached card's (top 44px, bottom and left insets, rounded corners); detaching also slides
// it off the left edge, where the closed card lives. No opacity: the rail never fades.
const RAIL_MORPH_MS = 200
function railMorph(_node: HTMLElement, { offscreen }: { offscreen: boolean }) {
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
// The card is always the default width; a rail the user widened resizes to or from it.
const docked = railWidth
return {
duration: reduced ? 0 : RAIL_MORPH_MS,
easing: cubicOut,
css: (t: number, u: number) =>
`top:${u * 44}px; bottom:${u * 8}px; left:${u * 4}px; border-radius:${u * 8}px; overflow:hidden;` +
`width:${SIDEBAR_MIN_REM + (docked - SIDEBAR_MIN_REM) * t}rem;` +
(offscreen ? `transform:translateX(calc(${-u} * (100% + 0.5rem)));` : '')
}
}
// The home-page workspace name travels to the card's workspace picker as the card opens,
// and back as it closes, so the name reads as one label joining the picker. It aims where
// the picker will be once the card has finished sliding (the card's current translate is
// undone), grows to the picker's font size, and fades out as it arrives (in as it leaves),
// so the handover to the picker's own label has no hard cut.
// Any other mount or unmount of the name (arriving on or leaving home) plays nothing.
function joinPicker(node: HTMLElement) {
const card = document.querySelector<HTMLElement>('[data-nav-card]')
const target = card?.querySelector<HTMLElement>('.wm-workspace-name')
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
if (!card || !target || reduced) return { duration: 0 }
const slide = new DOMMatrix(getComputedStyle(card).transform).m41
// Opening: the card is still off-screen, sliding in. Closing: it is still fully open.
const cardIsMoving = menuOpen ? slide !== 0 : slide === 0
if (!cardIsMoving) return { duration: 0 }
const from = node.getBoundingClientRect()
const to = target.getBoundingClientRect()
const dx = to.left - (menuOpen ? slide : 0) - from.left
const dy = to.top + to.height / 2 - (from.top + from.height / 2)
const scale =
parseFloat(getComputedStyle(target).fontSize) / parseFloat(getComputedStyle(node).fontSize)
return {
duration: 180,
easing: cubicInOut,
css: (_t: number, u: number) =>
`transform-origin: left center; transform: translate(${dx * u}px, ${dy * u}px) scale(${1 + (scale - 1) * u}); opacity: ${1 - u};`
}
}
// Matches the edge band's `duration-200`, so the drawer opens as the tint finishes.
const EDGE_OPEN_DELAY_MS = 200
let edgeOpenTimer: ReturnType<typeof setTimeout> | undefined
function onLoad() {
loadFavorites()
@@ -626,8 +714,8 @@
})
function changeCollapsed() {
if (innerWidth < 1248 && innerWidth >= 768 && !isCollapsed) {
isCollapsed = true
if (innerWidth < 1248 && innerWidth >= 768 && !collapsedState) {
collapsedState = true
}
}
@@ -735,7 +823,7 @@
$effect(() => {
const ws = $workspaceStore
const ready = sessionState.hydrated && $usersWorkspaceStore !== undefined
if (globalAiEnabled && ready && ws && !$userStore?.operator) {
if (globalAiEnabled && ready && ws) {
untrack(() => restoreSessionBackups(ws))
}
})
@@ -841,11 +929,6 @@
$effect(() => {
$workspaceStore && $userStore && untrack(() => loadDefaultScripts($workspaceStore!, $userStore))
})
$effect(() => {
if (isCollapsed && $userStore?.operator) {
isCollapsed = false
}
})
$effect(() => {
if (
$enterpriseLicense &&
@@ -914,6 +997,11 @@
label="Runs"
href={`${base}/runs`}
icon={Play}
disabled={!sidebarPageAllowed(
$userStore?.operator,
$userWorkspaces?.find((w) => w.id === $workspaceStore),
'runs'
)}
isCollapsed={collapsed}
aiId="sidebar-menu-link-runs"
aiDescription="Button to navigate to runs"
@@ -989,332 +1077,413 @@
<div class="fixed inset-0 z-50 cursor-col-resize select-none"></div>
{/if}
{#if !menuHidden}
{#if !$userStore?.operator}
{#if innerWidth < 768}
<div
class={classNames(
'relative',
menuOpen ? 'z-40' : 'pointer-events-none',
devOnly ? 'hidden' : ''
)}
role="dialog"
aria-modal="true"
>
{#if detachedFloating && !menuOpen && !devOnly}
<!-- Edge band: the other way to reach a detached sidebar. It opens only once its
tint has faded in, so a pointer brushing the screen edge does not open it. -->
<div
class="fixed inset-y-0 left-0 w-2 z-[4999] transition-colors duration-200 ease-out hover:bg-surface-accent-selected"
aria-hidden="true"
onmouseenter={() => {
clearTimeout(edgeOpenTimer)
edgeOpenTimer = setTimeout(() => (menuOpen = true), EDGE_OPEN_DELAY_MS)
}}
onmouseleave={() => clearTimeout(edgeOpenTimer)}
></div>
{/if}
{#if detachedFloating && !devOnly}
<!-- The detached sidebar's handle, kept visible above the open card. Hover opens the
card; a click docks the sidebar again. -->
<div class="absolute top-1 left-1 z5000 flex items-center">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div data-nav-handle onmouseenter={() => (menuOpen = true)}>
<MenuButton
class="!text-xs"
buttonClass="!pl-3.5 !pr-1 !w-auto"
icon={PanelLeft}
isCollapsed={false}
lightMode
color={$workspaceColor ?? 'rgb(var(--color-surface-sunken))'}
iconProps={handleIconColor ? { style: `color: ${handleIconColor}` } : undefined}
ariaLabel="Attach sidebar"
on:click={() => setDetached(false)}
/>
</div>
<!-- Plain text, outside the handle's hover area: on home it names the workspace,
with a ground so it stays legible over the page scrolling under it. Hidden
while the card is open, which would otherwise sit under it. -->
{#if onHome && !menuOpen}
<span
class="pl-0.5 pr-1.5 py-0.5 rounded-md bg-surface text-xs text-secondary truncate"
transition:joinPicker
>
{currentWorkspaceName}
</span>
{/if}
</div>
{/if}
{#if useDrawer}
<div
class={classNames(
'relative',
menuOpen ? 'z-40' : 'pointer-events-none',
devOnly ? 'hidden' : ''
)}
role="dialog"
aria-modal="true"
>
<!-- The detached card floats over the page without dimming it: it opens on hover,
so a backdrop would flash on every pass of the left edge. -->
{#if !detachedFloating}
<div
class={classNames(
'fixed inset-0 bg-black/50 transition-opacity ease-linear duration-300 z-40',
menuOpen ? 'opacity-100' : 'opacity-0'
)}
></div>
{/if}
<div class="fixed inset-0 flex z-40">
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<!-- Detached, the drawer is a card below the handle (pt-11 clears it) rather than a
full-height panel; the handle stays on top to show where it came from. -->
<div
class={classNames('fixed inset-0 flex z-40', detachedFloating ? 'pt-11 pl-1 pb-2' : '')}
onclick={(e) => {
if (e.target === e.currentTarget) menuOpen = false
}}
>
<div
data-nav-card
class={classNames(
'relative flex-1 flex flex-col max-w-min w-full bg-surface transition ease-in-out duration-300 transform',
detachedFloating ? 'rounded-lg border shadow-lg overflow-hidden' : '',
menuOpen
? 'translate-x-0'
: detachedFloating
? '-translate-x-[calc(100%+0.5rem)]'
: '-translate-x-full'
)}
>
<div
class={classNames(
'relative flex-1 flex flex-col max-w-min w-full bg-surface transition ease-in-out duration-300 transform',
menuOpen ? 'translate-x-0' : '-translate-x-full'
'absolute top-0 right-4 -mr-12 pt-2 ease-in-out duration-300',
menuOpen ? 'opacity-100' : 'opacity-0',
detachedFloating ? 'hidden' : ''
)}
>
<div
class={classNames(
'absolute top-0 right-4 -mr-12 pt-2 ease-in-out duration-300',
menuOpen ? 'opacity-100' : 'opacity-0'
)}
<button
type="button"
onclick={() => {
menuOpen = !menuOpen
}}
class="ml-1 flex items-center justify-center h-6 w-6 rounded-full focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white border border-white"
aria-label="Close"
>
<button
type="button"
onclick={() => {
menuOpen = !menuOpen
}}
class="ml-1 flex items-center justify-center h-6 w-6 rounded-full focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white border border-white"
aria-label="Close"
<svg
class="h-4 w-4 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
aria-hidden="true"
>
<svg
class="h-4 w-4 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div
class="h-full flex flex-col"
style:background-color={darkMode ? SIDEBAR_BG_DARK : SIDEBAR_BG}
>
<!-- Workspace picker as the drawer header (replaces the Windmill logo). -->
<div class="flex-shrink-0 px-2 h-12 w-52 flex items-center">
<Menubar class="w-full">
{#snippet children({ createMenu })}
<WorkspaceMenu {createMenu} />
{/snippet}
</Menubar>
</div>
<div
class="h-full flex flex-col"
style:background-color={darkMode ? SIDEBAR_BG_DARK : SIDEBAR_BG}
>
<!-- Workspace picker as the drawer header (replaces the Windmill logo). -->
<div class="flex-shrink-0 px-2 h-12 w-52 flex items-center">
<Menubar class="w-full">
{#snippet children({ createMenu })}
<WorkspaceMenu {createMenu} />
{/snippet}
</Menubar>
{#if !embedded && sessionsSwitchShown}
<!-- The switch: workspace navigation ⇄ sessions sidebar. -->
<div class="px-2 pb-1 w-52">
<SessionModeSwitch
mode={sessionMode ? 'session' : 'nav'}
onToggle={() => (preserveMenuOnNextNav = true)}
/>
</div>
{/if}
{#if !embedded && sessionsSwitchShown}
<!-- The switch: workspace navigation ⇄ sessions sidebar. -->
<div class="px-2 pb-1 w-52">
<SessionModeSwitch
mode={sessionMode ? 'session' : 'nav'}
onToggle={() => (preserveMenuOnNextNav = true)}
/>
</div>
{/if}
{#if !sessionMode}
<!-- Workspace scope (fork picker): part of the top workspace group. -->
<div class="pb-1 w-52 {sessionsSwitchShown ? '' : '-mt-1'}">
<WorkspaceScopeHeader isCollapsed={false} />
</div>
{/if}
{#if !sessionMode}
<!-- Workspace scope (fork picker): part of the top workspace group. -->
<div class="pb-1 w-52 {sessionsSwitchShown ? '' : '-mt-1'}">
<WorkspaceScopeHeader isCollapsed={false} />
</div>
{/if}
{#if sessionMode}
<!-- Session mode: the session list owns the rail.
{#if sessionMode}
<!-- Session mode: the session list owns the rail.
w-52 cap (matches the desktop sidebar width): the drawer is
max-w-min, and long session titles (nowrap before truncation)
would otherwise inflate its min-content width to the full
text width. -->
<div class="px-2 py-2 w-52">
<SessionPicker isCollapsed={false} embedded collapsible={false} />
</div>
{@render settingsMenu(false)}
{:else}
<!-- Navigation mode: Home → Settings scroll as ONE block with an
<div class="px-2 py-2 w-52 flex-1 min-h-0 overflow-y-auto">
<SessionPicker isCollapsed={false} embedded collapsible={false} />
</div>
{@render settingsMenu(false)}
{:else}
<!-- Navigation mode: Home → Settings scroll as ONE block with an
overflow fade hint (same treatment as the desktop rail). -->
<SidebarScrollArea color={darkMode ? SIDEBAR_BG_DARK : SIDEBAR_BG}>
<!-- Section 1: Home/Runs + Favorites + Search -->
<div class="px-2 pt-1 w-52 flex flex-col gap-1">
{@render quickLinks(false)}
<Menubar>
{#snippet children({ createMenu })}
<FavoriteMenu {createMenu} favoriteLinks={favoriteManager.current} />
{/snippet}
</Menubar>
<SidebarScrollArea color={darkMode ? SIDEBAR_BG_DARK : SIDEBAR_BG}>
<!-- Section 1: Home/Runs + Favorites + Search -->
<div class="px-2 pt-1 w-52 flex flex-col gap-1">
{@render quickLinks(false)}
<Menubar>
{#snippet children({ createMenu })}
<FavoriteMenu {createMenu} favoriteLinks={favoriteManager.current} />
{/snippet}
</Menubar>
<MenuButton
stopPropagationOnClick={true}
on:click={() => openSearchModal()}
isCollapsed={false}
icon={Search}
label="Search"
class="!text-xs"
shortcut={`${getModifierKey()}k`}
/>
{#if askAiShown}
<!-- Legacy Ask-AI pane, shown only when the user opted out of the
AI Sessions beta (otherwise SessionModeSwitch replaces it). -->
<MenuButton
stopPropagationOnClick={true}
on:click={() => openSearchModal()}
on:click={() => aiChatManager.toggleOpen()}
isCollapsed={false}
icon={Search}
label="Search"
icon={WandSparkles}
iconProps={{ forceDarkMode: true }}
label="Ask AI"
class="!text-xs"
shortcut={`${getModifierKey()}k`}
iconClasses="!text-ai"
shortcut={`${getModifierKey()}L`}
/>
{#if askAiShown}
<!-- Legacy Ask-AI pane, shown only when the user opted out of the
AI Sessions beta (otherwise SessionModeSwitch replaces it). -->
<MenuButton
stopPropagationOnClick={true}
on:click={() => aiChatManager.toggleOpen()}
isCollapsed={false}
icon={WandSparkles}
iconProps={{ forceDarkMode: true }}
label="Ask AI"
class="!text-xs"
iconClasses="!text-ai"
shortcut={`${getModifierKey()}L`}
/>
{/if}
</div>
{/if}
</div>
<!-- Section 2: workspace items -->
<SidebarContent
isCollapsed={false}
showSecondary={false}
excludeMainLabels={['Home', 'Runs']}
numUnacknowledgedCriticalAlerts={isCriticalAlertsUiMuted
? 0
: numUnacknowledgedCriticalAlerts}
/>
<!-- Section 2: workspace items -->
<SidebarContent
isCollapsed={false}
showSecondary={false}
excludeMainLabels={['Home', 'Runs']}
numUnacknowledgedCriticalAlerts={isCriticalAlertsUiMuted
? 0
: numUnacknowledgedCriticalAlerts}
/>
<!-- Section 3: instance settings — mt-auto drops it to the bottom
<!-- Section 3: instance settings — mt-auto drops it to the bottom
when there is spare room (see the desktop rail for the rationale). -->
<div class="mt-auto">
{@render settingsMenu(false)}
</div>
</SidebarScrollArea>
<div class="mt-auto">
{@render settingsMenu(false)}
</div>
</SidebarScrollArea>
{/if}
<div class="w-52">
{@render accountSetupBanner(false)}
<SidebarUsage isCollapsed={false} />
</div>
<div class="px-4 pt-3 pb-3.5 w-52 flex items-center justify-between">
{@render brandMark(false)}
{#if detachedFloating}
<Tooltip class="flex" placement="top" small>
<button
class="p-2 -m-2 rounded hover:bg-surface-hover"
aria-label="Attach sidebar"
onclick={() => setDetached(false)}
>
<PanelLeft size={14} class="flex-shrink-0 h-3.5 w-3.5 text-hint" />
</button>
{#snippet text()}Attach sidebar{/snippet}
</Tooltip>
{/if}
<div class="w-52">
{@render accountSetupBanner(false)}
<SidebarUsage isCollapsed={false} />
</div>
<div class="px-4 pt-3 pb-3.5 w-52">
{@render brandMark(false)}
</div>
</div>
</div>
</div>
</div>
{:else}
</div>
{:else}
<div
id="sidebar"
class={classNames(
dockToggled ? '' : 'wm-sidebar-in',
'flex flex-col fixed inset-y-0 z-40 ',
sidebarTransitionClass,
devOnly ? '!hidden' : ''
)}
style:width="{railWidth}rem"
in:railMorph={{ offscreen: false }}
out:railMorph={{ offscreen: true }}
>
<div
id="sidebar"
class={classNames(
'wm-sidebar-in flex flex-col fixed inset-y-0 z-40 ',
sidebarTransitionClass,
devOnly ? '!hidden' : ''
)}
style:width="{railWidth}rem"
class="flex-1 flex flex-col min-h-0 shadow-[inset_-1px_0_0_0_rgb(var(--color-border-light))] dark:shadow-[inset_-1px_0_0_0_#374151] [html.github-dark_&]:shadow-[inset_-1px_0_0_0_rgb(var(--color-border-light))]"
style:background-color={darkMode ? SIDEBAR_BG_DARK : SIDEBAR_BG}
>
<div
class="flex-1 flex flex-col min-h-0 h-screen shadow-[inset_-1px_0_0_0_rgb(var(--color-border-light))] dark:shadow-[inset_-1px_0_0_0_#374151] [html.github-dark_&]:shadow-[inset_-1px_0_0_0_rgb(var(--color-border-light))]"
style:background-color={darkMode ? SIDEBAR_BG_DARK : SIDEBAR_BG}
>
{#if !isCollapsed}
<!-- Resize handle straddling the right edge, only while expanded:
{#if !isCollapsed}
<!-- Resize handle straddling the right edge, only while expanded:
drag to widen (clamped at the min). Collapsing is the toggle
button's job. -->
<div
role="separator"
aria-orientation="vertical"
aria-label="Resize sidebar"
title="Drag to resize"
class={classNames(
'absolute inset-y-0 -right-0.5 w-1.5 cursor-col-resize z-50 transition-colors',
resizingSidebar ? '' : 'hover:bg-surface-hover'
)}
onpointerdown={startSidebarResize}
></div>
{/if}
<!-- Workspace picker as the sidebar header (replaces the Windmill logo).
<div
role="separator"
aria-orientation="vertical"
aria-label="Resize sidebar"
title="Drag to resize"
class={classNames(
'absolute inset-y-0 -right-0.5 w-1.5 cursor-col-resize z-50 transition-colors',
resizingSidebar ? '' : 'hover:bg-surface-hover'
)}
onpointerdown={startSidebarResize}
></div>
{/if}
<!-- Workspace picker as the sidebar header (replaces the Windmill logo).
Kept in both modes: it scopes which workspace family's sessions
the sessions sidebar shows. -->
<div class="flex-shrink-0 px-2 h-12 flex items-center">
<Menubar class="w-full">
{#snippet children({ createMenu })}
<WorkspaceMenu {createMenu} {isCollapsed} />
{/snippet}
</Menubar>
<div class="flex-shrink-0 px-2 h-12 flex items-center">
<Menubar class="w-full">
{#snippet children({ createMenu })}
<WorkspaceMenu {createMenu} {isCollapsed} />
{/snippet}
</Menubar>
</div>
{#if !embedded && sessionsSwitchShown}
<!-- The switch: workspace navigation ⇄ sessions sidebar. -->
<div class="px-2 pb-1 {isCollapsed ? 'flex justify-center' : ''}">
<SessionModeSwitch mode={sessionMode ? 'session' : 'nav'} {isCollapsed} />
</div>
{/if}
{#if !embedded && sessionsSwitchShown}
<!-- The switch: workspace navigation ⇄ sessions sidebar. -->
<div class="px-2 pb-1 {isCollapsed ? 'flex justify-center' : ''}">
<SessionModeSwitch mode={sessionMode ? 'session' : 'nav'} {isCollapsed} />
</div>
{/if}
{#if !sessionMode}
<!-- Workspace scope (fork picker): part of the top workspace group,
{#if !sessionMode}
<!-- Workspace scope (fork picker): part of the top workspace group,
together with the family menu and the mode switch above. Without
the switch, pull it up so the group still reads as one block. -->
<div class="pb-1 {sessionsSwitchShown ? '' : '-mt-1'}">
<WorkspaceScopeHeader {isCollapsed} />
</div>
{/if}
<div class="pb-1 {sessionsSwitchShown ? '' : '-mt-1'}">
<WorkspaceScopeHeader {isCollapsed} />
</div>
{/if}
{#if sessionMode}
<!-- Session mode: the session list owns the rail. Workspace nav moves
{#if sessionMode}
<!-- Session mode: the session list owns the rail. Workspace nav moves
into the preview side panel (the iframe renders its own nav). -->
<div class="px-2 py-2 flex flex-col gap-1 flex-1 min-h-0 overflow-y-auto">
<SessionPicker {isCollapsed} embedded collapsible={false} />
</div>
{@render settingsMenu(isCollapsed)}
{:else}
<!-- Navigation mode: Home → Settings scroll as ONE block with an
<div class="px-2 py-2 flex flex-col gap-1 flex-1 min-h-0 overflow-y-auto">
<SessionPicker {isCollapsed} embedded collapsible={false} />
</div>
{@render settingsMenu(isCollapsed)}
{:else}
<!-- Navigation mode: Home → Settings scroll as ONE block with an
overflow fade hint. The three sections (Home/Runs + Favorites +
Search / workspace items / instance settings) are spaced by the
scroll area's gap rather than each scrolling on its own. -->
<SidebarScrollArea color={darkMode ? SIDEBAR_BG_DARK : SIDEBAR_BG}>
<!-- Section 1: Home/Runs + Favorites + Search -->
<div class="px-2 pt-1 flex flex-col gap-1">
{@render quickLinks(isCollapsed)}
<Menubar class="flex flex-col gap-1">
{#snippet children({ createMenu })}
<FavoriteMenu
{createMenu}
favoriteLinks={favoriteManager.current}
{isCollapsed}
/>
{/snippet}
</Menubar>
<SidebarScrollArea color={darkMode ? SIDEBAR_BG_DARK : SIDEBAR_BG}>
<!-- Section 1: Home/Runs + Favorites + Search -->
<div class="px-2 pt-1 flex flex-col gap-1">
{@render quickLinks(isCollapsed)}
<Menubar class="flex flex-col gap-1">
{#snippet children({ createMenu })}
<FavoriteMenu
{createMenu}
favoriteLinks={favoriteManager.current}
{isCollapsed}
/>
{/snippet}
</Menubar>
<MenuButton
stopPropagationOnClick={true}
on:click={() => openSearchModal()}
{isCollapsed}
icon={Search}
label="Search"
class="!text-xs"
shortcut={`${getModifierKey()}k`}
/>
{#if askAiShown}
<!-- Legacy Ask-AI pane, shown only when the user opted out of the
AI Sessions beta (otherwise SessionModeSwitch replaces it). -->
<MenuButton
stopPropagationOnClick={true}
on:click={() => openSearchModal()}
on:click={() => aiChatManager.toggleOpen()}
{isCollapsed}
icon={Search}
label="Search"
icon={WandSparkles}
iconProps={{ forceDarkMode: true }}
label="Ask AI"
class="!text-xs"
shortcut={`${getModifierKey()}k`}
iconClasses="!text-ai"
shortcut={`${getModifierKey()}L`}
/>
{#if askAiShown}
<!-- Legacy Ask-AI pane, shown only when the user opted out of the
AI Sessions beta (otherwise SessionModeSwitch replaces it). -->
<MenuButton
stopPropagationOnClick={true}
on:click={() => aiChatManager.toggleOpen()}
{isCollapsed}
icon={WandSparkles}
iconProps={{ forceDarkMode: true }}
label="Ask AI"
class="!text-xs"
iconClasses="!text-ai"
shortcut={`${getModifierKey()}L`}
/>
{/if}
</div>
{/if}
</div>
<!-- Section 2: workspace items -->
<SidebarContent
{isCollapsed}
showSecondary={false}
excludeMainLabels={['Home', 'Runs']}
numUnacknowledgedCriticalAlerts={isCriticalAlertsUiMuted
? 0
: numUnacknowledgedCriticalAlerts}
/>
<!-- Section 2: workspace items -->
<SidebarContent
{isCollapsed}
showSecondary={false}
excludeMainLabels={['Home', 'Runs']}
numUnacknowledgedCriticalAlerts={isCriticalAlertsUiMuted
? 0
: numUnacknowledgedCriticalAlerts}
/>
<!-- Section 3: instance settings — mt-auto drops it to the bottom
<!-- Section 3: instance settings — mt-auto drops it to the bottom
when the column has spare room, and collapses back to the normal
inter-section gap once the content overflows and scrolls. -->
<div class="mt-auto">
{@render settingsMenu(isCollapsed)}
</div>
</SidebarScrollArea>
{/if}
<div class="mt-auto">
{@render settingsMenu(isCollapsed)}
</div>
</SidebarScrollArea>
{/if}
<div class="flex-shrink-0">
{@render accountSetupBanner(isCollapsed)}
<SidebarUsage {isCollapsed} />
</div>
<div class="flex-shrink-0">
{@render accountSetupBanner(isCollapsed)}
<SidebarUsage {isCollapsed} />
</div>
<div
class="flex-shrink-0 flex pt-3 pb-3.5 {isCollapsed
? 'flex-col items-center gap-3'
: 'items-center justify-between px-4'}"
>
{@render brandMark(isCollapsed)}
<div
class="flex-shrink-0 flex pt-3 pb-3.5 {isCollapsed
? 'flex-col items-center gap-3'
: 'items-center justify-between px-4'}"
>
{@render brandMark(isCollapsed)}
<div class="flex {isCollapsed ? 'flex-col gap-3' : 'gap-3'}">
<!-- p-2/-m-2 widens the hit area to ~32px without moving the icon. -->
<button
class="p-2 -m-2 rounded hover:bg-surface-hover"
title={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
onclick={() => {
isCollapsed = !isCollapsed
// Manual toggle is the persisted preference (auto-collapse isn't).
collapsePref.val = isCollapsed
}}
>
{#if isCollapsed}
<PanelLeftOpen size={14} class="flex-shrink-0 h-3.5 w-3.5 text-hint" />
{:else}
<PanelLeftClose size={14} class="flex-shrink-0 h-3.5 w-3.5 text-hint" />
{/if}
</button>
<Tooltip class="flex" placement="top" small>
<button
class="p-2 -m-2 rounded hover:bg-surface-hover"
aria-label="Detach sidebar"
onclick={() => setDetached(true)}
>
<PanelLeftDashed size={14} class="flex-shrink-0 h-3.5 w-3.5 text-hint" />
</button>
{#snippet text()}Detach sidebar{/snippet}
</Tooltip>
{#if !$userStore?.operator}
<button
class="p-2 -m-2 rounded hover:bg-surface-hover"
title={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
onclick={() => {
collapsedState = !collapsedState
// Manual toggle is the persisted preference (auto-collapse isn't).
collapsePref.val = collapsedState
}}
>
{#if isCollapsed}
<PanelLeftOpen size={14} class="flex-shrink-0 h-3.5 w-3.5 text-hint" />
{:else}
<PanelLeftClose size={14} class="flex-shrink-0 h-3.5 w-3.5 text-hint" />
{/if}
</button>
{/if}
</div>
</div>
</div>
{/if}
{:else}
<div class="absolute top-1 left-1 z5000">
<OperatorMenu favoriteLinks={favoriteManager.current} />
</div>
{/if}
<!-- Legacy menu -->
@@ -1454,18 +1623,15 @@
</button>
</div>
{/if}
<!-- Operators are exempt from the sessions beta: their minimal sidebar has
no Workspace ⇄ Sessions switch, so disabling the docked chat would leave
their "Ask AI" button toggling an unmounted pane. -->
<AiChatLayout
{children}
noPadding={devOnly || menuHidden}
disableAi={globalAiEnabled && !$userStore?.operator ? true : sessionMode}
disableAi={globalAiEnabled ? true : sessionMode}
loadAiConfig={!sessionMode}
showSessionsBetaBanner={!$userStore?.operator}
showSessionsBetaBanner
sidebarWidth={railWidth}
transitionClass={sidebarTransitionClass}
isMobile={innerWidth < 768}
isMobile={useDrawer}
onMenuOpen={() => {
menuOpen = true
}}
@@ -270,8 +270,8 @@
<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 />
@@ -507,7 +507,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)
@@ -39,7 +39,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,
@@ -55,6 +55,7 @@
import { registerToolDisplayActionHandler } from '$lib/components/copilot/chat/createdResourceActions.svelte'
import { previewTargetForSessionTarget } from '$lib/components/sessions/sessionPreviewTabs.svelte'
import { base } from '$lib/base'
import { navDetached } from '$lib/components/sidebar/navDetached.svelte'
import {
artifactKey,
itemDisplayName,
@@ -182,7 +183,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
@@ -828,24 +829,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. -->
@@ -898,7 +881,7 @@
: 'z-0 opacity-0 pointer-events-none'}"
aria-hidden={s.id !== activeSession?.id}
>
<SessionWrapper sessionId={s.id} />
<SessionWrapper sessionId={s.id} headerInset={navDetached.val} />
</div>
{/each}
</div>