diff --git a/cmd/nomos/main.go b/cmd/nomos/main.go index 17f8475..e0837f1 100644 --- a/cmd/nomos/main.go +++ b/cmd/nomos/main.go @@ -18,6 +18,7 @@ import ( "github.com/dtoro/oikos/internal/safego" "github.com/google/uuid" + "github.com/jackc/pgx/v5" ) func main() { @@ -448,6 +449,21 @@ func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store, a *a w.WriteHeader(204) case http.MethodGet: + // getMessages alone can't distinguish "session exists but has no + // messages yet" from "session id doesn't exist at all" — it's a + // plain WHERE session_id=$1 query that returns zero rows either + // way. A frontend window opened for a deleted/invalid session + // (persisted layout, a stale link) needs to tell those apart, so + // check existence explicitly and 404 rather than silently + // returning an empty transcript that looks like a fresh task. + if _, err := st.getSession(r.Context(), id); err != nil { + if err == pgx.ErrNoRows { + http.Error(w, "session not found", 404) + return + } + http.Error(w, err.Error(), 500) + return + } messages, err := st.getMessages(r.Context(), id) if err != nil { http.Error(w, err.Error(), 500) diff --git a/web/src/App.svelte b/web/src/App.svelte index c15e32f..2b74e65 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -1,47 +1,42 @@ {#if !configured} @@ -97,161 +61,6 @@ onCancel={isConfigured() ? () => (configured = true) : undefined} /> {:else} - - - - - - - - - navigate('overview')} - tooltipContent={`Oikos ${VERSION}`} - > - {#snippet child({ props })} - - {/snippet} - - - -
- {VERSION} -
- - - { newChat(); navigate('chat') }} - tooltipContent="New task" - > - {#snippet child({ props })} - - {/snippet} - - - -
- - - - - {#each navItems as item} - - navigate(item.id)} - tooltipContent={item.label} - > - {#snippet child({ props })} - - {/snippet} - - {#if item.badge?.()} - {item.badge()} - {/if} - - {/each} - - - - - - - - - -
- - -
- - - {#if page === 'chat'} - {@const goalText = $currentTask?.goal ? $currentTask.goal.replace(/[*_`~#]|\[.*?\]\(.*?\)/g, '') : 'New Task'} - - / - - {truncateMiddle(goalText, 100)} - - {:else} - {page === 'entity' ? routeParam : page === 'kb' ? 'Knowledge Base' : page === 'overview' ? 'Tasks' : page} - {/if} -
-
- {#if page === 'overview'} - - {:else if page === 'kb'} - - {:else if page === 'entity' && routeParam} - - {:else if page === 'ops'} - - {:else if page === 'signals'} - - {:else if page === 'knowledge'} - - {:else if page === 'learning'} - - {:else} - - {/if} -
- -
-
- - - - - Nomos chat - Persistent chat drawer - -
- -
-
-
- - - + + (configured = false)} /> {/if} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 49de2f8..36ee6b8 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -50,6 +50,21 @@ export async function fetchMessages(sessionId: string): Promise { return data.messages ?? [] } +// null distinguishes "session doesn't exist" (404 — the session was deleted, +// or a persisted/deep-linked window id was never valid) from a transient +// fetch failure, which should keep returning []/retrying rather than +// permanently flip a window into a "not found" state. Only the initial +// per-window load (chat.ts's loadSessionChat) needs this distinction — the +// polling loops keep using fetchMessages, where swallowing a blip into [] +// and trying again next tick is the right behavior. +export async function fetchMessagesOrNotFound(sessionId: string): Promise { + const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}`) + if (res.status === 404) return null + if (!res.ok) return [] + const data = await res.json() + return data.messages ?? [] +} + export async function deleteSession(sessionId: string): Promise { const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}`, { method: 'DELETE' }) return res.ok diff --git a/web/src/lib/apps.test.ts b/web/src/lib/apps.test.ts new file mode 100644 index 0000000..aab0b53 --- /dev/null +++ b/web/src/lib/apps.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, vi } from 'vitest' + +// apps.ts wires in every page component for real use, but that drags a +// heavy transitive graph into a unit test for no benefit here (and one of +// those pages imports svelte-sonner, which fails to resolve under vitest's +// bundled Vite — an unrelated, pre-existing package quirk). These tests only +// care about the registry's own shape (ids, sizes, window-id helpers), so +// stub the component imports out rather than pull all of that in. +vi.mock('../pages/Overview.svelte', () => ({ default: {} })) +vi.mock('../pages/KnowledgeBase.svelte', () => ({ default: {} })) +vi.mock('../pages/Ops.svelte', () => ({ default: {} })) +vi.mock('../pages/Signals.svelte', () => ({ default: {} })) +vi.mock('../pages/Knowledge.svelte', () => ({ default: {} })) +vi.mock('../pages/Learning.svelte', () => ({ default: {} })) + +import { APPS, appById, appWindowId, appIdFromWindowId } from './apps' + +describe('APPS registry', () => { + it('has unique, non-empty ids', () => { + const ids = APPS.map((a) => a.id) + expect(ids.length).toBeGreaterThan(0) + expect(new Set(ids).size).toBe(ids.length) + for (const id of ids) expect(id).not.toBe('') + }) + + it('gives every app a positive default size', () => { + for (const app of APPS) { + expect(app.width).toBeGreaterThan(0) + expect(app.height).toBeGreaterThan(0) + } + }) + + it('is indexed by id in appById', () => { + for (const app of APPS) { + expect(appById.get(app.id)).toBe(app) + } + expect(appById.size).toBe(APPS.length) + }) +}) + +describe('appWindowId / appIdFromWindowId', () => { + it('round-trips an app id through its window id', () => { + for (const app of APPS) { + expect(appIdFromWindowId(appWindowId(app.id))).toBe(app.id) + } + }) + + it('returns null for ids that are not app windows', () => { + expect(appIdFromWindowId('session:abc-123')).toBeNull() + expect(appIdFromWindowId('host:strong')).toBeNull() + expect(appIdFromWindowId('new-task')).toBeNull() + }) + + it('namespaces window ids so they cannot collide with entity slugs', () => { + // Entity slugs are bare `type:identifier` strings (see windows.ts's + // openEntityWindow) — app window ids must never look like one. + for (const app of APPS) { + expect(appWindowId(app.id).startsWith('app:')).toBe(true) + } + }) +}) diff --git a/web/src/lib/apps.ts b/web/src/lib/apps.ts new file mode 100644 index 0000000..e2fb233 --- /dev/null +++ b/web/src/lib/apps.ts @@ -0,0 +1,118 @@ +// The desktop's app registry — single source of truth for what shows up as +// a desktop icon and what opens in its window. Adding a new app is one entry +// here; nothing else needs to change (Desktop.svelte renders icons from +// APPS, WindowLayer.svelte resolves `app:` window ids back through +// appById, Taskbar.svelte reads title/icon the same way). Compare to the old +// App.svelte's hardcoded navItems array + if/else page branch, which required +// touching three places (nav list, header title, main content branch) to add +// one page. +import type { Component } from 'svelte' +import type { DashboardSummary } from '$lib/api' +import { openSignalCount } from '$lib/stores/context' +import Overview from '../pages/Overview.svelte' +import KnowledgeBase from '../pages/KnowledgeBase.svelte' +import Ops from '../pages/Ops.svelte' +import Signals from '../pages/Signals.svelte' +import Knowledge from '../pages/Knowledge.svelte' +import Learning from '../pages/Learning.svelte' +import ListTodoIcon from '@lucide/svelte/icons/list-todo' +import DatabaseIcon from '@lucide/svelte/icons/database' +import ShieldCheckIcon from '@lucide/svelte/icons/shield-check' +import SirenIcon from '@lucide/svelte/icons/siren' +import SearchIcon from '@lucide/svelte/icons/search' +import TrendingUpIcon from '@lucide/svelte/icons/trending-up' + +export interface AppDef { + id: string + title: string + icon: Component + component: Component + width: number + height: number + minWidth?: number + minHeight?: number + // Pure function over the shared dashboard summary — used for both the + // desktop icon's badge and the taskbar button's badge, so a new app that + // wants one just supplies this instead of each surface reimplementing it. + badge?: (summary: DashboardSummary | null) => number +} + +export const APPS: AppDef[] = [ + { + id: 'tasks', + title: 'Tasks', + icon: ListTodoIcon, + component: Overview, + width: 960, + height: 680, + minWidth: 480, + minHeight: 420 + }, + { + id: 'kb', + title: 'Knowledge Base', + icon: DatabaseIcon, + component: KnowledgeBase, + width: 1000, + height: 700, + minWidth: 520, + minHeight: 420 + }, + { + id: 'ops', + title: 'Operations', + icon: ShieldCheckIcon, + component: Ops, + width: 860, + height: 620, + minWidth: 480, + minHeight: 360, + badge: (s) => s?.approvals_pending ?? 0 + }, + { + id: 'signals', + title: 'Signals', + icon: SirenIcon, + component: Signals, + width: 860, + height: 620, + minWidth: 480, + minHeight: 360, + badge: (s) => openSignalCount(s) + }, + { + id: 'knowledge', + title: 'Knowledge', + icon: SearchIcon, + component: Knowledge, + width: 800, + height: 600, + minWidth: 440, + minHeight: 340 + }, + { + id: 'learning', + title: 'Learning', + icon: TrendingUpIcon, + component: Learning, + width: 800, + height: 600, + minWidth: 440, + minHeight: 340 + } +] + +export const appById = new Map(APPS.map((a) => [a.id, a])) + +// Window ids are namespaced so WindowLayer.svelte can tell at a glance which +// content branch owns an id: `app:` for registry apps, `session:` +// for task chat windows (see windows.ts), anything else is an entity slug. +const APP_PREFIX = 'app:' + +export function appWindowId(id: string): string { + return `${APP_PREFIX}${id}` +} + +export function appIdFromWindowId(windowId: string): string | null { + return windowId.startsWith(APP_PREFIX) ? windowId.slice(APP_PREFIX.length) : null +} diff --git a/web/src/lib/components/EntityDesktop.svelte b/web/src/lib/components/EntityDesktop.svelte deleted file mode 100644 index 95ecc37..0000000 --- a/web/src/lib/components/EntityDesktop.svelte +++ /dev/null @@ -1,56 +0,0 @@ - - -
- {#each $wmState.order as id (id)} - {@const win = $wmState.windows[id]} - {#if win} -
-
- {win.title} -
- - -
-
-
- {#key id} - {#if id.startsWith(SESSION_PREFIX)} - - {:else} - - {/if} - {/key} -
-
- {/if} - {/each} -
diff --git a/web/src/lib/components/MinimizedWindowsBar.svelte b/web/src/lib/components/MinimizedWindowsBar.svelte deleted file mode 100644 index d3e89d7..0000000 --- a/web/src/lib/components/MinimizedWindowsBar.svelte +++ /dev/null @@ -1,36 +0,0 @@ - - -{#if minimizedIds.length} -
- {#each minimizedIds as id (id)} - {@const win = $wmState.windows[id]} - {#if win} - - {/if} - {/each} -
-{/if} diff --git a/web/src/lib/components/SessionChatWindow.svelte b/web/src/lib/components/SessionChatWindow.svelte index 8189bd9..b3f4133 100644 --- a/web/src/lib/components/SessionChatWindow.svelte +++ b/web/src/lib/components/SessionChatWindow.svelte @@ -20,6 +20,7 @@ const chatStreaming = chat.streaming const chatConnectionState = chat.connectionState const chatError = chat.error + const chatNotFound = chat.notFound let loading = $state(true) onMount(async () => { @@ -57,6 +58,11 @@
{#if loading}
Loading…
+ {:else if $chatNotFound} +
+

Task not found.

+

It may have been deleted.

+
{:else} { @@ -265,7 +265,7 @@ // ─── drag / select ─────────────────────────────────────────────────── // A click (pointerdown+up with no movement in between) opens the entity - // straight in its own floating window (EntityDesktop) instead of a + // straight in its own floating window (WindowLayer) instead of a // click-through mini-panel — `selected` now only drives the highlight/dim // styling below, so you can see at a glance which node you last opened. let dragState: { node: Node; moved: boolean } | null = null diff --git a/web/src/lib/components/desktop-shell/Desktop.svelte b/web/src/lib/components/desktop-shell/Desktop.svelte new file mode 100644 index 0000000..ea65339 --- /dev/null +++ b/web/src/lib/components/desktop-shell/Desktop.svelte @@ -0,0 +1,163 @@ + + + + +
+ + + +
+ +{#if menuPos} + +{/if} diff --git a/web/src/lib/components/desktop-shell/DesktopIcon.svelte b/web/src/lib/components/desktop-shell/DesktopIcon.svelte new file mode 100644 index 0000000..1694abf --- /dev/null +++ b/web/src/lib/components/desktop-shell/DesktopIcon.svelte @@ -0,0 +1,100 @@ + + + diff --git a/web/src/lib/components/desktop-shell/TaskLauncher.svelte b/web/src/lib/components/desktop-shell/TaskLauncher.svelte new file mode 100644 index 0000000..472c8c5 --- /dev/null +++ b/web/src/lib/components/desktop-shell/TaskLauncher.svelte @@ -0,0 +1,62 @@ + + +
+ {#if !compact} +

What should Nomos do?

+

+ Describe a goal — Nomos will plan it, execute it, and report the outcome. +

+ {/if} +
{ + e.preventDefault() + submit() + }} + > +