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

@@ -0,0 +1,163 @@
<script lang="ts">
// The desktop shell: full-viewport surface (background + icons + the
// centered task launcher + the floating window layer) with the taskbar
// docked below it as a real flex sibling, not an overlay — so a maximized
// or dragged window can never end up underneath the taskbar. This replaces
// the old sidebar + hash-routed page shell in App.svelte entirely; apps are
// desktop icons now (see $lib/apps.ts), not nav items.
import { APPS } from '$lib/apps'
import { iconPositions, resetIconLayout } from '$lib/stores/icons'
import { wm, openAppWindow, toggleShowDesktop } from '$lib/stores/windows'
import { summary } from '$lib/stores/context'
import GraphBackground from '../GraphBackground.svelte'
import DesktopIcon from './DesktopIcon.svelte'
import TaskLauncher from './TaskLauncher.svelte'
import WindowLayer from './WindowLayer.svelte'
import Taskbar from './Taskbar.svelte'
import LayersIcon from '@lucide/svelte/icons/layers'
import Rows3Icon from '@lucide/svelte/icons/rows-3'
import MonitorIcon from '@lucide/svelte/icons/monitor'
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw'
import Undo2Icon from '@lucide/svelte/icons/undo-2'
import Redo2Icon from '@lucide/svelte/icons/redo-2'
let { onOpenConnection }: { onOpenConnection: () => void } = $props()
// Clicking the bare desktop (not an icon, not a window) blurs the focused
// window — the familiar "click empty desktop to deselect" affordance.
function onSurfaceClick(e: MouseEvent) {
if (e.currentTarget === e.target) wm.blur()
}
// Right-click menu, bare desktop only (same currentTarget===target gate as
// onSurfaceClick above — icons and windows sit on pointer-events-auto
// layers above the otherwise pointer-events-none surface, so a right-click
// that lands on either of them never reaches here). canUndo/canRedo are
// plain wmkit method calls (not stores), so they're snapshotted once at
// open time rather than read reactively in the template.
let menuPos = $state<{ x: number; y: number } | null>(null)
let menuCanUndo = $state(false)
let menuCanRedo = $state(false)
function onSurfaceContextMenu(e: MouseEvent) {
if (e.currentTarget !== e.target) return
e.preventDefault()
menuCanUndo = wm.canUndo()
menuCanRedo = wm.canRedo()
menuPos = { x: e.clientX, y: e.clientY }
}
function closeMenu() {
menuPos = null
}
function runMenuAction(fn: () => void) {
fn()
closeMenu()
}
// Cmd/Ctrl+Z / Shift+Z for window-arrangement undo/redo (move, resize,
// close, ...) — wmkit tracks this history but ships no default keybinding.
// Skipped entirely while an editable element has focus so it never
// fights the browser's own text-undo inside the task input or a form
// field.
function onWindowKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && menuPos) {
closeMenu()
return
}
const target = e.target as HTMLElement | null
const editable = !!target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
if (editable) return
if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== 'z') return
e.preventDefault()
if (e.shiftKey) wm.redo()
else wm.undo()
}
</script>
<svelte:window onkeydown={onWindowKeydown} onclick={closeMenu} />
<div class="fixed inset-0 flex flex-col">
<div
class="relative min-h-0 flex-1 overflow-hidden"
role="presentation"
onclick={onSurfaceClick}
oncontextmenu={onSurfaceContextMenu}
>
<GraphBackground />
<div class="pointer-events-none absolute inset-0 z-0">
{#each APPS as app (app.id)}
{@const pos = $iconPositions[app.id] ?? { col: 0, row: 0 }}
{@const badge = app.badge?.($summary) ?? 0}
<DesktopIcon {app} {pos} {badge} onOpen={() => openAppWindow(app.id)} />
{/each}
</div>
<div class="pointer-events-none absolute inset-0 z-10 flex items-center justify-center p-6">
<div class="pointer-events-auto">
<TaskLauncher />
</div>
</div>
<WindowLayer />
</div>
<Taskbar {onOpenConnection} />
</div>
{#if menuPos}
<div
class="fixed z-50 min-w-48 rounded-md border bg-popover p-1 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10"
style="left: {menuPos.x}px; top: {menuPos.y}px"
role="menu"
>
<button
type="button"
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground"
onclick={() => runMenuAction(() => wm.arrange('cascade'))}
>
<LayersIcon class="size-4" /> Cascade windows
</button>
<button
type="button"
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground"
onclick={() => runMenuAction(() => wm.arrange('tile'))}
>
<Rows3Icon class="size-4" /> Tile windows
</button>
<button
type="button"
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground"
onclick={() => runMenuAction(toggleShowDesktop)}
>
<MonitorIcon class="size-4" /> Show desktop
</button>
<div class="my-1 h-px bg-border"></div>
<button
type="button"
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground"
onclick={() => runMenuAction(resetIconLayout)}
>
<RotateCcwIcon class="size-4" /> Reset icon layout
</button>
<div class="my-1 h-px bg-border"></div>
<button
type="button"
disabled={!menuCanUndo}
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50"
onclick={() => runMenuAction(() => wm.undo())}
>
<Undo2Icon class="size-4" /> Undo
</button>
<button
type="button"
disabled={!menuCanRedo}
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50"
onclick={() => runMenuAction(() => wm.redo())}
>
<Redo2Icon class="size-4" /> Redo
</button>
</div>
{/if}