feat(web): redesign UI as an OS-style desktop shell
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:
@@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user