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()
}
)
}