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,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>
|
||||
@@ -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} />
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user