// One global wmkit window manager for the whole app (mounted once by // WindowLayer.svelte inside Desktop.svelte) — this is what lets an entity // opened from Knowledge Base, chat, or anywhere else land in the same // floating window layer, with several windows open side by side, rather than // each page owning its own single-entity sidebar/sheet. import { derived, get, type Readable } from 'svelte/store' import { createManager, createDesktop, wmStore } from '@surdeddd/wmkit/svelte' import { persist } from '@surdeddd/wmkit/persist' import { appById, appWindowId } from '$lib/apps' import { toggleDocked } from '$lib/stores/docked' import { sessions } from '$lib/stores/chat' import { heading } from '$lib/tasks' // Window ids for a task/session's chat window are namespaced `session:` // — see openTaskWindow below. Shared here (rather than each file redeclaring // its own copy) since both WindowLayer.svelte and focusedSessionId below // need to parse it. export const SESSION_PREFIX = 'session:' export const wm = createManager({ defaultSize: { width: 480, height: 560 } }) // New windows (and manual resizing) must never exceed the visible desktop // area — without this, a content-heavy entity window (many Details/ // Relations/Tasks sections) grows taller than the viewport with no way to // reach its own titlebar controls. Clamps requested width/height down to // the current viewport and caps maxWidth/maxHeight the same way, so // dragging a resize handle can't push it past the edge either. function clampToDesktop< T extends { width?: number; height?: number; maxWidth?: number; maxHeight?: number } >(init: T): T { const { viewport } = wm.getState() if (viewport.width <= 0 || viewport.height <= 0) return init return { ...init, width: init.width !== undefined ? Math.min(init.width, viewport.width) : undefined, height: init.height !== undefined ? Math.min(init.height, viewport.height) : undefined, maxWidth: Math.min(init.maxWidth ?? viewport.width, viewport.width), maxHeight: Math.min(init.maxHeight ?? viewport.height, viewport.height) } } export const dk = createDesktop(wm, { // topEdge:'maximize' + preview gives the classic drag-to-top-maximizes // affordance; magnetism/keyboard are wmkit defaults worth turning on now // that windows are the whole app's primary surface, not a secondary layer. snap: { topEdge: 'maximize', preview: true }, keyboard: true, magnetism: true, // Animates a minimized window toward its taskbar button instead of just // vanishing — Taskbar.svelte tags each button with this same attribute. minimizeTarget: (win) => document.querySelector(`[data-taskbar-btn="${CSS.escape(win.id)}"]`) }) export const wmState = wmStore(wm) // The session id backing whichever task/chat window currently has focus, or // null when no task window is focused (Tasks app, an entity window, or // nothing at all). The desktop mascot's stimuli (stimuli.ts) key off this so // its reactions track the task the operator is actually looking at, rather // than firing for every session fleet-wide. export const focusedSessionId: Readable = derived(wmState, ($s) => $s.focusedId?.startsWith(SESSION_PREFIX) ? $s.focusedId.slice(SESSION_PREFIX.length) : null ) // Layout survives reloads: every window id is self-describing (app:, // session:, or a bare entity slug — see EntityDesktop/WindowLayer's // content branch), so a hydrated window needs no extra bookkeeping to know // what to render once the desktop remounts. export const wmPersist = persist(wm, { key: 'oikos-windows', debounce: 300, autoRestore: true }) // A task window is titled from the operator's (truncated) prompt at // creation time — openTaskWindow below and chat.ts's startTask both only // know the raw text, not the goal/heading the backend eventually derives // for the session. Whenever the sessions list refreshes (loadSessions(), // called all over — on task events, after a turn completes, ...) resync // any open task window's title to the session's real heading, so the // taskbar/titlebar stop showing the placeholder forever. sessions.subscribe((list) => { for (const s of list) { const id = `session:${s.id}` const win = wm.get(id) if (!win) continue const title = heading(s) if (win.title !== title) wm.update(id, { title }) } }) // Classic show-desktop toggle: minimize everything, or if everything's // already minimized (a prior show-desktop, or the operator minimized them // all by hand), bring them all back rather than being a one-way action. // Shared by the taskbar button and the desktop's right-click menu. export function toggleShowDesktop(): void { const anyVisible = wm.getState().order.some((id) => wm.get(id)?.stage !== 'minimized') if (anyVisible) wm.minimizeAll() else wm.restoreAll() } // Opens (or focuses/restores) a registry app's window. Apps are // single-instance — double-clicking an already-open app's icon should never // stack a second window, same dedupe pattern as openEntityWindow below. // Docked apps (e.g. the mascot) have no wmkit window at all — clicking their // icon toggles visibility on the Docked Layer instead, so this branches on // kind before touching the window manager. All callers (the desktop icon, // the taskbar settings button, legacy hash resolution) go through here, so // none of them need a kind-specific branch. export function openAppWindow(appId: string): void { const app = get(appById).get(appId) if (!app) return if (app.docked) { toggleDocked(appId) return } const id = appWindowId(appId) if (wm.get(id)) { wm.restore(id) wm.focus(id) return } wm.open( clampToDesktop({ id, title: app.title, width: app.width, height: app.height, minWidth: app.minWidth, minHeight: app.minHeight }) ) } // Opens a window for the entity, or focuses (and restores, if minimized) the // existing one — wm.open() throws if a window with this id already exists, // and slugs make natural, stable window ids (also dedupes "same entity // opened twice" into one window instead of stacking duplicates). export function openEntityWindow(slug: string | null): void { if (!slug) return if (wm.get(slug)) { wm.restore(slug) wm.focus(slug) return } wm.open(clampToDesktop({ id: slug, title: slug })) } // Singleton "compose a new task" window — the Tasks app's New Task button // opens this rather than a dialog, since everything else in the desktop is // already a window. It renders as an empty chat (NewTaskChat, in // WindowLayer.svelte) sized like a real task window rather than a separate // compose screen, and closes itself once the task's session window takes // over. export const NEW_TASK_WINDOW_ID = 'new-task' export function openNewTaskWindow(): void { if (wm.get(NEW_TASK_WINDOW_ID)) { wm.restore(NEW_TASK_WINDOW_ID) wm.focus(NEW_TASK_WINDOW_ID) return } wm.open( clampToDesktop({ id: NEW_TASK_WINDOW_ID, title: 'New task', width: 900, height: 640, minWidth: 600, minHeight: 400 }) ) } // Same dedupe/restore/focus pattern as openEntityWindow, for a task/session's // chat window. Id is namespaced `session:` — distinct from entity window // ids (always a bare `type:identifier` slug, and task ENTITIES already use // `task:` as their own slug) so a task's chat window and its entity // detail window never collide over the same wmkit id. See // WindowLayer.svelte for the id -> content-component branch. export function openTaskWindow(sessionId: string | null, title: string): void { if (!sessionId) return const id = `session:${sessionId}` if (wm.get(id)) { wm.restore(id) wm.focus(id) return } wm.open(clampToDesktop({ id, title, width: 900, height: 640, minWidth: 600, minHeight: 400 })) }