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,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}`