mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 00:03:00 +00:00
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:
co-authored by
Claude Opus 5
parent
c4c9677982
commit
e0a33dc3d7
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user