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,78 +0,0 @@
<script lang="ts">
import { messages, streaming, connectionState, sendMessage, cancelStream, reconnect, error, chatErrors, dismissError } from '$lib/stores/chat'
import ChatThread from '$lib/components/ChatThread.svelte'
import TaskContextPanel from '$lib/components/TaskContextPanel.svelte'
let { showRail = true }: { showRail?: boolean } = $props()
// Resizable right rail (session graph). Persisted so it survives reloads.
const RAIL_MIN = 260
const RAIL_MAX = 620
function loadRailWidth(): number {
if (typeof localStorage === 'undefined') return 320
const v = Number(localStorage.getItem('oikos-rail-width'))
return v >= RAIL_MIN && v <= RAIL_MAX ? v : 320
}
let railWidth = $state(loadRailWidth())
let resizing = $state(false)
function startResize(e: PointerEvent) {
e.preventDefault()
resizing = true
const startX = e.clientX
const startW = railWidth
function move(ev: PointerEvent) {
railWidth = Math.min(RAIL_MAX, Math.max(RAIL_MIN, startW + (startX - ev.clientX)))
}
function up() {
resizing = false
localStorage.setItem('oikos-rail-width', String(railWidth))
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', up)
}
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
}
const suggestions = [
'What needs my attention right now?',
'Summarize fleet health',
'Any pending approvals or open signals?',
'What changed in the last hour?'
]
</script>
<div class="flex h-full min-h-0">
<ChatThread
messages={$messages}
streaming={$streaming}
connectionState={$connectionState}
error={$error}
chatErrors={$chatErrors}
onSend={sendMessage}
onCancel={cancelStream}
onReconnect={reconnect}
onDismissError={dismissError}
{suggestions}
/>
{#if showRail}
<div class="hidden shrink-0 xl:flex" style="width: {railWidth}px">
<button
type="button"
class="group/rz relative w-1.5 shrink-0 cursor-col-resize touch-none"
onpointerdown={startResize}
aria-label="Resize task panel"
>
<span
class="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 transition-colors {resizing
? 'bg-primary/60'
: 'bg-border group-hover/rz:bg-primary/50'}"
></span>
</button>
<div class="flex min-w-0 flex-1 flex-col">
<TaskContextPanel />
</div>
</div>
{/if}
</div>

View File

@@ -1,8 +0,0 @@
<script lang="ts">
import EntityDetailContent from '$lib/components/EntityDetailContent.svelte'
import { openEntityWindow } from '$lib/stores/windows'
let { slug }: { slug: string } = $props()
</script>
<EntityDetailContent {slug} onSelectEntity={openEntityWindow} />

View File

@@ -28,7 +28,7 @@
// both views are asking the same underlying question ("show me X").
let search = $state('')
// Tracks only the most recently opened entity, for row/node highlight —
// actual detail viewing now happens in floating windows (EntityDesktop),
// actual detail viewing now happens in floating windows (WindowLayer, mounted inside Desktop.svelte),
// which can have several entities open at once.
let lastOpened = $state<string | null>(null)
@@ -42,7 +42,7 @@
openEntityWindow(slug)
}
// Drop the highlight once its window is closed (from EntityDesktop, not
// Drop the highlight once its window is closed (from WindowLayer, not
// necessarily from here) rather than leaving a row/node marked "open" when
// it isn't anymore.
$effect(() => {
@@ -273,7 +273,7 @@
</div>
<!-- browse pane — selecting an entity opens it in a floating window
(EntityDesktop, mounted globally in App.svelte) instead of a sidebar. -->
(WindowLayer, mounted globally inside Desktop.svelte) instead of a sidebar. -->
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
{#if view === 'graph'}
<EntityGraph

View File

@@ -1,47 +1,14 @@
<script lang="ts">
import { onMount } from 'svelte'
import { fetchDashboardSummary, type DashboardSummary, type Session } from '$lib/api'
import { sessions, loadSessions, newChat, sendMessage } from '$lib/stores/chat'
import { openTaskWindow } from '$lib/stores/windows'
import { sessions, loadSessions } from '$lib/stores/chat'
import { openTaskWindow, openNewTaskWindow } from '$lib/stores/windows'
import { liveEvents, subscribeEvents } from '$lib/stores/events'
import { bucket, statusStyle, FILTERS, TASK_EVENTS, heading, type Bucket } from '$lib/tasks'
import { relativeTime } from '$lib/utils'
import GraphBackground from '$lib/components/GraphBackground.svelte'
import { Textarea } from '$lib/components/ui/textarea'
import { Button } from '$lib/components/ui/button'
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
import CircleCheckIcon from '@lucide/svelte/icons/circle-check'
import TriangleAlertIcon from '@lucide/svelte/icons/triangle-alert'
// ── Dashboard metrics (ported from the old Overview cards) ──────────────
let summary = $state<DashboardSummary | null>(null)
async function loadSummary() {
summary = await fetchDashboardSummary()
}
const totalEntities = $derived(
summary ? Object.values(summary.entities_by_type).reduce((a, b) => a + b, 0) : 0
)
const entityTypeCount = $derived(summary ? Object.keys(summary.entities_by_type).length : 0)
const totalMonitored = $derived(
summary
? summary.health.healthy + summary.health.degraded + summary.health.down + summary.health.unknown
: 0
)
const healthTone = $derived(
!summary ? 'ok' : summary.health.down > 0 ? 'down' : summary.health.degraded > 0 ? 'degraded' : 'ok'
)
const totalSignals = $derived(
summary ? Object.values(summary.signals_by_severity).reduce((a, b) => a + b, 0) : 0
)
const worstSeverity = $derived(
summary?.signals_by_severity.critical
? 'critical'
: summary?.signals_by_severity.warning
? 'warning'
: 'none'
)
import PlusIcon from '@lucide/svelte/icons/plus'
import type { Session } from '$lib/api'
// ── Task board ──────────────────────────────────────────────────────────
let filter = $state<'all' | Bucket>('all')
@@ -59,28 +26,9 @@
openTaskWindow(s.id, heading(s))
}
// ── New task entry ──────────────────────────────────────────────────────
let input = $state('')
function submit() {
const text = input.trim()
if (!text) return
input = ''
newChat()
sendMessage(text)
location.hash = '#/chat'
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
submit()
}
}
onMount(() => {
loadSummary()
loadSessions()
const unsubStream = subscribeEvents()
const summaryTimer = setInterval(loadSummary, 15000)
// Refetch the board when a task's lifecycle changes anywhere. Scan all
// events newer than the last seen (entity.touched fires constantly and
@@ -102,161 +50,78 @@
return () => {
unsub()
unsubStream()
clearInterval(summaryTimer)
}
})
</script>
<div class="relative h-full overflow-hidden">
<div class="relative flex h-full flex-col gap-3 overflow-hidden p-4">
<GraphBackground />
<div class="relative z-10 h-full overflow-y-auto">
<!-- Hero: fills the viewport. The input is pinned to true vertical center via the
grid's middle 1fr row; the metrics strip and scroll hint sit in the auto rows
above/below without shifting it off-center. Scrolling lifts the whole hero to
reveal the table. -->
<section class="grid min-h-full grid-rows-[auto_1fr_auto] gap-6 px-4 py-16">
<!-- Metrics strip -->
<div class="flex flex-wrap items-center justify-center gap-2 text-xs">
<div class="flex items-center gap-1.5 rounded-full border bg-card/60 px-3 py-1.5 backdrop-blur">
<span class="text-muted-foreground">Entities</span>
<span class="font-semibold tabular-nums">{totalEntities}</span>
{#if entityTypeCount}<span class="text-muted-foreground">· {entityTypeCount} types</span>{/if}
</div>
<div class="flex items-center gap-1.5 rounded-full border bg-card/60 px-3 py-1.5 backdrop-blur">
<span
class="size-2 rounded-full {healthTone === 'ok' ? 'bg-success' : healthTone === 'degraded' ? 'bg-warning' : 'bg-destructive'}"
></span>
<span class="text-muted-foreground">Health</span>
<span class="font-semibold tabular-nums">{summary?.health.healthy ?? 0} / {totalMonitored}</span>
</div>
<button
type="button"
onclick={() => (location.hash = '#/signals')}
class="flex items-center gap-1.5 rounded-full border bg-card/60 px-3 py-1.5 backdrop-blur transition-colors hover:border-primary/50"
>
{#if worstSeverity === 'critical'}
<TriangleAlertIcon class="size-3.5 text-destructive" />
{:else if worstSeverity === 'warning'}
<TriangleAlertIcon class="size-3.5 text-warning" />
{:else}
<CircleCheckIcon class="size-3.5 text-success" />
{/if}
<span class="text-muted-foreground">Signals</span>
<span class="font-semibold tabular-nums">{totalSignals}</span>
</button>
<button
type="button"
onclick={() => (location.hash = '#/ops')}
class="flex items-center gap-1.5 rounded-full border bg-card/60 px-3 py-1.5 backdrop-blur transition-colors hover:border-primary/50"
>
<span class="text-muted-foreground">Approvals</span>
<span class="font-semibold tabular-nums {summary?.approvals_pending ? 'text-destructive' : ''}"
>{summary?.approvals_pending ?? 0}</span
>
</button>
</div>
<div class="relative z-10 flex flex-wrap items-center gap-1.5">
{#each FILTERS as f}
<button
type="button"
onclick={() => (filter = f.id)}
class="rounded-full border px-2.5 py-1 text-xs transition-colors {filter === f.id
? 'border-primary bg-primary/10 text-foreground'
: 'border-border bg-card/60 text-muted-foreground backdrop-blur hover:bg-muted/50'}"
>
{f.label}
<span class="ml-1 opacity-60">{counts[f.id] ?? 0}</span>
</button>
{/each}
<div class="flex-1"></div>
<Button size="sm" class="gap-1.5" onclick={openNewTaskWindow}>
<PlusIcon class="size-4" />
New task
</Button>
</div>
<!-- New task entry: centered in the middle (1fr) row -->
<div class="flex items-center justify-center">
<div class="w-full max-w-2xl text-center">
<h1 class="mb-1 text-2xl font-semibold tracking-tight">What should Nomos do?</h1>
<p class="mb-4 text-sm text-muted-foreground">
Describe a goal — Nomos will plan it, execute it, and report the outcome.
<div class="relative z-10 min-h-0 flex-1 overflow-auto rounded-xl border bg-card/70 backdrop-blur">
{#if visible.length === 0}
<div class="flex flex-col items-center gap-2 px-4 py-16 text-center">
<p class="max-w-sm text-sm text-muted-foreground">
{filter === 'all'
? 'No tasks yet. Start one and Nomos will plan it, execute it, and report the outcome.'
: `No ${FILTERS.find((f) => f.id === filter)?.label.toLowerCase()} tasks.`}
</p>
<form
class="relative rounded-2xl border bg-card/70 shadow-lg backdrop-blur focus-within:border-primary/60"
onsubmit={(e) => {
e.preventDefault()
submit()
}}
>
<Textarea
bind:value={input}
onkeydown={handleKeydown}
placeholder="e.g. Roll the staging database back to last night's snapshot and verify the app is healthy…"
rows={3}
class="max-h-52 min-h-24 resize-none border-0 bg-transparent px-4 py-3.5 text-base shadow-none focus-visible:ring-0"
/>
<div class="flex items-center justify-between px-3 pb-3">
<span class="text-[11px] text-muted-foreground">Enter to start · Shift+Enter for newline</span>
<Button type="submit" size="icon" disabled={!input.trim()} aria-label="Start task">
<ArrowUpIcon />
</Button>
</div>
</form>
</div>
</div>
<span class="justify-self-center text-[11px] text-muted-foreground/70">Scroll to see all tasks ↓</span>
</section>
<!-- Task table -->
<section class="mx-auto w-full max-w-5xl px-4 pb-16">
<div class="rounded-xl border bg-card/70 backdrop-blur">
<div class="flex flex-wrap items-center gap-1.5 border-b p-3">
{#each FILTERS as f}
<button
type="button"
onclick={() => (filter = f.id)}
class="rounded-full border px-2.5 py-1 text-xs transition-colors {filter === f.id
? 'border-primary bg-primary/10 text-foreground'
: 'border-border text-muted-foreground hover:bg-muted/50'}"
{:else}
<table class="w-full text-sm">
<thead>
<tr class="border-b text-left text-xs text-muted-foreground">
<th class="w-36 px-4 py-2 font-medium">Status</th>
<th class="px-4 py-2 font-medium">Task</th>
<th class="hidden px-4 py-2 font-medium md:table-cell">Summary</th>
<th class="w-28 px-4 py-2 text-right font-medium">Last active</th>
</tr>
</thead>
<tbody>
{#each visible as s (s.id)}
{@const st = statusStyle(s)}
<tr
class="cursor-pointer border-b last:border-0 transition-colors hover:bg-muted/40"
onclick={() => openTask(s)}
>
{f.label}
<span class="ml-1 opacity-60">{counts[f.id] ?? 0}</span>
</button>
<td class="px-4 py-2.5">
<span class="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<span class="size-2 rounded-full {st.dot} {st.pulse ? 'animate-pulse' : ''}"></span>
{st.label}
</span>
</td>
<td class="max-w-0 px-4 py-2.5">
<span class="line-clamp-1 font-medium">{heading(s)}</span>
</td>
<td class="hidden max-w-0 px-4 py-2.5 md:table-cell">
<span class="line-clamp-1 text-xs text-muted-foreground">{s.summary || '—'}</span>
</td>
<td class="whitespace-nowrap px-4 py-2.5 text-right text-[11px] text-muted-foreground">
{relativeTime(s.last_active_at)}
</td>
</tr>
{/each}
</div>
{#if visible.length === 0}
<div class="flex flex-col items-center gap-2 px-4 py-16 text-center">
<p class="max-w-sm text-sm text-muted-foreground">
{filter === 'all'
? 'No tasks yet. Start one above and Nomos will plan it, execute it, and report the outcome.'
: `No ${FILTERS.find((f) => f.id === filter)?.label.toLowerCase()} tasks.`}
</p>
</div>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b text-left text-xs text-muted-foreground">
<th class="w-36 px-4 py-2 font-medium">Status</th>
<th class="px-4 py-2 font-medium">Task</th>
<th class="hidden px-4 py-2 font-medium md:table-cell">Summary</th>
<th class="w-28 px-4 py-2 text-right font-medium">Last active</th>
</tr>
</thead>
<tbody>
{#each visible as s (s.id)}
{@const st = statusStyle(s)}
<tr
class="cursor-pointer border-b last:border-0 transition-colors hover:bg-muted/40"
onclick={() => openTask(s)}
>
<td class="px-4 py-2.5">
<span class="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<span class="size-2 rounded-full {st.dot} {st.pulse ? 'animate-pulse' : ''}"></span>
{st.label}
</span>
</td>
<td class="max-w-0 px-4 py-2.5">
<span class="line-clamp-1 font-medium">{heading(s)}</span>
</td>
<td class="hidden max-w-0 px-4 py-2.5 md:table-cell">
<span class="line-clamp-1 text-xs text-muted-foreground">{s.summary || '—'}</span>
</td>
<td class="whitespace-nowrap px-4 py-2.5 text-right text-[11px] text-muted-foreground">
{relativeTime(s.last_active_at)}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
</section>
</tbody>
</table>
{/if}
</div>
</div>