feat(web): redesign UI as an OS-style desktop shell
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

Replace the sidebar + hash-routed page shell with a desktop metaphor:
draggable app icons, apps opening as floating wmkit windows, a centered
"What should Nomos do?" task launcher, and a bottom taskbar showing all
open windows plus a system tray.

- New app registry ($lib/apps.ts) — adding an app is one entry, nothing
  else to touch.
- New desktop shell components (Desktop, WindowLayer, DesktopIcon,
  Taskbar, TaskLauncher) under $lib/components/desktop-shell/.
- Icon positions are a persisted, collision-avoiding grid ($lib/stores/icons.ts).
- Window layout persists across reloads (wmkit persist), with
  drag-to-maximize, F6 window cycling, and now a right-click desktop menu
  (cascade/tile/show desktop/reset icons) plus Cmd/Ctrl+Z undo/redo for
  window moves, resizes, and closes.
- Taskbar buttons get a hover-close and self-correct their title once a
  new task's real goal is known.
- New task windows (desktop launcher and the Tasks app's "New task"
  button) open as a window, not a dialog, and hand off to the real
  session window once the backend assigns an id.
- Fixed a real gap along the way: GET /sessions/{id} couldn't tell
  "session deleted" from "session has no messages yet" (both returned
  200 with an empty list) — cmd/nomos/main.go now checks existence and
  404s, so a stale/persisted task window shows "Task not found" instead
  of a misleadingly empty, live-looking chat.
- Test coverage for the new pure logic (icon placement/collision
  avoidance, app registry id helpers) plus a vitest matchMedia polyfill
  needed to import anything touching the theme store.

Deletes the now-superseded sidebar shell, MinimizedWindowsBar, and the
standalone Chat/EntityDetail pages (folded into the window layer).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 21:34:15 +02:00
parent 8657ac5669
commit aed068de12
24 changed files with 1314 additions and 617 deletions

View File

@@ -1,5 +1,5 @@
import { writable, get, type Writable } from 'svelte/store'
import { streamChat, fetchSessions, fetchMessages, deleteSession as apiDeleteSession } from '$lib/api'
import { streamChat, fetchSessions, fetchMessages, fetchMessagesOrNotFound, deleteSession as apiDeleteSession } from '$lib/api'
import type { ChatEvent, Session, Message } from '$lib/api'
import type { ToolCallResult } from '$lib/types'
@@ -499,6 +499,11 @@ export interface SessionChatState {
streaming: Writable<boolean>
connectionState: Writable<'connected' | 'disconnected' | 'reconnecting'>
error: Writable<string | null>
// Set by loadSessionChat when the backend 404s the session outright
// (deleted, or an id that was never valid — a stale persisted window, a
// bad deep link). Distinct from a merely-empty transcript, which is the
// normal state for a session that exists but hasn't sent a message yet.
notFound: Writable<boolean>
}
const sessionChats = new Map<string, SessionChatState>()
@@ -509,7 +514,13 @@ const sessionPollers = new Map<string, ReturnType<typeof setInterval>>()
export function chatFor(sessionId: string): SessionChatState {
let c = sessionChats.get(sessionId)
if (!c) {
c = { messages: writable([]), streaming: writable(false), connectionState: writable('connected'), error: writable(null) }
c = {
messages: writable([]),
streaming: writable(false),
connectionState: writable('connected'),
error: writable(null),
notFound: writable(false)
}
sessionChats.set(sessionId, c)
}
return c
@@ -544,7 +555,11 @@ export function stopSessionPolling(sessionId: string) {
export async function loadSessionChat(sessionId: string): Promise<void> {
const chat = chatFor(sessionId)
chat.streaming.set(false)
const msgs = await fetchMessages(sessionId)
const msgs = await fetchMessagesOrNotFound(sessionId)
if (msgs === null) {
chat.notFound.set(true)
return // nothing to poll — the session doesn't exist
}
chat.messages.set(toChatMessages(msgs))
startSessionPolling(sessionId)
}
@@ -650,3 +665,117 @@ export function cancelSessionStream(sessionId: string) {
activeControllers.delete(sessionId)
chatFor(sessionId).streaming.set(false)
}
// ─── new-task launcher (desktop center input / Tasks app) ───────────────────
//
// Starting a brand-new task has no session id to hang a window off of until
// the stream's own 'session' event assigns one (see the 'session' branch in
// sendMessage above) — the desktop launcher needs to open that task's window
// the moment an id exists, not before. startTask begins the stream
// immediately, buffers any events that arrive before 'session' (defensive:
// in practice 'session' always arrives first), then seeds that session's own
// chatFor() bundle exactly like sendSessionMessage does and hands the id back
// via onSession so the caller can open its window. From that point on the
// window behaves exactly like any other task window.
export function startTask(text: string, onSession: (sessionId: string) => void): void {
const userMsg: ChatMessage = { id: mid(), role: 'user', text, tools: [], pendingApprovals: [] }
const assistantMsg: ChatMessage = { id: mid(), role: 'assistant', text: '', tools: [], pendingApprovals: [] }
const activeTools: Map<string, ToolCallResult> = new Map()
let receivedDone = false
let sessionId: string | null = null
let chat: SessionChatState | null = null
const buffered: ChatEvent[] = []
function apply(ev: ChatEvent) {
const c = chat
if (!c || !sessionId) return
if (ev.type === 'tool_use') {
const tr: ToolCallResult = { type: 'tool_use', name: ev.data.name, id: ev.data.id, args: ev.data.args }
activeTools.set(ev.data.id, tr)
c.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') last.tools = [...last.tools, tr]
return [...ms]
})
} else if (ev.type === 'tool_result') {
const existing = activeTools.get(ev.data.id)
if (existing) {
const updated: ToolCallResult = { ...existing, type: 'tool_result', result: ev.data.result, error: ev.data.error }
activeTools.set(ev.data.id, updated)
c.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') {
last.tools = last.tools.map((t) => (t.id === ev.data.id ? updated : t))
last.pendingApprovals = extractApprovals(last.tools)
}
return [...ms]
})
}
} else if (ev.type === 'text_delta') {
c.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') last.text += ev.data
return [...ms]
})
} else if (ev.type === 'text') {
c.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') last.text = ev.data
return [...ms]
})
} else if (ev.type === 'done') {
receivedDone = true
c.connectionState.set('connected')
c.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') last.pendingApprovals = extractApprovals(last.tools)
return [...ms]
})
startSessionPolling(sessionId)
} else if (ev.type === 'error') {
c.error.set(ev.data)
}
}
const controller = streamChat(
text,
null,
(ev: ChatEvent) => {
if (ev.type === 'session') {
sessionId = ev.data
activeControllers.set(sessionId, controller)
chat = chatFor(sessionId)
chat.streaming.set(true)
chat.messages.update((ms) => [...ms, userMsg, assistantMsg])
onSession(sessionId)
for (const b of buffered.splice(0)) apply(b)
return
}
if (!chat) {
buffered.push(ev)
return
}
apply(ev)
},
(err: string) => {
if (!chat) return // never got a session id — nothing to show the error in
if (err === 'AbortError' || err.includes('aborted')) {
chat.streaming.set(false)
return
}
chat.error.set(err)
if (!receivedDone && sessionId) {
chat.connectionState.set('disconnected')
startSessionPolling(sessionId)
addChatError('Agent connection lost. The task is still running — retrying…', 'Dismiss')
} else {
chat.streaming.set(false)
}
},
() => {
if (chat) chat.streaming.set(false)
if (sessionId && activeControllers.get(sessionId) === controller) activeControllers.delete(sessionId)
loadSessions()
}
)
}

View File

@@ -0,0 +1,95 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
// icons.ts only needs APPS for its default-layout ids — stub it out rather
// than pull in the real registry's full page-component graph (see
// apps.test.ts for why that graph is expensive/broken under vitest).
vi.mock('$lib/apps', () => ({
APPS: [{ id: 'tasks' }, { id: 'kb' }, { id: 'ops' }, { id: 'signals' }, { id: 'knowledge' }, { id: 'learning' }]
}))
import { GRID, iconPositions, placeIcon, iconPixelPos, maxCols, getIconPositions, resetIconLayout } from './icons'
// placeIcon mutates the shared module-level store, so each test starts from
// a known, empty layout rather than whatever the previous test (or apps.ts's
// registry-derived defaults) left behind.
beforeEach(() => {
iconPositions.set({})
localStorage.clear()
})
describe('iconPixelPos', () => {
it('converts a grid cell to pixel coordinates using GRID constants', () => {
expect(iconPixelPos({ col: 0, row: 0 })).toEqual({ x: GRID.padding, y: GRID.padding })
expect(iconPixelPos({ col: 1, row: 2 })).toEqual({
x: GRID.padding + (GRID.cell + GRID.gap),
y: GRID.padding + 2 * (GRID.cell + GRID.gap)
})
})
})
describe('maxCols', () => {
it('computes how many columns fit in a viewport width', () => {
const cellSpan = GRID.cell + GRID.gap
expect(maxCols(GRID.padding + cellSpan * 3)).toBe(3)
})
it('never returns less than 1, even for a tiny viewport', () => {
expect(maxCols(0)).toBe(1)
expect(maxCols(GRID.padding)).toBe(1)
})
})
describe('placeIcon', () => {
it('places an icon at the requested cell when it is free', () => {
placeIcon('tasks', 3, 4)
expect(getIconPositions().tasks).toEqual({ col: 3, row: 4 })
})
it('clamps negative coordinates to 0 for an otherwise-free cell', () => {
placeIcon('tasks', -5, -2)
expect(getIconPositions().tasks).toEqual({ col: 0, row: 0 })
})
it('nudges to the nearest free cell when the target is occupied', () => {
iconPositions.set({ kb: { col: 2, row: 2 } })
placeIcon('tasks', 2, 2)
const pos = getIconPositions().tasks
// Must not land on top of kb, and must be one of the 8 immediate
// neighbors (radius-1 ring) since all of them are free.
expect(pos).not.toEqual({ col: 2, row: 2 })
expect(Math.max(Math.abs(pos.col - 2), Math.abs(pos.row - 2))).toBe(1)
})
it('does not disturb the icon already occupying a cell when another icon is nudged past it', () => {
iconPositions.set({ kb: { col: 2, row: 2 } })
placeIcon('tasks', 2, 2)
expect(getIconPositions().kb).toEqual({ col: 2, row: 2 })
})
it('moving an icon back onto its own current cell is a no-op collision (never nudges against itself)', () => {
iconPositions.set({ tasks: { col: 5, row: 5 } })
placeIcon('tasks', 5, 5)
expect(getIconPositions().tasks).toEqual({ col: 5, row: 5 })
})
it('persists the updated layout to localStorage', () => {
placeIcon('tasks', 1, 1)
const stored = JSON.parse(localStorage.getItem('oikos-desktop-icons') ?? '{}')
expect(stored.tasks).toEqual({ col: 1, row: 1 })
})
})
describe('resetIconLayout', () => {
it('restores the classic left-edge column in registry order', () => {
iconPositions.set({ tasks: { col: 4, row: 7 }, kb: { col: 1, row: 1 } })
resetIconLayout()
expect(getIconPositions()).toEqual({
tasks: { col: 0, row: 0 },
kb: { col: 0, row: 1 },
ops: { col: 0, row: 2 },
signals: { col: 0, row: 3 },
knowledge: { col: 0, row: 4 },
learning: { col: 0, row: 5 }
})
})
})

113
web/src/lib/stores/icons.ts Normal file
View File

@@ -0,0 +1,113 @@
// Desktop icon positions — a simple column/row grid, persisted to
// localStorage so icons stay where the operator put them across reloads.
// Deliberately NOT wmkit: wmkit manages floating windows (pixel bounds,
// z-order, stage), icons are a flat, non-overlapping grid with a much
// simpler drag model (snap-to-cell, no resize/minimize/stack). Reinventing
// that inside wmkit would mean fighting its window-shaped abstractions for
// no benefit.
import { writable, get } from 'svelte/store'
import { APPS } from '$lib/apps'
export interface IconPos {
col: number
row: number
}
export const GRID = { cell: 96, gap: 12, padding: 16 }
const STORAGE_KEY = 'oikos-desktop-icons'
function defaultPositions(): Record<string, IconPos> {
// Classic OS default: one left-edge column, registry order.
const out: Record<string, IconPos> = {}
APPS.forEach((app, i) => {
out[app.id] = { col: 0, row: i }
})
return out
}
function load(): Record<string, IconPos> {
if (typeof localStorage === 'undefined') return defaultPositions()
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return defaultPositions()
const parsed = JSON.parse(raw) as Record<string, IconPos>
const out = defaultPositions()
// Merge over the defaults so a newly-registered app (not in the saved
// blob yet) still gets a sane starting position instead of being absent
// from the grid entirely.
for (const [id, pos] of Object.entries(parsed)) {
if (appIds.has(id)) out[id] = pos
}
return out
} catch {
return defaultPositions()
}
}
const appIds = new Set(APPS.map((a) => a.id))
export const iconPositions = writable<Record<string, IconPos>>(load())
function persist(positions: Record<string, IconPos>): void {
if (typeof localStorage === 'undefined') return
localStorage.setItem(STORAGE_KEY, JSON.stringify(positions))
}
iconPositions.subscribe((positions) => persist(positions))
function occupied(positions: Record<string, IconPos>, col: number, row: number, exceptId: string): boolean {
return Object.entries(positions).some(([id, p]) => id !== exceptId && p.col === col && p.row === row)
}
// Finds the nearest free cell to (col, row) via an expanding ring search,
// so dropping an icon onto an occupied cell nudges it to the closest open
// spot instead of silently overlapping or refusing the drop.
function nearestFreeCell(
positions: Record<string, IconPos>,
col: number,
row: number,
exceptId: string
): IconPos {
if (!occupied(positions, col, row, exceptId)) return { col: Math.max(0, col), row: Math.max(0, row) }
for (let radius = 1; radius < 64; radius++) {
for (let dc = -radius; dc <= radius; dc++) {
for (let dr = -radius; dr <= radius; dr++) {
if (Math.max(Math.abs(dc), Math.abs(dr)) !== radius) continue
const c = col + dc
const r = row + dr
if (c < 0 || r < 0) continue
if (!occupied(positions, c, r, exceptId)) return { col: c, row: r }
}
}
}
return { col: Math.max(0, col), row: Math.max(0, row) }
}
export function placeIcon(appId: string, col: number, row: number): void {
iconPositions.update((positions) => {
const target = nearestFreeCell(positions, col, row, appId)
return { ...positions, [appId]: target }
})
}
export function iconPixelPos(pos: IconPos): { x: number; y: number } {
return {
x: GRID.padding + pos.col * (GRID.cell + GRID.gap),
y: GRID.padding + pos.row * (GRID.cell + GRID.gap)
}
}
export function maxCols(viewportWidth: number): number {
return Math.max(1, Math.floor((viewportWidth - GRID.padding) / (GRID.cell + GRID.gap)))
}
export function getIconPositions(): Record<string, IconPos> {
return get(iconPositions)
}
// Bails a messy manual layout back to the classic left-edge column,
// registry order — the desktop's right-click menu's "Reset icon layout".
export function resetIconLayout(): void {
iconPositions.set(defaultPositions())
}

View File

@@ -1,14 +1,83 @@
// One global wmkit window manager for the whole app (mounted once by
// EntityDesktop.svelte in App.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.
// 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 { createManager, createDesktop, wmStore } from '@surdeddd/wmkit/svelte'
import { persist } from '@surdeddd/wmkit/persist'
import { appById, appWindowId } from '$lib/apps'
import { sessions } from '$lib/stores/chat'
import { heading } from '$lib/tasks'
export const wm = createManager({ defaultSize: { width: 480, height: 560 } })
export const dk = createDesktop(wm)
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)
// 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.
export function openAppWindow(appId: string): void {
const app = appById.get(appId)
if (!app) return
const id = appWindowId(appId)
if (wm.get(id)) {
wm.restore(id)
wm.focus(id)
return
}
wm.open({
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
@@ -23,12 +92,28 @@ export function openEntityWindow(slug: string | null): void {
wm.open({ 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; TaskLauncher closes it itself (via its onStarted
// callback, wired up in WindowLayer.svelte) 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({ id: NEW_TASK_WINDOW_ID, title: 'New task', width: 480, height: 340, minWidth: 360, minHeight: 280 })
}
// 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
// EntityDesktop.svelte for the id -> content-component branch.
// WindowLayer.svelte for the id -> content-component branch.
export function openTaskWindow(sessionId: string | null, title: string): void {
if (!sessionId) return
const id = `session:${sessionId}`