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>
128 lines
4.9 KiB
Svelte
128 lines
4.9 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte'
|
|
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 { Button } from '$lib/components/ui/button'
|
|
import PlusIcon from '@lucide/svelte/icons/plus'
|
|
import type { Session } from '$lib/api'
|
|
|
|
// ── Task board ──────────────────────────────────────────────────────────
|
|
let filter = $state<'all' | Bucket>('all')
|
|
|
|
const counts = $derived.by(() => {
|
|
const c: Record<string, number> = { all: $sessions.length, running: 0, input: 0, done: 0, failed: 0 }
|
|
for (const s of $sessions) c[bucket(s)]++
|
|
return c
|
|
})
|
|
const visible = $derived(
|
|
filter === 'all' ? $sessions : $sessions.filter((s) => bucket(s) === filter)
|
|
)
|
|
|
|
function openTask(s: Session) {
|
|
openTaskWindow(s.id, heading(s))
|
|
}
|
|
|
|
onMount(() => {
|
|
loadSessions()
|
|
const unsubStream = subscribeEvents()
|
|
|
|
// Refetch the board when a task's lifecycle changes anywhere. Scan all
|
|
// events newer than the last seen (entity.touched fires constantly and
|
|
// buries task.status); debounce a burst into one refetch.
|
|
let lastSeenId = 0
|
|
let refreshTimer: ReturnType<typeof setTimeout> | null = null
|
|
const unsub = liveEvents.subscribe((evs) => {
|
|
if (evs.length === 0) return
|
|
const maxId = evs[0].id
|
|
if (maxId <= lastSeenId) return
|
|
const relevant = evs.some((e) => e.id > lastSeenId && TASK_EVENTS.has(e.type))
|
|
lastSeenId = maxId
|
|
if (relevant) {
|
|
if (refreshTimer) clearTimeout(refreshTimer)
|
|
refreshTimer = setTimeout(() => loadSessions(), 400)
|
|
}
|
|
})
|
|
|
|
return () => {
|
|
unsub()
|
|
unsubStream()
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<div class="relative flex h-full flex-col gap-3 overflow-hidden p-4">
|
|
<GraphBackground />
|
|
|
|
<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>
|
|
|
|
<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>
|
|
</div>
|
|
{: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)}
|
|
>
|
|
<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>
|
|
{/if}
|
|
</div>
|
|
</div>
|