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,131 @@
<script lang="ts">
// Bottom taskbar: a real flex row in the page layout (not an overlay), so
// windows can never be dragged/maximized underneath it — see Desktop.svelte
// for how the window layer's bounds are scoped to the surface above this.
// Shows a button for every open window (not just minimized ones — compare
// to the old MinimizedWindowsBar, which only ever showed minimized windows
// and gave no way to see/switch between windows that were merely
// unfocused), plus a system tray for theme/connection/version.
import { wm, wmState, toggleShowDesktop } from '$lib/stores/windows'
import { appById, appIdFromWindowId } from '$lib/apps'
import { summary } from '$lib/stores/context'
import { truncateMiddle } from '$lib/utils'
import { getTheme, toggleTheme, THEME_LABELS } from '$lib/stores/theme.svelte'
import { VERSION } from '$lib/version'
import MessageSquareIcon from '@lucide/svelte/icons/message-square'
import DatabaseIcon from '@lucide/svelte/icons/database'
import PaletteIcon from '@lucide/svelte/icons/palette'
import SettingsIcon from '@lucide/svelte/icons/settings'
import LayoutGridIcon from '@lucide/svelte/icons/layout-grid'
import XIcon from '@lucide/svelte/icons/x'
let { onOpenConnection }: { onOpenConnection: () => void } = $props()
const SESSION_PREFIX = 'session:'
const buttons = $derived(
[...$wmState.order]
.map((id) => $wmState.windows[id])
.filter((w): w is NonNullable<typeof w> => !!w)
.sort((a, b) => a.openedSeq - b.openedSeq)
)
function iconFor(id: string) {
const appId = appIdFromWindowId(id)
if (appId) return appById.get(appId)?.icon
if (id.startsWith(SESSION_PREFIX)) return MessageSquareIcon
return DatabaseIcon
}
function badgeFor(id: string): number {
const appId = appIdFromWindowId(id)
const app = appId ? appById.get(appId) : undefined
return app?.badge?.($summary) ?? 0
}
function toggle(id: string, win: (typeof buttons)[number]) {
if (win.stage === 'minimized') {
wm.restore(id)
wm.focus(id)
} else if ($wmState.focusedId === id) {
wm.minimize(id)
} else {
wm.focus(id)
}
}
</script>
<div class="flex h-11 shrink-0 items-center gap-1.5 border-t bg-muted/30 px-2">
<button
type="button"
class="flex shrink-0 items-center justify-center rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
onclick={toggleShowDesktop}
title="Show desktop"
>
<LayoutGridIcon class="size-4" />
</button>
<div class="h-6 w-px shrink-0 bg-border"></div>
<div class="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto">
{#each buttons as win (win.id)}
{@const Icon = iconFor(win.id)}
{@const badge = badgeFor(win.id)}
<div class="group/tb relative flex shrink-0">
<button
type="button"
data-taskbar-btn={win.id}
class="flex h-8 max-w-56 items-center gap-1.5 rounded-md border px-2 font-mono text-xs transition-colors {$wmState.focusedId ===
win.id && win.stage !== 'minimized'
? 'border-primary/50 bg-primary/10 text-foreground'
: 'border-transparent bg-card/60 text-muted-foreground hover:bg-muted'} {win.stage === 'minimized' ? 'opacity-60' : ''}"
onclick={() => toggle(win.id, win)}
title={win.title}
>
{#if Icon}<Icon class="size-3.5 shrink-0" />{/if}
<span class="min-w-0 truncate">{truncateMiddle(win.title, 26)}</span>
{#if badge > 0}
<span class="flex h-3.5 min-w-3.5 shrink-0 items-center justify-center rounded-full bg-destructive px-1 text-[9px] font-semibold text-destructive-foreground">
{badge > 99 ? '99+' : badge}
</span>
{/if}
</button>
<button
type="button"
class="absolute -top-1.5 -right-1.5 hidden size-4 items-center justify-center rounded-full bg-muted-foreground/80 text-background hover:bg-destructive group-hover/tb:flex"
onclick={(e) => {
e.stopPropagation()
wm.close(win.id)
}}
aria-label="Close {win.title}"
>
<XIcon class="size-2.5" />
</button>
</div>
{/each}
</div>
<div class="h-6 w-px shrink-0 bg-border"></div>
<div class="flex shrink-0 items-center gap-0.5">
<button
type="button"
class="flex items-center justify-center gap-1.5 rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
onclick={() => toggleTheme()}
title="Cycle theme"
>
<PaletteIcon class="size-4" />
<span class="hidden text-xs sm:inline">{THEME_LABELS[getTheme()]}</span>
</button>
<button
type="button"
class="flex items-center justify-center rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
onclick={onOpenConnection}
title="Server connection settings"
>
<SettingsIcon class="size-4" />
</button>
<span class="px-1.5 text-[11px] text-muted-foreground select-none">{VERSION}</span>
</div>
</div>