Problem: the frontend had an implicit OS+Apps metaphor (desktop, floating windows, an app registry) but the contract was informal — the mascot was hardcoded into the shell, all apps were statically imported into one 800KB bundle, and there was no install/uninstall path. Change: three phases landed. - Phase 1 (contract + docked kind): AppDef extended with docked/noIcon and optional geometry; the mascot registered as a docked app via a generic DockedLayer that replaces the hardcoded <MascotLayer />; openAppWindow branches on docked → toggleDocked; persisted docked visibility store (absent key = visible, no APPS import to avoid a static cycle). - Phase 2 (lazy loading): AppDef.component is now a dynamic-import loader; LazyApp renders with a loading skeleton; Vite code-splits each app (main bundle 800KB→485KB); the LazyMascot wrapper is gone since the lazy loader breaks the import cycle directly. - Phase 3 (installable apps, local bundles): AppManifest + catalog + installApp/uninstallApp + localStorage persistence; reactive apps store (built-in + installed) and derived appById; App Store page; Notes demo app; icons.ts and WindowLayer's orphan-close react to registration so installs appear without a reload. - Structure: data-table casing unified to PascalCase; the mislabeled DataTable.svelte.ts (pure types, not runes) renamed to types.ts; LazyApp colocated with its desktop-shell consumers; app-store moved under lib/ so the dependency direction is consistent. Risk: the app registry is now a reactive store, not a static array, so every consumer (Desktop, DockedLayer, Taskbar, icons, windows) reads from derived stores. Two static-cycle traps are documented in docs/mbse/components.md §9: docked.ts must not import APPS (it would fire a TDZ at init via the apps.ts→pages→windows.ts→here path), and apps.ts must not statically import the mascot (the lazy loader defers its module graph). Remote bundle loading, the /api/v1/apps endpoint, and permission enforcement are deliberately NOT in this commit — they are security-critical and deferred to Phase 4 with an ADR. Verification: vitest 38/38; svelte-check + tsc clean for changed files; eslint clean; vite build green; runtime smoke confirmed (install Notes → icon appears → open → uninstall → icon + window gone; survives reload). docs/mbse/components.md Component 9 and the plan updated. Plan: plans/2026-07-21-frontend-os-apps-architecture.md
101 lines
3.7 KiB
Svelte
101 lines
3.7 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, FILTERS, TASK_EVENTS, heading, type Bucket } from '$lib/tasks'
|
|
import { Button } from '$lib/components/ui/button'
|
|
import DataTable from '$lib/components/data-table/DataTable.svelte'
|
|
import type { DataTableColumn } from '$lib/components/data-table/types'
|
|
import StatusDotRenderer from '$lib/components/data-table/renderers/StatusDotRenderer.svelte'
|
|
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()
|
|
|
|
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()
|
|
}
|
|
})
|
|
|
|
const columns: DataTableColumn<Session>[] = [
|
|
{ key: '_status', header: 'Status', render: StatusDotRenderer, width: '140px' },
|
|
{ key: '_heading', header: 'Task', sortable: true, accessor: heading, truncate: true },
|
|
{ key: 'summary', header: 'Summary', accessor: (s) => s.summary || '—', truncate: true, headerClass: 'hidden md:table-cell', class: 'hidden md:table-cell' },
|
|
{ key: 'last_active_at', header: 'Last active', render: 'relative-time', sortable: true, width: '112px', align: 'right' },
|
|
]
|
|
|
|
const emptyMessage = $derived(
|
|
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.`
|
|
)
|
|
</script>
|
|
|
|
<div class="relative flex h-full flex-col gap-3 overflow-hidden p-4">
|
|
|
|
<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-hidden rounded-xl border bg-card/70 backdrop-blur">
|
|
<DataTable
|
|
{columns}
|
|
data={visible}
|
|
{emptyMessage}
|
|
bordered={false}
|
|
onRowClick={openTask}
|
|
/>
|
|
</div>
|
|
</div>
|