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,56 +0,0 @@
|
||||
<script lang="ts">
|
||||
// Global floating-window layer — mounted once in App.svelte, above every
|
||||
// page, so an entity opened from Knowledge Base, chat, or anywhere else
|
||||
// lands in the same window stack instead of each page owning its own
|
||||
// single-entity sidebar/sheet. See $lib/stores/windows.ts. Minimized
|
||||
// windows reopen from MinimizedWindowsBar, mounted separately inside the
|
||||
// page layout (not here) so it takes real space instead of floating over
|
||||
// content — see that component for why.
|
||||
import { dk, wmState, openEntityWindow } from '$lib/stores/windows'
|
||||
import EntityDetailContent from './EntityDetailContent.svelte'
|
||||
import SessionChatWindow from './SessionChatWindow.svelte'
|
||||
import XIcon from '@lucide/svelte/icons/x'
|
||||
import MinusIcon from '@lucide/svelte/icons/minus'
|
||||
|
||||
const SESSION_PREFIX = 'session:'
|
||||
</script>
|
||||
|
||||
<div use:dk.desktop class="pointer-events-none fixed inset-0 z-40">
|
||||
{#each $wmState.order as id (id)}
|
||||
{@const win = $wmState.windows[id]}
|
||||
{#if win}
|
||||
<section use:dk.window={{ id }} class="min-w-0" aria-label={win.title}>
|
||||
<header data-wm-drag class="flex shrink-0 cursor-move items-center justify-between gap-2 border-b bg-muted/40 px-3 py-1.5">
|
||||
<span data-wm-title class="flex min-w-0 flex-1 items-center self-stretch truncate font-mono text-xs font-medium">{win.title}</span>
|
||||
<div class="flex shrink-0 items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
data-wm-minimize
|
||||
class="flex items-center justify-center rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
aria-label="Minimize {win.title}"
|
||||
>
|
||||
<MinusIcon class="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-wm-close
|
||||
class="flex items-center justify-center rounded p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
aria-label="Close {win.title}"
|
||||
>
|
||||
<XIcon class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div data-wm-content class="min-h-0 flex-1 overflow-hidden">
|
||||
{#key id}
|
||||
{#if id.startsWith(SESSION_PREFIX)}
|
||||
<SessionChatWindow sessionId={id.slice(SESSION_PREFIX.length)} />
|
||||
{:else}
|
||||
<EntityDetailContent slug={id} onSelectEntity={openEntityWindow} />
|
||||
{/if}
|
||||
{/key}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
@@ -1,36 +0,0 @@
|
||||
<script lang="ts">
|
||||
// Deliberately mounted inside the page layout (App.svelte's Sidebar.Inset
|
||||
// column, not alongside EntityDesktop's full-viewport overlay) — as a real
|
||||
// flex child it takes its own height and the page content above it
|
||||
// (min-h-0 flex-1) shrinks to make room, instead of floating over
|
||||
// whatever's scrolled to the bottom.
|
||||
import { wm, wmState } from '$lib/stores/windows'
|
||||
import { truncateMiddle } from '$lib/utils'
|
||||
import Maximize2Icon from '@lucide/svelte/icons/maximize-2'
|
||||
|
||||
const minimizedIds = $derived($wmState.order.filter((id) => $wmState.windows[id]?.stage === 'minimized'))
|
||||
|
||||
function restoreWindow(id: string) {
|
||||
wm.restore(id)
|
||||
wm.focus(id)
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if minimizedIds.length}
|
||||
<div class="flex shrink-0 items-center gap-1.5 overflow-x-auto border-t bg-muted/30 px-2 py-1.5">
|
||||
{#each minimizedIds as id (id)}
|
||||
{@const win = $wmState.windows[id]}
|
||||
{#if win}
|
||||
<button
|
||||
type="button"
|
||||
class="group flex max-w-56 shrink-0 items-center gap-2 rounded-md border bg-card py-1 pr-1.5 pl-2.5 font-mono text-xs hover:bg-muted"
|
||||
onclick={() => restoreWindow(id)}
|
||||
title={win.title}
|
||||
>
|
||||
<span class="min-w-0 truncate">{truncateMiddle(win.title, 32)}</span>
|
||||
<Maximize2Icon class="size-3 shrink-0 text-muted-foreground group-hover:text-foreground" aria-hidden="true" />
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -20,6 +20,7 @@
|
||||
const chatStreaming = chat.streaming
|
||||
const chatConnectionState = chat.connectionState
|
||||
const chatError = chat.error
|
||||
const chatNotFound = chat.notFound
|
||||
let loading = $state(true)
|
||||
|
||||
onMount(async () => {
|
||||
@@ -57,6 +58,11 @@
|
||||
<div class="flex h-full min-h-0">
|
||||
{#if loading}
|
||||
<div class="flex flex-1 items-center justify-center text-xs text-muted-foreground">Loading…</div>
|
||||
{:else if $chatNotFound}
|
||||
<div class="flex flex-1 flex-col items-center justify-center gap-1 p-6 text-center">
|
||||
<p class="text-sm text-muted-foreground">Task not found.</p>
|
||||
<p class="text-xs text-muted-foreground/70">It may have been deleted.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<ChatThread
|
||||
messages={$chatMessages}
|
||||
|
||||
@@ -215,7 +215,7 @@
|
||||
})
|
||||
|
||||
// The highlight ring/dim styling below is tied to the node whose window
|
||||
// was last opened — once that window is closed (from EntityDesktop, not
|
||||
// was last opened — once that window is closed (from WindowLayer, not
|
||||
// necessarily from here), the ring should go with it rather than pointing
|
||||
// at a window that no longer exists.
|
||||
$effect(() => {
|
||||
@@ -265,7 +265,7 @@
|
||||
|
||||
// ─── drag / select ───────────────────────────────────────────────────
|
||||
// A click (pointerdown+up with no movement in between) opens the entity
|
||||
// straight in its own floating window (EntityDesktop) instead of a
|
||||
// straight in its own floating window (WindowLayer) instead of a
|
||||
// click-through mini-panel — `selected` now only drives the highlight/dim
|
||||
// styling below, so you can see at a glance which node you last opened.
|
||||
let dragState: { node: Node; moved: boolean } | null = null
|
||||
|
||||
163
web/src/lib/components/desktop-shell/Desktop.svelte
Normal file
163
web/src/lib/components/desktop-shell/Desktop.svelte
Normal 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}
|
||||
100
web/src/lib/components/desktop-shell/DesktopIcon.svelte
Normal file
100
web/src/lib/components/desktop-shell/DesktopIcon.svelte
Normal file
@@ -0,0 +1,100 @@
|
||||
<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>
|
||||
62
web/src/lib/components/desktop-shell/TaskLauncher.svelte
Normal file
62
web/src/lib/components/desktop-shell/TaskLauncher.svelte
Normal file
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
// "What should Nomos do?" — the desktop's centerpiece. Extracted from the
|
||||
// old Overview page hero so both the desktop surface and the Tasks app
|
||||
// window can mount it; startTask() (see $lib/stores/chat.ts) begins the
|
||||
// stream immediately and hands back the session id once the backend
|
||||
// assigns one, which is the earliest point a task window can be opened.
|
||||
import { startTask } from '$lib/stores/chat'
|
||||
import { openTaskWindow } from '$lib/stores/windows'
|
||||
import { truncateMiddle } from '$lib/utils'
|
||||
import { Textarea } from '$lib/components/ui/textarea'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
|
||||
|
||||
let { compact = false, onStarted }: { compact?: boolean; onStarted?: () => void } = $props()
|
||||
|
||||
let input = $state('')
|
||||
|
||||
function submit() {
|
||||
const text = input.trim()
|
||||
if (!text) return
|
||||
input = ''
|
||||
startTask(text, (sessionId) => openTaskWindow(sessionId, truncateMiddle(text, 60)))
|
||||
onStarted?.()
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
submit()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="w-full max-w-2xl text-center">
|
||||
{#if !compact}
|
||||
<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.
|
||||
</p>
|
||||
{/if}
|
||||
<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={compact ? 2 : 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>
|
||||
131
web/src/lib/components/desktop-shell/Taskbar.svelte
Normal file
131
web/src/lib/components/desktop-shell/Taskbar.svelte
Normal 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>
|
||||
89
web/src/lib/components/desktop-shell/WindowLayer.svelte
Normal file
89
web/src/lib/components/desktop-shell/WindowLayer.svelte
Normal file
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
// The floating-window layer — mounted once inside Desktop.svelte, above the
|
||||
// icons layer, so every window (an app, a task, an entity detail) shares
|
||||
// one stack instead of each page owning its own single-entity
|
||||
// sidebar/sheet. See $lib/stores/windows.ts. Content is resolved purely
|
||||
// from the window's id, which is why persisted/hydrated windows (see
|
||||
// wmPersist in windows.ts) need no extra bookkeeping to know what to render:
|
||||
// app:<id> -> registry component (windows.ts openAppWindow)
|
||||
// session:<id> -> SessionChatWindow (windows.ts openTaskWindow)
|
||||
// new-task -> TaskLauncher (windows.ts openNewTaskWindow)
|
||||
// anything else -> entity slug -> EntityDetailContent
|
||||
import { wm, dk, wmState, openEntityWindow, NEW_TASK_WINDOW_ID } from '$lib/stores/windows'
|
||||
import { appById, appIdFromWindowId } from '$lib/apps'
|
||||
import EntityDetailContent from '../EntityDetailContent.svelte'
|
||||
import SessionChatWindow from '../SessionChatWindow.svelte'
|
||||
import TaskLauncher from './TaskLauncher.svelte'
|
||||
import XIcon from '@lucide/svelte/icons/x'
|
||||
import MinusIcon from '@lucide/svelte/icons/minus'
|
||||
import Maximize2Icon from '@lucide/svelte/icons/maximize-2'
|
||||
|
||||
const SESSION_PREFIX = 'session:'
|
||||
|
||||
// A hydrated `app:<id>` window whose id no longer matches any registry
|
||||
// entry (the app was renamed/removed since the layout was persisted) has
|
||||
// nothing to render — close it rather than leaving a permanently-blank
|
||||
// window stuck in the taskbar.
|
||||
$effect(() => {
|
||||
for (const id of $wmState.order) {
|
||||
const appId = appIdFromWindowId(id)
|
||||
if (appId && !appById.has(appId)) wm.close(id)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div use:dk.desktop class="absolute inset-0 z-40 pointer-events-none">
|
||||
{#each $wmState.order as id (id)}
|
||||
{@const win = $wmState.windows[id]}
|
||||
{@const appId = appIdFromWindowId(id)}
|
||||
{@const app = appId ? appById.get(appId) : undefined}
|
||||
{#if win && (!appId || app)}
|
||||
<section use:dk.window={{ id }} class="min-w-0" aria-label={win.title}>
|
||||
<header data-wm-drag class="flex shrink-0 cursor-move items-center justify-between gap-2 border-b bg-muted/40 px-3 py-1.5">
|
||||
<span data-wm-title class="flex min-w-0 flex-1 items-center self-stretch truncate font-mono text-xs font-medium">{win.title}</span>
|
||||
<div class="flex shrink-0 items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
data-wm-minimize
|
||||
class="flex items-center justify-center rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
aria-label="Minimize {win.title}"
|
||||
>
|
||||
<MinusIcon class="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-wm-maximize
|
||||
class="flex items-center justify-center rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
aria-label="Maximize {win.title}"
|
||||
>
|
||||
<Maximize2Icon class="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-wm-close
|
||||
class="flex items-center justify-center rounded p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
aria-label="Close {win.title}"
|
||||
>
|
||||
<XIcon class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div data-wm-content class="min-h-0 flex-1 overflow-hidden">
|
||||
{#key id}
|
||||
{#if id.startsWith(SESSION_PREFIX)}
|
||||
<SessionChatWindow sessionId={id.slice(SESSION_PREFIX.length)} />
|
||||
{:else if id === NEW_TASK_WINDOW_ID}
|
||||
<div class="flex h-full items-center justify-center p-6">
|
||||
<TaskLauncher onStarted={() => wm.close(NEW_TASK_WINDOW_ID)} />
|
||||
</div>
|
||||
{:else if app}
|
||||
<app.component />
|
||||
{:else}
|
||||
<EntityDetailContent slug={id} onSelectEntity={openEntityWindow} />
|
||||
{/if}
|
||||
{/key}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
Reference in New Issue
Block a user