Compare commits

...
Author SHA1 Message Date
Guilhem LemouelandClaude Opus 4.8 e6ace03c17 feat(sessions): prototype session-mode layout wrapper (design exploration)
Do not merge — design exploration of an optional full-page 'session mode' layout for AI sessions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:18:54 +02:00
8 changed files with 931 additions and 744 deletions
@@ -16,7 +16,6 @@
} from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import { goto } from '$lib/navigation'
import { page } from '$app/state'
import { useLocalStorageValue } from '$lib/svelte5Utils.svelte'
import { slide } from 'svelte/transition'
import {
@@ -42,15 +41,17 @@
removeSession
} from './sessionRuntime.svelte'
import SessionStatusDot from './SessionStatusDot.svelte'
import WorkspaceIcon from '$lib/components/workspace/WorkspaceIcon.svelte'
import SessionFilterMenu from './SessionFilterMenu.svelte'
import { Menu, Menubar, MenuItem } from '$lib/components/meltComponents'
import MenuButton from '$lib/components/sidebar/MenuButton.svelte'
import MenuButton, { sidebarClasses } from '$lib/components/sidebar/MenuButton.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate'
import { userWorkspaces, workspaceStore } from '$lib/stores'
import { WorkspaceService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { currentWorkspaceRootId, workspaceRootId } from './sessionScope.svelte'
import { sessionLayout, setSessionMode, sessionTargetHref } from './sessionMode.svelte'
// Look up the cached fork comparison for a session through its runtime
// (if any). The deriveForkStatus helper handles the "no runtime yet"
@@ -86,16 +87,18 @@
// the feature flag is off, the sidebar section is hidden entirely.
const globalEnabled = isGlobalAiEnabled()
// Only highlight the active session while we're actually on the session
// page — once the user navigates away, `currentSessionId` lingers but no
// row should appear selected.
const onSessionsPage = $derived(page.route.id?.includes('/sessions') ?? false)
// Only highlight the active session while session mode is on — outside it
// `currentSessionId` lingers but no row should appear selected.
const sessionActive = $derived(sessionLayout.on)
interface Props {
isCollapsed?: boolean
// When false, the section is always expanded (no collapse chevron) — used
// where the picker is the whole rail rather than one sidebar section.
collapsible?: boolean
}
let { isCollapsed = false }: Props = $props()
let { isCollapsed = false, collapsible = true }: Props = $props()
const sectionCollapsed = useLocalStorageValue(
'windmill_sessions_section_collapsed',
@@ -103,12 +106,11 @@
'boolean'
)
const showArchived = useLocalStorageValue('windmill_sessions_show_archived', false, 'boolean')
// Off by default: the list is scoped to the current workspace family. Turn on
// to include sessions from every workspace (grouped by family) — handy when
// switching sessions across workspaces without switching workspace first.
// On by default: list sessions from every workspace (grouped by family), so the
// picker is a global session switcher. Turn off to scope to the current family.
const showAllWorkspaces = useLocalStorageValue(
'windmill_sessions_show_all_workspaces',
false,
true,
'boolean'
)
@@ -244,7 +246,12 @@
// session after editing items elsewhere in the SPA, where neither
// the visibility-change nor the AI-loading signal would fire.
void getRuntime(session.id)?.refreshForkComparison()
await goto(`/sessions?session_name=${encodeURIComponent(session.name)}`)
// Clicking a session enters session mode and navigates the embedded
// panel to its target (if any). Sessions with no editor target just
// activate and leave the panel on its current page.
setSessionMode(true)
const href = sessionTargetHref(session.target)
if (href) await goto(href)
if (restoreFocus) {
// goto() resets focus to <body> — put it back on the active session button
// so subsequent arrow keys keep navigating the list.
@@ -325,7 +332,9 @@
if (wasActive) {
const next = sessionState.sessions[0]
if (next) await activate(next)
else await goto('/sessions')
// No sessions left — clear the selection so the shell shows a fresh
// ready-to-type composer instead of a dangling deleted session.
else sessionState.currentSessionId = undefined
}
}
@@ -382,7 +391,7 @@
/>
{#if totalUnread > 0}
<span
class="absolute top-1 right-1 pointer-events-none inline-block w-2 h-2 rounded-full bg-blue-500"
class="absolute top-1 right-1 pointer-events-none inline-block w-2 h-2 rounded-full bg-surface-accent-primary"
aria-label="{totalUnread} unread message{totalUnread === 1
? ''
: 's'} across all sessions"
@@ -421,11 +430,16 @@
{@const runtime = getRuntime(session.id)}
{@const status = runtime ? getSessionChatStatus(runtime) : 'idle'}
{@const isSelected =
onSessionsPage && session.id === sessionState.currentSessionId}
sessionActive && session.id === sessionState.currentSessionId}
{@const unread = unreadFor(session)}
{@const draft = hasDraft(session)}
<MenuItem
class={twMerge(menuItemBase, isSelected ? 'bg-surface-hover' : '')}
class={twMerge(
menuItemBase,
isSelected
? twMerge(sidebarClasses.selectedBg, sidebarClasses.selectedText)
: ''
)}
onClick={() => activate(session)}
{item}
>
@@ -449,7 +463,7 @@
{/if}
{#if unread > 0}
<span
class="inline-flex items-center justify-center rounded-full bg-blue-500 text-white font-medium leading-none min-w-4 h-4 px-1 text-[10px]"
class="inline-flex items-center justify-center rounded-full bg-surface-accent-primary text-white font-medium leading-none min-w-4 h-4 px-1 text-[10px]"
aria-label="{unread} unread message{unread === 1 ? '' : 's'}"
>
{unread > 9 ? '9+' : unread}
@@ -468,9 +482,13 @@
</Menubar>
</div>
{:else}
<div class="px-2 pt-3 pb-2 flex flex-col gap-1 border-b border-light dark:border-gray-700">
<div
class="px-2 pt-3 pb-2 flex flex-col gap-1 {collapsible
? 'border-b border-light dark:border-gray-700'
: ''}"
>
<div class="flex flex-row items-center justify-between pl-1 pr-0.5">
{#if visibleSessions.length > 0}
{#if collapsible && visibleSessions.length > 0}
<button
type="button"
onclick={() => (sectionCollapsed.val = !sectionCollapsed.val)}
@@ -546,7 +564,7 @@
/>
</div>
</div>
{#if !sectionCollapsed.val}
{#if !collapsible || !sectionCollapsed.val}
<div
bind:this={listRoot}
transition:slide={{ duration: 180 }}
@@ -555,27 +573,36 @@
role="listbox"
tabindex="-1"
>
{#each sessionGroups as group (group.rootId)}
{#each sessionGroups as group, groupIdx (group.rootId)}
{#if showGroupHeaders}
{@const groupWs = $userWorkspaces.find((w) => w.id === group.rootId)}
<div
class="px-2 pt-1.5 pb-0.5 text-[0.5rem] uppercase text-tertiary truncate"
class={twMerge(
'flex items-center gap-2 px-2 pt-2 pb-1 min-w-0',
// Space families apart so group boundaries read clearly.
groupIdx > 0 ? 'mt-4' : ''
)}
title={group.name}
>
{group.name}
<WorkspaceIcon workspaceColor={groupWs?.color} size={12} />
<span class="text-xs font-medium text-secondary truncate">{group.name}</span>
</div>
{/if}
{#each group.sessions as session (session.id)}
{@const runtime = getRuntime(session.id)}
{@const status = runtime ? getSessionChatStatus(runtime) : 'idle'}
{@const isSelected = onSessionsPage && session.id === sessionState.currentSessionId}
{@const isSelected = sessionActive && session.id === sessionState.currentSessionId}
{@const isEditing = editingId === session.id}
{@const unread = unreadFor(session)}
{@const draft = hasDraft(session)}
<div
class={twMerge(
'flex flex-row items-center group rounded',
isSelected ? 'bg-surface-hover text-primary' : 'hover:bg-surface-hover',
session.archived ? 'opacity-60' : ''
isSelected ? sidebarClasses.selectedBg : 'hover:bg-surface-hover',
session.archived ? 'opacity-60' : '',
// Indent rows under their family header so the session name lines up
// with the (wider) workspace-icon header name above.
showGroupHeaders ? 'pl-5' : ''
)}
>
{#if isEditing}
@@ -609,7 +636,7 @@
onclick={() => activate(session)}
class={twMerge(
'flex flex-row items-center gap-2 text-left text-xs font-normal focus:outline-none flex-1 min-w-0 px-2 py-1',
unread > 0 ? 'text-primary font-semibold' : 'text-secondary'
isSelected ? sidebarClasses.selectedText : 'text-secondary'
)}
>
<SessionStatusDot
@@ -625,7 +652,7 @@
{/if}
{#if unread > 0}
<span
class="inline-flex items-center justify-center rounded-full bg-blue-500 text-white font-medium leading-none min-w-4 h-4 px-1 text-[10px]"
class="inline-flex items-center justify-center rounded-full bg-surface-accent-primary text-white font-medium leading-none min-w-4 h-4 px-1 text-[10px]"
aria-label="{unread} unread message{unread === 1 ? '' : 's'}"
>
{unread > 9 ? '9+' : unread}
@@ -0,0 +1,210 @@
<script lang="ts">
import { untrack, getContext, onMount, type Snippet } from 'svelte'
import { cubicOut } from 'svelte/easing'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { Search } from 'lucide-svelte'
import { page } from '$app/state'
import { base } from '$app/paths'
import { goto } from '$lib/navigation'
import { getModifierKey } from '$lib/utils'
import { Menubar } from '$lib/components/meltComponents'
import MenuButton from '$lib/components/sidebar/MenuButton.svelte'
import SettingsMenu from '$lib/components/sidebar/SettingsMenu.svelte'
import SidebarContent from '$lib/components/sidebar/SidebarContent.svelte'
import FavoriteMenu, { favoriteManager } from '$lib/components/sidebar/FavoriteMenu.svelte'
import WindmillIcon from '$lib/components/icons/WindmillIcon.svelte'
import SessionPicker from './SessionPicker.svelte'
import SessionWrapper from './SessionWrapper.svelte'
import { setSessionMode } from './sessionMode.svelte'
import {
createSession,
sessionState,
setSessionTarget,
type SessionTarget
} from './sessionState.svelte'
let { children }: { children?: Snippet } = $props()
const openSearch = getContext<(text?: string) => void>('openSearchWithPrefilledText')
// Open animation: grow the chat pane from 0 → its resting size so the session
// chrome slides in and the panel makes room. Svelte intro transitions don't
// fire on content slotted inside <Pane>, so we animate the pane size directly.
// bind:size hands control back to the splitter once the intro completes (and
// minSize is relaxed to 0 during the intro so the from-0 frames aren't clamped).
const CHAT_PANE_SIZE = 44
let chatPaneSize = $state(0)
let introDone = $state(false)
onMount(() => {
const start = performance.now()
const dur = 320
function step(now: number) {
const t = Math.min(1, (now - start) / dur)
chatPaneSize = CHAT_PANE_SIZE * cubicOut(t)
if (t < 1) {
requestAnimationFrame(step)
} else {
chatPaneSize = CHAT_PANE_SIZE
introDone = true
}
}
requestAnimationFrame(step)
})
// Resolve the selected session, but only if its record actually exists.
// A stale currentSessionId can outlive its session (e.g. a transient draft
// dropped on reload), and mounting the wrapper on a missing id renders a
// "Session not found" stub.
const activeId = $derived(
sessionState.currentSessionId &&
sessionState.sessions.some((s) => s.id === sessionState.currentSessionId)
? sessionState.currentSessionId
: undefined
)
// No valid session selected → spin up (or reuse) the transient draft so the
// center shows a ready-to-type composer. Sending the first message commits
// it; until then nothing is persisted.
$effect(() => {
if (!activeId) {
untrack(() => createSession())
}
})
// Parse the embedded panel's route back into a session editor target.
function parseTarget(pathname: string): SessionTarget | undefined {
const p = pathname.startsWith(base) ? pathname.slice(base.length) : pathname
const m = /^\/(scripts|flows|apps_raw)\/(?:edit|add|get)\/(.+)$/.exec(p)
if (!m) return undefined
const kind = m[1] === 'scripts' ? 'script' : m[1] === 'flows' ? 'flow' : 'raw_app'
return { kind, path: decodeURIComponent(m[2]) }
}
// "Target follows the panel": as the embedded Windmill route changes, retarget
// the active session at whatever item it now shows. Only committed sessions —
// retargeting a transient would persist it before its first message.
$effect(() => {
const id = activeId
const t = parseTarget(page.url.pathname)
if (!id || !t) return
const s = sessionState.sessions.find((x) => x.id === id)
if (!s || s.transient) return
if (s.target?.kind === t.kind && s.target?.path === t.path) return
untrack(() => setSessionTarget(id, t))
})
</script>
<div class="h-screen w-screen flex flex-row overflow-hidden">
<Splitpanes horizontal={false} class="flex-1 min-h-0 splitter-hidden">
<!-- Pane 1 = session chrome (sessions rail + chat). The fixed-width rail
plus a flex chat means dragging the (hidden) splitter resizes the chat.
The inner wrapper slides in from the left when session mode opens. -->
<Pane bind:size={chatPaneSize} minSize={introDone ? 26 : 0} class="flex min-h-0">
<div class="flex flex-row w-full min-h-0">
<!-- Sessions rail, replacing the global nav sidebar. -->
<div class="w-56 shrink-0 flex flex-col border-r border-light bg-surface-secondary min-h-0">
<div class="flex items-center gap-2 px-3 h-12 border-b border-light shrink-0">
<button
type="button"
onclick={() => goto('/')}
title="Home"
aria-label="Home"
class="shrink-0 flex items-center"
>
<WindmillIcon height="20px" width="20px" />
</button>
<span class="text-sm font-semibold text-emphasis">Windmill</span>
</div>
<div class="flex-1 min-h-0 overflow-y-auto">
<SessionPicker isCollapsed={false} collapsible={false} />
</div>
<!-- Account / instance actions (User, Settings, Workers, Logs, Help)
gathered under one "Settings" dropdown in the rail footer. -->
<div class="shrink-0 border-t border-light p-2">
<SettingsMenu />
</div>
</div>
<!-- Chat: fills the rest of the pane, so the splitter resizes it. -->
<div class="flex-1 min-w-0 flex flex-col min-h-0">
{#if activeId}
{#key activeId}
<SessionWrapper
sessionId={activeId}
hideEditor
onExit={() => setSessionMode(false)}
/>
{/key}
{/if}
</div>
</div>
</Pane>
<!-- Pane 2 = the live Windmill page, framed like the session editor pane —
the nav sidebar and the page content share one rounded, bordered card. -->
<Pane minSize={30} class="flex flex-col min-h-0">
<!-- pl-0: the card's left edge sits flush against the splitter, so the
invisible handle sticks to the panel (matching the session editor pane). -->
<div class="flex-1 min-h-0 flex flex-col p-2 pl-0">
<div
class="flex flex-col flex-1 min-h-0 rounded-md border border-light overflow-hidden relative bg-surface"
>
<!-- Viewing breadcrumb: full-width panel header, on top of the nav sidebar. -->
<div
class="px-3 h-8 flex items-center gap-2 border-b border-light shrink-0 text-xs text-secondary"
>
<span class="text-tertiary">Viewing</span>
<span class="font-mono truncate">{page.url.pathname}</span>
</div>
<div class="flex flex-row flex-1 min-h-0">
<!-- Temporary nav sidebar so the panel stays navigable. Mirrors the global
sidebar minus the workspace picker (which would let you switch workspace
out from under the active session). -->
<div
class="w-12 shrink-0 flex flex-col border-r border-light bg-surface min-h-0 overflow-y-auto"
>
<div class="px-2 py-2 border-b border-light dark:border-gray-700 flex flex-col gap-1">
<Menubar class="flex flex-col gap-1">
{#snippet children({ createMenu })}
<FavoriteMenu
{createMenu}
favoriteLinks={favoriteManager.current}
isCollapsed
/>
{/snippet}
</Menubar>
<MenuButton
stopPropagationOnClick={true}
on:click={() => openSearch?.()}
isCollapsed
icon={Search}
label="Search"
class="!text-xs"
shortcut={`${getModifierKey()}k`}
/>
</div>
<SidebarContent
isCollapsed
showSecondary={false}
numUnacknowledgedCriticalAlerts={0}
/>
</div>
<div id="content" class="flex-1 min-h-0 flex flex-col overflow-hidden">
{@render children?.()}
</div>
</div>
</div>
</div>
</Pane>
</Splitpanes>
</div>
<style>
/* Invisible-but-draggable splitter between the chat and the panel. */
:global(.splitter-hidden .splitpanes__splitter) {
background-color: transparent !important;
border: none !important;
opacity: 0 !important;
}
</style>
@@ -16,6 +16,7 @@
Archive,
ArchiveRestore,
EllipsisVertical,
PanelLeftClose,
PanelRightClose,
PanelRightOpen,
Pencil,
@@ -49,7 +50,16 @@
import { goto } from '$lib/navigation'
import { slide } from 'svelte/transition'
let { sessionId }: { sessionId: string } = $props()
// hideEditor: never mount the inline editor pane. Used by SessionShell, where
// the edited item is shown as the live full-page route in the layout panel
// instead, so the wrapper contributes only its chat column.
// onExit (when provided): a collapse-pane button on the header's top-right that
// dismisses the session layout. Lives here so it sits in the chat header.
let {
sessionId,
hideEditor = false,
onExit
}: { sessionId: string; hideEditor?: boolean; onExit?: () => void } = $props()
// LRU-warm sessions get their editor pane mounted; others render
// chat-only. Reading from the reactive Set keeps SessionWrapper in
@@ -83,7 +93,6 @@
async function resetToNewSession() {
const fresh = createSession()
selectSession(fresh.id)
await goto(`/sessions?session_name=${encodeURIComponent(fresh.name)}`)
}
// If the session targets a forked workspace that's still accessible,
@@ -262,7 +271,7 @@
session.target?.kind === 'flow' ||
session.target?.kind === 'script' ||
session.target?.kind === 'raw_app'}
{@const hasEditor = mountEditor && hasTarget && editorVisible}
{@const hasEditor = mountEditor && hasTarget && editorVisible && !hideEditor}
{#snippet inputPreface()}
{#if !hasFirstUserMessage}
@@ -372,7 +381,7 @@
</span>
{/snippet}
</DropdownV2>
{#if !session.target && hasFirstUserMessage}
{#if !hideEditor && !session.target && hasFirstUserMessage}
<!-- Drill-picker for sessions that have started but haven't
picked an editor target yet. Hidden on fresh sessions
(no messages yet) — the workspace bar is the only
@@ -396,7 +405,7 @@
{/snippet}
</Popover>
</div>
{:else if hasTarget && mountEditor && !editorVisible}
{:else if !hideEditor && hasTarget && mountEditor && !editorVisible}
<div class="ml-auto">
<Button
variant="subtle"
@@ -420,6 +429,17 @@
</button>
</div>
{/if}
{#if onExit}
<button
type="button"
onclick={onExit}
title="Exit session mode"
aria-label="Exit session mode"
class="ml-auto inline-flex items-center justify-center w-6 h-6 rounded text-tertiary hover:text-primary hover:bg-surface-hover"
>
<PanelLeftClose size={16} />
</button>
{/if}
</header>
<div class="flex-1 min-h-0 w-full flex flex-col {hasFirstUserMessage ? '' : 'pt-8'}">
<AIChat
@@ -0,0 +1,51 @@
import { BROWSER } from 'esm-env'
import { base } from '$app/paths'
import type { SessionTarget } from './sessionState.svelte'
// Session mode is an optional layout wrapper, not a route: it can be toggled
// on over whatever Windmill page is currently shown. The flag is sticky across
// reloads/navigations so the wrapped layout survives a refresh.
const STORAGE_KEY = 'wm_session_mode'
function readInitial(): boolean {
if (!BROWSER) return false
try {
return localStorage.getItem(STORAGE_KEY) === '1'
} catch {
return false
}
}
export const sessionLayout = $state<{ on: boolean }>({ on: readInitial() })
export function setSessionMode(on: boolean): void {
sessionLayout.on = on
if (!BROWSER) return
try {
if (on) localStorage.setItem(STORAGE_KEY, '1')
else localStorage.removeItem(STORAGE_KEY)
} catch {
// localStorage can throw in private mode — the in-memory flag still drives the layout.
}
}
export function toggleSessionMode(): void {
setSessionMode(!sessionLayout.on)
}
// Maps a session's editor target to the canonical full-page Windmill route.
// Clicking a session navigates the embedded panel here; non-editor targets
// (or none) return undefined so the panel just keeps its current page.
export function sessionTargetHref(target: SessionTarget | undefined): string | undefined {
if (!target) return undefined
const seg =
target.kind === 'script'
? 'scripts/edit'
: target.kind === 'flow'
? 'flows/edit'
: target.kind === 'raw_app'
? 'apps_raw/edit'
: undefined
if (!seg) return undefined
return `${base}/${seg}/${target.path}`
}
@@ -0,0 +1,44 @@
<script lang="ts">
import { Settings, User, ServerCog, Logs, HelpCircle, LogOut } from 'lucide-svelte'
import { base } from '$app/paths'
import { goto } from '$lib/navigation'
import { type Item } from '$lib/utils'
import { logout } from '$lib/logoutKit'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import { USER_SETTINGS_HASH, SUPERADMIN_SETTINGS_HASH } from './settings'
let { isCollapsed = false }: { isCollapsed?: boolean } = $props()
// Account / instance actions gathered under one "Settings" dropdown, shared by
// the session rail and the global sidebar so both expose the same entry point.
const items: Item[] = [
{ displayName: 'User', icon: User, action: () => goto(USER_SETTINGS_HASH) },
{
displayName: 'Instance settings',
icon: Settings,
action: () => goto(SUPERADMIN_SETTINGS_HASH)
},
{ displayName: 'Workers', icon: ServerCog, href: `${base}/workers` },
{ displayName: 'Logs', icon: Logs, href: `${base}/audit_logs` },
{
displayName: 'Help',
icon: HelpCircle,
href: 'https://www.windmill.dev/docs/intro',
hrefTarget: '_blank'
},
{ displayName: 'Logout', icon: LogOut, action: () => logout(), separatorTop: true }
]
</script>
<DropdownV2 {items} placement="top-start" class="w-full">
{#snippet buttonReplacement()}
<span
class="flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-secondary text-xs hover:bg-surface-hover cursor-pointer {isCollapsed
? 'justify-center'
: ''}"
>
<Settings size={16} />
{#if !isCollapsed}Settings{/if}
</span>
{/snippet}
</DropdownV2>
@@ -35,6 +35,7 @@
Route,
Settings,
UserCog,
Users,
Plus,
Unplug,
AlertCircle,
@@ -291,9 +292,19 @@
interface Props {
numUnacknowledgedCriticalAlerts?: number
isCollapsed?: boolean
// Render the workspace-content nav (Home/Runs/Variables/… + triggers).
showMain?: boolean
// Render the bottom account group (User, Settings, Workers, Folders, Logs, Help).
// Splitting these lets a host show content nav and account nav in separate rails.
showSecondary?: boolean
}
let { numUnacknowledgedCriticalAlerts = 0, isCollapsed = false }: Props = $props()
let {
numUnacknowledgedCriticalAlerts = 0,
isCollapsed = false,
showMain = true,
showSecondary = true
}: Props = $props()
let leaveWorkspaceModal = $state(false)
let deleteWorkspaceForkModal = $state(false)
@@ -353,6 +364,22 @@
aiId: 'sidebar-menu-link-assets',
aiDescription: 'Button to navigate to assets'
},
{
label: 'Folders',
href: `${base}/folders`,
icon: FolderOpen,
disabled: $userStore?.operator,
aiId: 'sidebar-menu-link-folders',
aiDescription: 'Button to navigate to folders'
},
{
label: 'Groups',
href: `${base}/groups`,
icon: Users,
disabled: $userStore?.operator,
aiId: 'sidebar-menu-link-groups',
aiDescription: 'Button to navigate to groups'
},
// Add Tutorials to main menu only if not all completed and not skipped
...($tutorialsToDo.length > 0 && !$skippedAll
? [
@@ -575,33 +602,6 @@
aiId: 'sidebar-menu-link-workers',
aiDescription: 'Button to navigate to workers'
},
{
label: 'Folders & Groups',
icon: FolderOpen,
aiId: 'sidebar-menu-link-folders-groups',
aiDescription: 'Button to navigate to folders and groups',
subItems: [
{
label: 'Folders',
href: `${base}/folders`,
icon: FolderOpen,
disabled: $userStore?.operator,
aiId: 'sidebar-menu-link-folders',
aiDescription: 'Button to navigate to folders',
faIcon: undefined
},
{
label: 'Groups',
href: `${base}/groups`,
icon: UserCog,
disabled: $userStore?.operator,
aiId: 'sidebar-menu-link-groups',
aiDescription: 'Button to navigate to groups',
faIcon: undefined
}
],
disabled: $userStore?.operator
},
$devopsRole || $userStore?.is_admin
? {
label: 'Logs',
@@ -660,211 +660,215 @@
)}
>
<div class={twMerge('pt-4 flex flex-col grow')}>
<div class="space-y-1">
{#each mainMenuLinks as menuLink (menuLink.href ?? menuLink.label)}
<MenuLink class="!text-xs" {...menuLink} {isCollapsed} />
{/each}
</div>
<div class="pt-4">
{#if isCollapsed}
<div class="text-secondary text-[0.5rem] uppercase transition-opacity opacity-0">
Triggers
</div>
{:else}
<button
type="button"
onclick={() => (triggersCollapsed.val = !triggersCollapsed.val)}
class="text-secondary text-[0.5rem] uppercase flex flex-row items-center gap-1 rounded px-1 -mx-1 py-0.5 hover:bg-surface-hover focus:outline-none"
aria-expanded={!triggersCollapsed.val}
>
Triggers
{#if triggersCollapsed.val}
<ChevronRight size={10} />
{:else}
<ChevronDown size={10} />
{/if}
</button>
{/if}
{#if isCollapsed || !triggersCollapsed.val}
<div transition:slide={{ duration: 180 }}>
<Menubar class="flex flex-col gap-1">
{#snippet children({ createMenu })}
{#each triggerMenuLinks as menuLink (menuLink.href ?? menuLink.label)}
<MenuLink class="!text-xs" {...menuLink} {isCollapsed} />
{/each}
{#if extraTriggerLinks.length > 0 && !$userStore?.operator}
<Menu {createMenu} usePointerDownOutside>
{#snippet triggr({ trigger })}
<MeltButton
aiId="sidebar-menu-link-add-trigger"
aiDescription="Button to add a new trigger. Can be HTTP, WebSocket, Postgres, Kafka, NATS, SQS, GCP Pub/Sub, or MQTT"
class={twMerge(
'w-full text-secondary text-2xs flex flex-row gap-1 py-1 items-center px-2 hover:bg-surface-hover rounded',
'data-[highlighted]:bg-surface-hover'
)}
meltElement={trigger}
>
<Plus size={14} />
</MeltButton>
{/snippet}
{#snippet children({ item })}
{#each extraTriggerLinks as subItem (subItem.href ?? subItem.label)}
<MenuItem
aiId={subItem.aiId}
aiDescription={subItem.aiDescription}
href={subItem.disabled ? '' : subItem.href}
class={twMerge(
itemClass,
subItem.disabled ? 'pointer-events-none opacity-50' : ''
)}
{item}
disabled={subItem.disabled}
>
<div class="flex flex-row items-center gap-2">
{#if subItem.icon}
<subItem.icon size={16} />
{/if}
{subItem.label}
</div>
</MenuItem>
{/each}
{/snippet}
</Menu>
{/if}
{/snippet}
</Menubar>
</div>
{/if}
</div>
<div class="flex flex-col gap-2 mt-auto pt-4">
<!-- Single Menubar so melt-ui's hover-to-switch spans the whole bottom
group (Settings/Workers/Folders/Logs AND Help). With Help in its own
Menubar the menus stack instead of switching (WIN-1993). Each group
keeps its own flex container for spacing. -->
<Menubar class="flex flex-col gap-2">
{#snippet children({ createMenu })}
<div class="flex flex-col gap-1">
<UserMenu {isCollapsed} {createMenu} />
{#each secondaryMenuLinks as menuLink (menuLink.href ?? menuLink.label)}
{#if menuLink.subItems}
{@const notificationsCount = computeAllNotificationsCount(menuLink.subItems)}
<Menu {createMenu} usePointerDownOutside>
{#snippet triggr({ trigger })}
<MenuButton
class="!text-2xs"
{...menuLink}
{isCollapsed}
{notificationsCount}
{trigger}
/>
{/snippet}
{#snippet children({ item })}
{#each menuLink.subItems as subItem (subItem.href ?? subItem.label)}
<MenuItem
class={itemClass}
href={subItem.href}
{item}
onClick={() => {
subItem?.['action']?.()
}}
aiId={subItem.aiId}
aiDescription={subItem.aiDescription}
>
<div class="flex flex-row items-center gap-2">
{#if subItem.icon}
<subItem.icon size={16} />
{/if}
{subItem.label}
{#if subItem?.['notificationCount']}
<div class="ml-auto">
<SideBarNotification
notificationCount={subItem['notificationCount']}
/>
</div>
{/if}
</div>
</MenuItem>
{/each}
{/snippet}
</Menu>
{:else}
<MenuSingleItem>
{#snippet children({})}
<MenuLink class="!text-2xs" {...menuLink} {isCollapsed} />
{/snippet}
</MenuSingleItem>
{/if}
{/each}
{#if showMain}
<div class="space-y-1">
{#each mainMenuLinks as menuLink (menuLink.href ?? menuLink.label)}
<MenuLink class="!text-xs" {...menuLink} {isCollapsed} />
{/each}
</div>
<div class="pt-4">
{#if isCollapsed}
<div class="text-secondary text-[0.5rem] uppercase transition-opacity opacity-0">
Triggers
</div>
<div class="flex flex-col gap-1">
{#each thirdMenuLinks as menuLink (menuLink)}
{#if menuLink.subItems}
<Menu {createMenu} usePointerDownOutside>
{#snippet triggr({ trigger })}
<button
class="relative w-full"
onclick={() => {
if (menuLink.label === 'Help') {
openChangelogs()
}
}}
>
<MenuButton class="!text-2xs" {...menuLink} {isCollapsed} {trigger} />
{#if menuLink.label === 'Help' && hasNewChangelogs}
<span
class={twMerge(
'flex h-2 w-2 absolute',
isCollapsed ? 'top-1 right-1' : 'right-2 top-1/2 -translate-y-1/2'
)}
>
<span
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-frost-400 opacity-75"
></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-frost-500"
></span>
</span>
{/if}
</button>
{/snippet}
{#snippet children({ item })}
{#each menuLink.subItems as subItem (subItem.href ?? subItem.label)}
<MenuItem
href={subItem.href}
class={itemClass}
target={subItem.external !== false ? '_blank' : undefined}
{item}
{:else}
<button
type="button"
onclick={() => (triggersCollapsed.val = !triggersCollapsed.val)}
class="text-secondary text-[0.5rem] uppercase flex flex-row items-center gap-1 rounded px-1 -mx-1 py-0.5 hover:bg-surface-hover focus:outline-none"
aria-expanded={!triggersCollapsed.val}
>
Triggers
{#if triggersCollapsed.val}
<ChevronRight size={10} />
{:else}
<ChevronDown size={10} />
{/if}
</button>
{/if}
{#if isCollapsed || !triggersCollapsed.val}
<div transition:slide={{ duration: 180 }}>
<Menubar class="flex flex-col gap-1">
{#snippet children({ createMenu })}
{#each triggerMenuLinks as menuLink (menuLink.href ?? menuLink.label)}
<MenuLink class="!text-xs" {...menuLink} {isCollapsed} />
{/each}
{#if extraTriggerLinks.length > 0 && !$userStore?.operator}
<Menu {createMenu} usePointerDownOutside>
{#snippet triggr({ trigger })}
<MeltButton
aiId="sidebar-menu-link-add-trigger"
aiDescription="Button to add a new trigger. Can be HTTP, WebSocket, Postgres, Kafka, NATS, SQS, GCP Pub/Sub, or MQTT"
class={twMerge(
'w-full text-secondary text-2xs flex flex-row gap-1 py-1 items-center px-2 hover:bg-surface-hover rounded',
'data-[highlighted]:bg-surface-hover'
)}
meltElement={trigger}
>
<div class="flex flex-row items-center gap-2">
{#if subItem.icon}
<subItem.icon size={16} />
{/if}
{subItem.label}
</div>
</MenuItem>
{/each}
{#if recentChangelogs.length > 0}
<div class="w-full h-1 border-t"></div>
<span class="text-xs px-4 font-bold"> Latest changelogs </span>
{#each recentChangelogs as changelog}
<MenuItem href={changelog.href} class={itemClass} target="_blank" {item}>
<Plus size={14} />
</MeltButton>
{/snippet}
{#snippet children({ item })}
{#each extraTriggerLinks as subItem (subItem.href ?? subItem.label)}
<MenuItem
aiId={subItem.aiId}
aiDescription={subItem.aiDescription}
href={subItem.disabled ? '' : subItem.href}
class={twMerge(
itemClass,
subItem.disabled ? 'pointer-events-none opacity-50' : ''
)}
{item}
disabled={subItem.disabled}
>
<div class="flex flex-row items-center gap-2">
{changelog.label}
{#if subItem.icon}
<subItem.icon size={16} />
{/if}
{subItem.label}
</div>
</MenuItem>
{/each}
{/if}
{/snippet}
</Menu>
{/if}
{/each}
{/snippet}
</Menu>
{/if}
{/snippet}
</Menubar>
</div>
{/snippet}
</Menubar>
</div>
{/if}
</div>
{/if}
{#if showSecondary}
<div class="flex flex-col gap-2 mt-auto pt-4">
<!-- Single Menubar so melt-ui's hover-to-switch spans the whole bottom
group (Settings/Workers/Folders/Logs AND Help). With Help in its own
Menubar the menus stack instead of switching (WIN-1993). Each group
keeps its own flex container for spacing. -->
<Menubar class="flex flex-col gap-2">
{#snippet children({ createMenu })}
<div class="flex flex-col gap-1">
<UserMenu {isCollapsed} {createMenu} />
{#each secondaryMenuLinks as menuLink (menuLink.href ?? menuLink.label)}
{#if menuLink.subItems}
{@const notificationsCount = computeAllNotificationsCount(menuLink.subItems)}
<Menu {createMenu} usePointerDownOutside>
{#snippet triggr({ trigger })}
<MenuButton
class="!text-2xs"
{...menuLink}
{isCollapsed}
{notificationsCount}
{trigger}
/>
{/snippet}
{#snippet children({ item })}
{#each menuLink.subItems as subItem (subItem.href ?? subItem.label)}
<MenuItem
class={itemClass}
href={subItem.href}
{item}
onClick={() => {
subItem?.['action']?.()
}}
aiId={subItem.aiId}
aiDescription={subItem.aiDescription}
>
<div class="flex flex-row items-center gap-2">
{#if subItem.icon}
<subItem.icon size={16} />
{/if}
{subItem.label}
{#if subItem?.['notificationCount']}
<div class="ml-auto">
<SideBarNotification
notificationCount={subItem['notificationCount']}
/>
</div>
{/if}
</div>
</MenuItem>
{/each}
{/snippet}
</Menu>
{:else}
<MenuSingleItem>
{#snippet children({})}
<MenuLink class="!text-2xs" {...menuLink} {isCollapsed} />
{/snippet}
</MenuSingleItem>
{/if}
{/each}
</div>
<div class="flex flex-col gap-1">
{#each thirdMenuLinks as menuLink (menuLink)}
{#if menuLink.subItems}
<Menu {createMenu} usePointerDownOutside>
{#snippet triggr({ trigger })}
<button
class="relative w-full"
onclick={() => {
if (menuLink.label === 'Help') {
openChangelogs()
}
}}
>
<MenuButton class="!text-2xs" {...menuLink} {isCollapsed} {trigger} />
{#if menuLink.label === 'Help' && hasNewChangelogs}
<span
class={twMerge(
'flex h-2 w-2 absolute',
isCollapsed ? 'top-1 right-1' : 'right-2 top-1/2 -translate-y-1/2'
)}
>
<span
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-frost-400 opacity-75"
></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-frost-500"
></span>
</span>
{/if}
</button>
{/snippet}
{#snippet children({ item })}
{#each menuLink.subItems as subItem (subItem.href ?? subItem.label)}
<MenuItem
href={subItem.href}
class={itemClass}
target={subItem.external !== false ? '_blank' : undefined}
{item}
>
<div class="flex flex-row items-center gap-2">
{#if subItem.icon}
<subItem.icon size={16} />
{/if}
{subItem.label}
</div>
</MenuItem>
{/each}
{#if recentChangelogs.length > 0}
<div class="w-full h-1 border-t"></div>
<span class="text-xs px-4 font-bold"> Latest changelogs </span>
{#each recentChangelogs as changelog}
<MenuItem href={changelog.href} class={itemClass} target="_blank" {item}>
<div class="flex flex-row items-center gap-2">
{changelog.label}
</div>
</MenuItem>
{/each}
{/if}
{/snippet}
</Menu>
{/if}
{/each}
</div>
{/snippet}
</Menubar>
</div>
{/if}
</div></nav
>
+316 -327
View File
@@ -14,6 +14,7 @@
import { capitalize, classNames, getModifierKey, sendUserToast } from '$lib/utils'
import WorkspaceMenu from '$lib/components/sidebar/WorkspaceMenu.svelte'
import SidebarContent from '$lib/components/sidebar/SidebarContent.svelte'
import SettingsMenu from '$lib/components/sidebar/SettingsMenu.svelte'
import CriticalAlertModal from '$lib/components/sidebar/CriticalAlertModal.svelte'
import ForkConflictModal from '$lib/components/ForkConflictModal.svelte'
import {
@@ -67,6 +68,8 @@
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'
import SessionShell from '$lib/components/sessions/SessionShell.svelte'
import { sessionLayout } from '$lib/components/sessions/sessionMode.svelte'
import { DEFAULT_HUB_BASE_URL } from '$lib/hub'
import DBManagerDrawer from '$lib/components/DBManagerDrawer.svelte'
import { useIsDarkMode } from '$lib/components/DarkModeObserver.svelte'
@@ -343,9 +346,6 @@
}
let devOnly = $derived(page.url.pathname.startsWith(base + '/scripts/dev'))
// Sessions own their own chat pane; suppress the global Ask-AI chat on the /sessions route
// so it doesn't render a second chat overlay on top of the session.
let inSessionRoute = $derived(page.url.pathname.startsWith(base + '/sessions'))
async function loadDefaultScripts(workspace: string, user: UserExt | undefined) {
if (!user?.operator) {
@@ -516,364 +516,353 @@
<CriticalAlertModal bind:muteSettings bind:numUnacknowledgedCriticalAlerts />
{/if}
<div class="h-screen flex flex-col">
{#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 sessionLayout.on && !$userStore?.operator && !menuHidden}
<SessionShell>{@render children?.()}</SessionShell>
{:else}
{#if !menuHidden}
{#if !$userStore?.operator}
{#if innerWidth < 768}
<div
class={classNames(
'fixed inset-0 bg-black/50 transition-opacity ease-linear duration-300 z-40',
menuOpen ? 'opacity-100' : 'opacity-0'
'relative',
menuOpen ? 'z-40' : 'pointer-events-none',
devOnly ? 'hidden' : ''
)}
></div>
<div class="fixed inset-0 flex z-40">
role="dialog"
aria-modal="true"
>
<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'
'fixed inset-0 bg-black/50 transition-opacity ease-linear duration-300 z-40',
menuOpen ? 'opacity-100' : 'opacity-0'
)}
>
></div>
<div class="fixed inset-0 flex z-40">
<div
class={classNames(
'absolute top-0 right-4 -mr-12 pt-2 ease-in-out duration-300',
menuOpen ? 'opacity-100' : 'opacity-0'
'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'
)}
>
<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"
<div
class={classNames(
'absolute top-0 right-4 -mr-12 pt-2 ease-in-out duration-300',
menuOpen ? 'opacity-100' : 'opacity-0'
)}
>
<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>
</div>
<div
class="h-full flex flex-col"
style:background-color={darkMode ? SIDEBAR_BG_DARK : SIDEBAR_BG}
>
<div class="flex gap-x-2 flex-shrink-0 p-4 font-semibold text-emphasis w-40">
<WindmillIcon white={darkMode} height="20px" width="20px" />
{#if $whitelabelNameStore}
{$whitelabelNameStore}
{:else}
Windmill
{/if}
</div>
<div class="px-2 py-4 border-y border-light dark:border-gray-700">
<Menubar>
{#snippet children({ createMenu })}
<WorkspaceMenu {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`}
/>
<MenuButton
stopPropagationOnClick={true}
on:click={() => aiChatManager.toggleOpen()}
isCollapsed={false}
icon={WandSparkles}
iconProps={{
forceDarkMode: true
<button
type="button"
onclick={() => {
menuOpen = !menuOpen
}}
label="Ask AI"
class="!text-xs"
iconClasses="!text-ai"
shortcut={`${getModifierKey()}L`}
/>
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"
>
<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}
>
<div class="flex gap-x-2 flex-shrink-0 p-4 font-semibold text-emphasis w-40">
<WindmillIcon white={darkMode} height="20px" width="20px" />
{#if $whitelabelNameStore}
{$whitelabelNameStore}
{:else}
Windmill
{/if}
</div>
<div class="px-2 py-4 border-y border-light dark:border-gray-700">
<Menubar>
{#snippet children({ createMenu })}
<WorkspaceMenu {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`}
/>
</div>
<!-- w-40 cap: the drawer is max-w-min, and long session titles
<!-- w-40 cap: 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="w-40">
<SessionPicker isCollapsed={false} />
</div>
<SidebarContent
isCollapsed={false}
numUnacknowledgedCriticalAlerts={isCriticalAlertsUiMuted
? 0
: numUnacknowledgedCriticalAlerts}
/>
</div>
</div>
</div>
</div>
{:else}
<div
id="sidebar"
class={classNames(
'flex flex-col fixed inset-y-0 transition-all ease-in-out duration-200 z-40 ',
isCollapsed ? 'w-12' : 'w-40',
devOnly ? '!hidden' : ''
)}
>
<div
class="flex-1 flex flex-col min-h-0 h-screen border-r border-light dark:border-gray-700"
style:background-color={darkMode ? SIDEBAR_BG_DARK : SIDEBAR_BG}
>
<button
onclick={() => {
goto('/')
}}
>
<div
class="flex-row flex-shrink-0 px-3.5 py-3.5 text-opacity-70 h-12 flex items-center gap-1.5"
class:w-40={!isCollapsed}
>
<div class:mr-1={!isCollapsed}>
<WindmillIcon white={darkMode} height="20px" width="20px" />
</div>
{#if !isCollapsed}
<div class="text-sm mt-0.5 text-emphasis">
{#if $whitelabelNameStore}{capitalize(
$whitelabelNameStore
)}{:else}Windmill{/if}
<div class="w-40">
<SessionPicker isCollapsed={false} />
</div>
{/if}
</div>
</button>
<div class="px-2 py-4 border-y border-light dark:border-gray-700 flex flex-col gap-1">
<Menubar class="flex flex-col gap-1">
{#snippet children({ createMenu })}
<WorkspaceMenu {createMenu} {isCollapsed} />
<FavoriteMenu
{createMenu}
favoriteLinks={favoriteManager.current}
{isCollapsed}
<SidebarContent
isCollapsed={false}
showSecondary={false}
numUnacknowledgedCriticalAlerts={isCriticalAlertsUiMuted
? 0
: numUnacknowledgedCriticalAlerts}
/>
{/snippet}
</Menubar>
<MenuButton
stopPropagationOnClick={true}
on:click={() => openSearchModal()}
{isCollapsed}
icon={Search}
label="Search"
class="!text-xs"
shortcut={`${getModifierKey()}k`}
/>
<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`}
/>
</div>
<SessionPicker {isCollapsed} />
<SidebarContent
{isCollapsed}
numUnacknowledgedCriticalAlerts={isCriticalAlertsUiMuted
? 0
: numUnacknowledgedCriticalAlerts}
/>
<div class="flex-shrink-0 flex px-4 pb-3.5">
<button
onclick={() => {
isCollapsed = !isCollapsed
}}
>
<ArrowLeft
size={16}
class={classNames(
'flex-shrink-0 h-4 w-4 transition-all ease-in-out duration-200 text-secondary',
isCollapsed ? 'rotate-180' : 'rotate-0'
)}
/>
</button>
<div class="px-2 pb-2">
<SettingsMenu isCollapsed={false} />
</div>
</div>
</div>
</div>
</div>
</div>
{/if}
{:else}
<div class="absolute top-1 left-1 z5000">
<OperatorMenu favoriteLinks={favoriteManager.current} />
</div>
{/if}
<!-- Legacy menu -->
<div
class={classNames(
'fixed inset-0 bg-black/50 transition-opacity ease-linear duration-300',
'opacity-0 pointer-events-none'
)}
>
<div class={twMerge('fixed inset-0 flex ', '-z-0')}>
<div
class={classNames(
'relative flex-1 flex flex-col max-w-min w-full bg-surface transition ease-in-out duration-100 transform',
'-translate-x-full'
)}
>
{:else}
<div
id="sidebar"
class={classNames(
'absolute top-0 right-0 -mr-12 pt-2 ease-in-out duration-100',
'opacity-0'
'flex flex-col fixed inset-y-0 transition-all ease-in-out duration-200 z-40 ',
isCollapsed ? 'w-12' : 'w-40',
devOnly ? '!hidden' : ''
)}
>
<button
type="button"
onclick={() => {
// menuSlide = !menuSlide
}}
aria-label="Close"
class="ml-1 flex items-center justify-center h-8 w-8 rounded-full focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white border border-white"
>
<svg
class="h-6 w-6 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>
</div>
<div class="h-full" style:background-color={darkMode ? SIDEBAR_BG_DARK : SIDEBAR_BG}>
<div
class="flex gap-x-2 flex-shrink-0 p-4 font-semibold text-emphasis w-10"
class:w-40={!isCollapsed}
class="flex-1 flex flex-col min-h-0 h-screen border-r border-light dark:border-gray-700"
style:background-color={darkMode ? SIDEBAR_BG_DARK : SIDEBAR_BG}
>
<WindmillIcon white={darkMode} height="20px" width="20px" />
{#if !isCollapsed}{#if $whitelabelNameStore}{capitalize(
$whitelabelNameStore
)}{:else}Windmill{/if}{/if}
</div>
<div class="px-2 py-4 space-y-2 border-y border-light dark:border-gray-700">
<Menubar>
{#snippet children({ createMenu })}
<WorkspaceMenu {createMenu} />
<FavoriteMenu {createMenu} favoriteLinks={favoriteManager.current} />
{/snippet}
</Menubar>
<MenuButton
stopPropagationOnClick={true}
on:click={() => openSearchModal()}
{isCollapsed}
icon={Search}
label="Search"
class="!text-xs"
shortcut={`${getModifierKey()}k`}
/>
<MenuButton
stopPropagationOnClick={true}
on:click={() => aiChatManager.toggleOpen()}
{isCollapsed}
icon={WandSparkles}
iconProps={{
forceDarkMode: true
<button
onclick={() => {
goto('/')
}}
label="Ask AI"
class="!text-xs"
iconClasses="!text-ai"
shortcut={`${getModifierKey()}L`}
>
<div
class="flex-row flex-shrink-0 px-3.5 py-3.5 text-opacity-70 h-12 flex items-center gap-1.5"
class:w-40={!isCollapsed}
>
<div class:mr-1={!isCollapsed}>
<WindmillIcon white={darkMode} height="20px" width="20px" />
</div>
{#if !isCollapsed}
<div class="text-sm mt-0.5 text-emphasis">
{#if $whitelabelNameStore}{capitalize(
$whitelabelNameStore
)}{:else}Windmill{/if}
</div>
{/if}
</div>
</button>
<div
class="px-2 py-4 border-y border-light dark:border-gray-700 flex flex-col gap-1"
>
<Menubar class="flex flex-col gap-1">
{#snippet children({ createMenu })}
<WorkspaceMenu {createMenu} {isCollapsed} />
<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`}
/>
</div>
<SessionPicker {isCollapsed} />
<SidebarContent
{isCollapsed}
showSecondary={false}
numUnacknowledgedCriticalAlerts={isCriticalAlertsUiMuted
? 0
: numUnacknowledgedCriticalAlerts}
/>
<div class="px-2 pb-1">
<SettingsMenu {isCollapsed} />
</div>
<div class="flex-shrink-0 flex px-4 pb-3.5">
<button
onclick={() => {
isCollapsed = !isCollapsed
}}
>
<ArrowLeft
size={16}
class={classNames(
'flex-shrink-0 h-4 w-4 transition-all ease-in-out duration-200 text-secondary',
isCollapsed ? 'rotate-180' : 'rotate-0'
)}
/>
</button>
</div>
</div>
</div>
{/if}
{:else}
<div class="absolute top-1 left-1 z5000">
<OperatorMenu favoriteLinks={favoriteManager.current} />
</div>
{/if}
<!-- Legacy menu -->
<div
class={classNames(
'fixed inset-0 bg-black/50 transition-opacity ease-linear duration-300',
'opacity-0 pointer-events-none'
)}
>
<div class={twMerge('fixed inset-0 flex ', '-z-0')}>
<div
class={classNames(
'relative flex-1 flex flex-col max-w-min w-full bg-surface transition ease-in-out duration-100 transform',
'-translate-x-full'
)}
>
<div
class={classNames(
'absolute top-0 right-0 -mr-12 pt-2 ease-in-out duration-100',
'opacity-0'
)}
>
<button
type="button"
onclick={() => {
// menuSlide = !menuSlide
}}
aria-label="Close"
class="ml-1 flex items-center justify-center h-8 w-8 rounded-full focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white border border-white"
>
<svg
class="h-6 w-6 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>
</div>
<div class="h-full" style:background-color={darkMode ? SIDEBAR_BG_DARK : SIDEBAR_BG}>
<div
class="flex gap-x-2 flex-shrink-0 p-4 font-semibold text-emphasis w-10"
class:w-40={!isCollapsed}
>
<WindmillIcon white={darkMode} height="20px" width="20px" />
{#if !isCollapsed}{#if $whitelabelNameStore}{capitalize(
$whitelabelNameStore
)}{:else}Windmill{/if}{/if}
</div>
<div class="px-2 py-4 space-y-2 border-y border-light dark:border-gray-700">
<Menubar>
{#snippet children({ createMenu })}
<WorkspaceMenu {createMenu} />
<FavoriteMenu {createMenu} favoriteLinks={favoriteManager.current} />
{/snippet}
</Menubar>
<MenuButton
stopPropagationOnClick={true}
on:click={() => openSearchModal()}
{isCollapsed}
icon={Search}
label="Search"
class="!text-xs"
shortcut={`${getModifierKey()}k`}
/>
<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`}
/>
</div>
<SidebarContent
{isCollapsed}
numUnacknowledgedCriticalAlerts={isCriticalAlertsUiMuted
? 0
: numUnacknowledgedCriticalAlerts}
/>
</div>
<SidebarContent
{isCollapsed}
numUnacknowledgedCriticalAlerts={isCriticalAlertsUiMuted
? 0
: numUnacknowledgedCriticalAlerts}
/>
</div>
</div>
</div>
{/if}
<div class="flex flex-col h-full w-full">
{#if $userStore?.is_service_account}
<div
class="bg-yellow-100 dark:bg-yellow-900/50 border-b border-yellow-300 dark:border-yellow-700 px-4 py-2 text-sm text-yellow-800 dark:text-yellow-200 flex items-center justify-center gap-4 shrink-0"
>
<span>
Viewing workspace on behalf of <strong>{$userStore.username}</strong>
<span class="text-yellow-600 dark:text-yellow-400"
>(impersonated by {$userStore.impersonating_email})</span
>
</span>
<button
class="px-3 py-1 text-xs font-medium bg-yellow-200 dark:bg-yellow-800 hover:bg-yellow-300 dark:hover:bg-yellow-700 rounded transition-colors"
onclick={async () => {
const savedToken = sessionStorage.getItem('pre_impersonation_token')
if (savedToken && $workspaceStore) {
try {
await UserService.exitImpersonation({
workspace: $workspaceStore,
requestBody: { token: savedToken }
})
} catch (e) {
console.error('Failed to exit impersonation', e)
}
sessionStorage.removeItem('pre_impersonation_token')
sessionStorage.removeItem('pre_impersonation_email')
}
window.location.href = '/workspace_settings?tab=users'
}}
>
Exit impersonation
</button>
</div>
{/if}
<AiChatLayout
{children}
noPadding={devOnly}
disableAi={true}
{isCollapsed}
isMobile={innerWidth < 768}
onMenuOpen={() => {
menuOpen = true
}}
/>
</div>
{/if}
<div class="flex flex-col h-full w-full">
{#if $userStore?.is_service_account}
<div
class="bg-yellow-100 dark:bg-yellow-900/50 border-b border-yellow-300 dark:border-yellow-700 px-4 py-2 text-sm text-yellow-800 dark:text-yellow-200 flex items-center justify-center gap-4 shrink-0"
>
<span>
Viewing workspace on behalf of <strong>{$userStore.username}</strong>
<span class="text-yellow-600 dark:text-yellow-400"
>(impersonated by {$userStore.impersonating_email})</span
>
</span>
<button
class="px-3 py-1 text-xs font-medium bg-yellow-200 dark:bg-yellow-800 hover:bg-yellow-300 dark:hover:bg-yellow-700 rounded transition-colors"
onclick={async () => {
const savedToken = sessionStorage.getItem('pre_impersonation_token')
if (savedToken && $workspaceStore) {
try {
await UserService.exitImpersonation({
workspace: $workspaceStore,
requestBody: { token: savedToken }
})
} catch (e) {
console.error('Failed to exit impersonation', e)
}
sessionStorage.removeItem('pre_impersonation_token')
sessionStorage.removeItem('pre_impersonation_email')
}
window.location.href = '/workspace_settings?tab=users'
}}
>
Exit impersonation
</button>
</div>
{/if}
<AiChatLayout
{children}
noPadding={devOnly}
disableAi={inSessionRoute}
{isCollapsed}
isMobile={innerWidth < 768}
onMenuOpen={() => {
menuOpen = true
}}
/>
</div>
</div>
{:else}
<CenteredModal title="Loading user..." loading={true}></CenteredModal>
@@ -1,158 +0,0 @@
<script lang="ts">
import { untrack } from 'svelte'
import { page } from '$app/state'
import { Plus } from 'lucide-svelte'
import { Button } from '$lib/components/common'
import { goto } from '$lib/navigation'
import SessionWrapper from '$lib/components/sessions/SessionWrapper.svelte'
import {
createSession,
selectSession,
sessionState,
syncWorkspaceTo
} from '$lib/components/sessions/sessionState.svelte'
import {
getOrCreateRuntime,
getRuntime,
listRuntimes,
promoteEditorWarm
} from '$lib/components/sessions/sessionRuntime.svelte'
import { markSessionSeen } from '$lib/components/sessions/sessionUnread.svelte'
import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate'
import { userWorkspaces } from '$lib/stores'
const globalEnabled = isGlobalAiEnabled()
const sessionName = $derived(page.url.searchParams.get('session_name') ?? '')
// Unfiltered resolution by name — used to drive workspace switching
// when a deep-linked session lives outside the current workspace.
const sessionByName = $derived(
sessionName ? sessionState.sessions.find((s) => s.name === sessionName) : undefined
)
// If the deep-linked session committed to a workspace different from
// the active one, switch globally so visibility resolves and the
// editor loads against the right workspace. Skip the switch when the
// target workspace is no longer in the user's list — pointing the
// global workspace at a deleted id would break sidebar scope and the
// editor; SessionWrapper handles the unavailable state separately.
$effect(() => {
const ws = sessionByName?.workspace_id
if (!ws) return
if (!$userWorkspaces.find((w) => w.id === ws)) return
untrack(() => syncWorkspaceTo(ws))
})
// sessionState.sessions holds every local session for the user. Resolve by
// name without applying the sidebar root filter so an open chat survives
// workspace switches.
const activeSession = $derived(sessionState.sessions.find((s) => s.name === sessionName))
// Touch the runtime for the active session so it gets created on first visit
// and the pane shows up. Subsequent renders find it via listRuntimes().
// Also refresh the fork diff count: deep-link / back-button navigation
// changes the URL but doesn't fire the picker.activate path nor the
// visibility-change signal, so this is the only hook that catches a
// user returning from another route in the same tab.
//
// Gate on session identity (id) rather than the full activeSession
// derived — sessionState.sessions mutates on every persisted change
// (including token-by-token last_message updates during AI streaming),
// so a value-trigger would re-fetch compareWorkspaces dozens of times
// per turn. We only want to refresh when the user actually arrives at
// a new session.
let lastArrivedSessionId: string | undefined
$effect(() => {
const session = activeSession
if (!session) {
lastArrivedSessionId = undefined
return
}
if (lastArrivedSessionId === session.id) return
lastArrivedSessionId = session.id
untrack(() => {
// Keep currentSessionId in sync with the URL so consumers
// (refresh hooks, picker selection) react to deep links the
// same way they react to picker clicks.
selectSession(session.id)
const rt = getOrCreateRuntime(session)
void rt.refreshForkComparison()
})
})
// Warm = sessions that currently have a live (module-scoped) runtime. The
// picker eagerly creates runtimes for its visible sessions, so this tracks
// whatever the picker shows — the current family, or every family when
// "Show all workspaces" is on. Runtimes whose session record isn't loaded
// resolve to undefined here and drop out.
const warmSessions = $derived(
listRuntimes()
.map((r) => sessionState.sessions.find((s) => s.id === r.sessionId))
.filter((s): s is NonNullable<typeof s> => s != null)
)
// Promote the active session in the LRU. Mutations untracked so the effect
// only re-runs when activeSession changes, not on its own writes.
$effect(() => {
const id = activeSession?.id
if (!id) return
untrack(() => promoteEditorWarm(id))
})
// Mark the active session "seen" up to its current displayMessages
// length. Watching messages.length here means: arrive at the page →
// clear unread; AI streams a new message while you're on the page →
// clear unread again so the badge never lights up for a session
// you're actively looking at. The effect only depends on the
// length, not the array contents, so token-by-token streams within
// a single message don't fire it on every chunk.
$effect(() => {
const id = activeSession?.id
if (!id) return
const rt = getRuntime(id)
if (!rt) return
const count = rt.manager.displayMessages.length
untrack(() => markSessionSeen(id, count))
})
async function startNewSession() {
const fresh = createSession()
await goto(`/sessions?session_name=${encodeURIComponent(fresh.name)}`)
}
</script>
{#if !globalEnabled}
<div class="p-8 text-secondary text-sm">
Sessions are gated on the global-AI dev flag. Enable with
<code class="text-2xs font-mono">localStorage.setItem('wm_dev_global_ai', '1')</code> and reload.
</div>
{:else if !sessionName}
<div class="p-8 text-secondary">No session selected — pick one in the sidebar.</div>
{:else if !sessionByName}
<!-- A session_name is in the URL but no session by that name exists — e.g. a
deleted session or a link opened in a different browser. -->
<div class="p-8 flex flex-col items-start gap-3 text-secondary text-sm">
<div class="flex flex-col gap-1">
<p class="text-primary font-medium">Session not found</p>
<p>
No session named <code class="font-mono text-2xs">{sessionName}</code> exists. It may have been
deleted, or this link was created in a different browser.
</p>
</div>
<Button size="xs" startIcon={{ icon: Plus }} onclick={startNewSession}>New session</Button>
</div>
{:else}
<div class="relative flex-1 min-h-0">
{#each warmSessions as s (s.id)}
<div
class="absolute inset-0 flex flex-col {s.id === activeSession?.id
? 'z-10 opacity-100 pointer-events-auto'
: 'z-0 opacity-0 pointer-events-none'}"
aria-hidden={s.id !== activeSession?.id}
>
<SessionWrapper sessionId={s.id} />
</div>
{/each}
</div>
{/if}