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>
101 lines
3.3 KiB
Svelte
101 lines
3.3 KiB
Svelte
<script lang="ts">
|
|
// A single desktop icon: positioned from the grid store, draggable to any
|
|
// free cell, opens its app on a plain click. wmkit has nothing to do with
|
|
// icons — they're a flat non-overlapping grid, not floating/resizable
|
|
// windows, so this is a small self-contained pointer-drag implementation
|
|
// rather than pressing wmkit's window abstractions into a shape they don't
|
|
// fit. See $lib/stores/icons.ts for the grid model + persistence.
|
|
import { GRID, iconPixelPos, placeIcon, type IconPos } from '$lib/stores/icons'
|
|
import type { AppDef } from '$lib/apps'
|
|
|
|
let {
|
|
app,
|
|
pos,
|
|
badge = 0,
|
|
onOpen
|
|
}: { app: AppDef; pos: IconPos; badge?: number; onOpen: () => void } = $props()
|
|
|
|
const DRAG_THRESHOLD = 5
|
|
|
|
let dragging = $state(false)
|
|
let dragPos = $state<{ x: number; y: number } | null>(null)
|
|
|
|
function toCell(x: number, y: number): IconPos {
|
|
return {
|
|
col: Math.round((x - GRID.padding) / (GRID.cell + GRID.gap)),
|
|
row: Math.round((y - GRID.padding) / (GRID.cell + GRID.gap))
|
|
}
|
|
}
|
|
|
|
function onPointerDown(e: PointerEvent) {
|
|
if (e.button !== 0) return
|
|
const el = e.currentTarget as HTMLElement
|
|
const startX = e.clientX
|
|
const startY = e.clientY
|
|
const origin = iconPixelPos(pos)
|
|
let moved = false
|
|
|
|
el.setPointerCapture(e.pointerId)
|
|
|
|
function onMove(ev: PointerEvent) {
|
|
const dx = ev.clientX - startX
|
|
const dy = ev.clientY - startY
|
|
if (!moved && Math.hypot(dx, dy) > DRAG_THRESHOLD) {
|
|
moved = true
|
|
dragging = true
|
|
}
|
|
if (moved) {
|
|
dragPos = { x: origin.x + dx, y: origin.y + dy }
|
|
}
|
|
}
|
|
|
|
function onUp() {
|
|
el.removeEventListener('pointermove', onMove)
|
|
el.removeEventListener('pointerup', onUp)
|
|
if (moved && dragPos) {
|
|
const cell = toCell(dragPos.x, dragPos.y)
|
|
placeIcon(app.id, cell.col, cell.row)
|
|
} else {
|
|
onOpen()
|
|
}
|
|
dragging = false
|
|
dragPos = null
|
|
}
|
|
|
|
el.addEventListener('pointermove', onMove)
|
|
el.addEventListener('pointerup', onUp)
|
|
}
|
|
|
|
function onKeydown(e: KeyboardEvent) {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault()
|
|
onOpen()
|
|
}
|
|
}
|
|
|
|
const restPos = $derived(iconPixelPos(pos))
|
|
const left = $derived(dragging && dragPos ? dragPos.x : restPos.x)
|
|
const top = $derived(dragging && dragPos ? dragPos.y : restPos.y)
|
|
</script>
|
|
|
|
<button
|
|
type="button"
|
|
class="group absolute flex flex-col items-center gap-1 rounded-lg p-1.5 pointer-events-auto select-none focus-visible:outline-2 focus-visible:outline-ring {dragging
|
|
? 'z-50 cursor-grabbing bg-accent/40'
|
|
: 'cursor-pointer hover:bg-accent/30'}"
|
|
style="left: {left}px; top: {top}px; width: {GRID.cell}px;"
|
|
onpointerdown={onPointerDown}
|
|
onkeydown={onKeydown}
|
|
title={app.title}
|
|
>
|
|
<span class="relative flex size-10 items-center justify-center rounded-xl border bg-card/80 text-foreground shadow-sm backdrop-blur">
|
|
<app.icon class="size-5" />
|
|
{#if badge > 0}
|
|
<span class="absolute -right-1.5 -top-1.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-semibold text-destructive-foreground">
|
|
{badge > 99 ? '99+' : badge}
|
|
</span>
|
|
{/if}
|
|
</span>
|
|
<span class="max-w-full truncate text-[11px] text-foreground/90">{app.title}</span>
|
|
</button>
|