Files
oikos/web/src/lib/stores/windows.ts
dtoro 1aaedf498a
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
v0.20.0: thinking blocks, chat windows overhaul, scroll fix
Backend:
- Add isThinking flag to agentEvent for text before tool calls
- Separate thinking from response text in runChatTurn and continue.go
- Persist thinking in a dedicated field in message content

Frontend:
- Add thinking field to MessageContent, ChatMessage, ChatTextEvent types
- Create ThinkingBlock.svelte — collapsible block with brain icon
- SSE handler moves text_delta content to thinking on isThinking flag
- Render thinking block between tools and response in ChatThread
- Fix chat window scroll reset on focus change (stable windowKeys order)
- Remove redundant #key id wrapper in WindowLayer
- Enlarge sidebar rail (24→32 default, 40→60 max)
- Remove glyph from sidebar, square graph at top
- Replace AgentTrace/ToolCallCard/UnifiedTimeline with TurnTrace/ToolLine
2026-08-04 22:42:53 +02:00

214 lines
8.8 KiB
TypeScript

// 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:<id>`
// — 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)
// Stable insertion-order window IDs — unlike $wmState.order (which reorders on
// focus/raise), this only changes when a window is opened or closed. Used by
// WindowLayer's {#each} so the DOM order stays stable; wmkit handles visual
// stacking via z-index in syncAll(). Without this, every focus change moves
// <section> elements in the DOM, which resets scroll positions of scrollable
// children in Chrome.
let _lastKeys: string[] = []
export const windowKeys = derived(wmState, ($s) => {
const keys = Object.keys($s.windows)
if (keys.length === _lastKeys.length && keys.every((k, i) => k === _lastKeys[i])) {
return _lastKeys
}
_lastKeys = keys
return keys
})
// 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<string | null> = 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:<id>,
// session:<id>, 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'
// Seed text for the next new-task window. Module state rather than a window
// property because wmkit windows carry only geometry — WindowLayer reads this
// when it mounts NewTaskChat. Cleared on read so a later manually-opened task
// does not inherit a stale prompt.
let pendingTaskDraft = ''
export function takePendingTaskDraft(): string {
const draft = pendingTaskDraft
pendingTaskDraft = ''
return draft
}
export function openNewTaskWindow(draft = ''): void {
pendingTaskDraft = draft
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:<id>` — distinct from entity window
// ids (always a bare `type:identifier` slug, and task ENTITIES already use
// `task:<uuid>` 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 }))
}