feat(web): app-registry architecture — OS + Apps, lazy loading, installable apps
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

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
This commit is contained in:
2026-07-21 14:37:36 +02:00
parent 50aed11cc4
commit 482c7f3448
30 changed files with 1597 additions and 127 deletions

View File

@@ -1 +1 @@
0.11.0 0.12.0

View File

@@ -31,6 +31,7 @@ the relevant section here.
| [6. PostgreSQL/TimescaleDB](#6-postgresqltimescaledb) | `migrations/`, `seeds/` | ✅ live — the System's own source of truth | | [6. PostgreSQL/TimescaleDB](#6-postgresqltimescaledb) | `migrations/`, `seeds/` | ✅ live — the System's own source of truth |
| [7. Dormant components](#7-dormant-components) | `internal/actuator`, `internal/learning` | 🔴 compiled, never started | | [7. Dormant components](#7-dormant-components) | `internal/actuator`, `internal/learning` | 🔴 compiled, never started |
| [8. Auxiliary components](#8-auxiliary-components) | `cmd/webhook`, `cmd/desktop` | ✅ live — deploy + packaging, not decision logic | | [8. Auxiliary components](#8-auxiliary-components) | `cmd/webhook`, `cmd/desktop` | ✅ live — deploy + packaging, not decision logic |
| [9. web control room — App architecture](#9-web-control-room--app-architecture) | `web/src/lib/apps.ts`, `web/src/lib/stores/windows.ts`, `web/src/lib/stores/docked.ts`, `web/src/lib/components/desktop-shell/` | ✅ live — the OS + Apps shell contract |
--- ---
@@ -364,7 +365,11 @@ cross-origin (the Wails desktop webview, §8).
Standalone deploy, versioned and released independently of the `oikos` Standalone deploy, versioned and released independently of the `oikos`
binary — see [README.md §4.5](README.md#45-build--release-artifacts) for binary — see [README.md §4.5](README.md#45-build--release-artifacts) for
why "deployed" means two different release cadences depending on whether why "deployed" means two different release cadences depending on whether
you mean the container or the desktop app. you mean the container or the desktop app. The shell-level architecture
(window manager, app registry, docked layer) is documented separately as
[§9 below](#9-web-control-room--app-architecture); this section covers
the page-level concerns, §9 covers the OS + Apps contract the pages hang
off.
--- ---
@@ -496,6 +501,177 @@ functional sense.
--- ---
## 9. web control room — App architecture
**Stakeholders:** anyone adding a page, adding a desktop overlay, or
planning dynamic/third-party app installation. **Why this View earns its
place:** §5 documents the *pages*; this View documents the *shell* they
hang off — and the shell is the part whose contract a new app has to
satisfy. It is also the layer where the "Oikos-as-OS" metaphor
(desktop, icons, floating windows, a tamagotchi-style resident
creature) is actually implemented, so the boundary between "Base OS" and
"App" has to be explicit here or it doesn't exist anywhere.
### App architecture — Internal structure
| File | Role |
|---|---|
| `web/src/lib/apps.ts` | The App registry. Two layers: `builtinApps` (static, always installed) + `installedAppIds` (persisted, from the App Store). The public `apps` store is derived (built-in + installed); `appById` is a derived Map. `installApp`/`uninstallApp` mutate the installed set. Window-id helpers (`appWindowId`, `appIdFromWindowId`) unchanged. |
| `web/src/app-store/catalog.ts` | The installable-app catalog: `AppManifest` (persistable metadata) + `CatalogEntry` (manifest + Lucide icon + dynamic-import loader). Static in Phase 3 (apps ship with the build); Phase 4 swaps this for a fetched `/api/v1/apps` endpoint. Declares `AppPermission` (enforcement is Phase 4). |
| `web/src/app-store/apps/Notes.svelte` | Demo installable app — a localStorage-backed scratchpad proving the install→icon→window→uninstall lifecycle end-to-end. |
| `web/src/lib/stores/windows.ts` | The wmkit window manager singleton + the `openAppWindow` / `openEntityWindow` / `openTaskWindow` primitives. `openAppWindow` branches on `docked` (toggles visibility) vs windowed (`wm.open`); resolves the app via `get(appById)`. |
| `web/src/lib/stores/docked.ts` | Persisted visibility for docked apps. Absent key = visible (default-on); store holds only overrides. Deliberately does **not** import `APPS` — doing so would create a static cycle (`apps.ts` → pages → `windows.ts` → here → `apps.ts`) and fire a TDZ on `APPS` at init. |
| `web/src/lib/stores/icons.ts` | Desktop icon grid: column/row positions, drag-to-reorder, localStorage persistence. Reactive to the `apps` store — a newly-installed app gets a free cell on the next emission; `resetIconLayout` re-seeds from the live registry, not a static snapshot. |
| `web/src/lib/components/LazyApp.svelte` | Renders an app's lazily-loaded component (`AppDef.component` is a dynamic-import loader, not the component). Shows the shared spinner while the chunk fetches; used by both WindowLayer and DockedLayer so the loading state is uniform across app kinds. Vite's module cache makes repeat opens resolve from cache. |
| `web/src/lib/components/desktop-shell/Desktop.svelte` | Full-viewport surface: background, icons, task launcher, `<WindowLayer />`, `<DockedLayer />`, taskbar. Reads `$apps` (the derived store) so installs reflect immediately. |
| `web/src/lib/components/desktop-shell/WindowLayer.svelte` | Floating-window stack (z-40). Resolves window id → content component; renders shared titlebar chrome. The orphan-close `$effect` is reactive on `$appById` — reinstalling an app revives its persisted window, uninstalling closes it. |
| `web/src/lib/components/desktop-shell/DockedLayer.svelte` | Docked-app overlay (z-45). Renders `$apps.filter(a => a.docked)` gated on `dockedVisibility`. Replaces the previously-hardcoded `<MascotLayer />`. |
| `web/src/lib/components/desktop-shell/Taskbar.svelte` | Window buttons + tray. Renders from `wmState.order`; resolves icons via `$appById`. |
| `web/src/pages/AppStore.svelte` | The App Store — lists the catalog, shows install state, install/uninstall. Installing makes the app appear on the desktop immediately (no reload) via the reactive `apps` store; uninstalling closes any open window for that app via WindowLayer's orphan-close effect. |
### App architecture — The App contract
```typescript
interface AppDef {
id: string // unique; window IDs are "app:<id>"
title: string // desktop icon label + window titlebar
icon: Component // Lucide icon (desktop icon + taskbar)
component: () => Promise<{ default: Component }> // dynamic-import loader
docked?: boolean // true = Docked Layer app, no window
noIcon?: boolean // true = registered but no desktop icon
width?: number; height?: number; minWidth?: number; minHeight?: number
// required for windowed, forbidden for docked
badge?: (s: DashboardSummary | null) => number
}
```
`component` is a dynamic-import loader (`() => import('../pages/X.svelte')`),
not the component itself. Desktop icons render from metadata alone (id,
title, icon — all static), the component chunk fetches on first window
open, and Vite code-splits each app into its own chunk (Phase 2). The
mascot uses the same path — `() => import('./mascot/MascotLayer.svelte')`
— which also defers the mascot's module graph until after `apps.ts` has
finished initializing, breaking what would otherwise be a static cycle
(`apps.ts``MascotLayer``Mascot.svelte``icons.ts``apps.ts`).
Two app kinds, picked by one flag:
| Kind | Window | Titlebar | Taskbar | Opened by |
|---|---|---|---|---|
| **Windowed** (default) | wmkit floating window | yes | yes | `openAppWindow``wm.open` |
| **Docked** (`docked: true`) | none — renders on the Docked Layer | no | no | `openAppWindow``toggleDocked` |
Apps receive **no props** from the shell. They import the OS-service
surface (below) directly. The shell→app edge is one-way.
### App architecture — The OS-service surface (AppOS)
The stable set of `$lib` exports an App may import. Everything else in
`$lib` is shell-internal and may change without notice. This is a
**documentation contract** today (apps are compiled in); it becomes an
**enforced sandbox boundary** the moment third-party app installation
(Phase 3 in [the plan](../../plans/2026-07-21-frontend-os-apps-architecture.md)) lands.
| Service | Import |
|---|---|
| Open an app window | `openAppWindow(id)` from `$lib/stores/windows` |
| Open an entity window | `openEntityWindow(slug)` from `$lib/stores/windows` |
| Open a task window | `openTaskWindow(sessionId, title)` from `$lib/stores/windows` |
| Dashboard summary | `summary`, `subscribeContext` from `$lib/stores/context` |
| Live events | `subscribeEvents` from `$lib/stores/events` |
| Per-session chat / workspace / activity | `chatFor`, `workspaceFor`, `activityLogFor` from `$lib/stores/{chat,workspace,activity}` |
| REST API | `$lib/api` (generated from OpenAPI, [ADR-0004](../adr/0004-openapi-first.md)) |
| UI primitives | `$lib/components/ui/*` |
| Theme | `getTheme`, `setTheme` from `$lib/stores/theme.svelte` |
### App architecture — Content resolution
Window ids are namespaced so the window layer resolves content purely
from the id, with no extra bookkeeping — which is also why persisted
windows hydrate correctly across reloads:
| Id shape | Renders |
|---|---|
| `app:<id>` | the registry app's component (`appById.get(id).component`) |
| `session:<id>` | `SessionChatWindow` (per-session chat) |
| `new-task` | `NewTaskChat` (singleton compose) |
| bare slug (`type:identifier`) | `EntityDetailContent` (fallback) |
A hydrated `app:<id>` window whose id no longer matches a registry entry
(an app removed since the layout was persisted) self-closes — the
orphan-close `$effect` in `WindowLayer.svelte` sweeps it on mount.
### App architecture — Current population
Seven windowed apps + one docked app:
| App | Kind | Badge |
|---|---|---|
| `tasks` | windowed | — |
| `kb` | windowed | — |
| `ops` | windowed | `approvals_pending` |
| `signals` | windowed | open signal count |
| `knowledge` | windowed | — |
| `learning` | windowed | — |
| `settings` | windowed | — |
| `mascot` (Cluck) | **docked** | — |
The mascot is the first docked app and the reason the docked kind
exists; before this View it was a hardcoded `<MascotLayer />` in
`Desktop.svelte`, not a registry entry. Its persistent model
(`web/src/lib/mascot/state.svelte.ts`, localStorage) and sprite cache
(`sprites.ts`) are module-scoped, so toggling visibility (unmount) and
restoring (remount) loses no state — this is why `docked` visibility is
a plain `{#if}` gate rather than a `keepAlive` mechanism.
### App architecture — Designed extension points (documented, not built)
| Extension | Mechanism when built | Trigger |
|---|---|---|
| Titlebar actions | `titlebarActions?: Component` on `AppDef`, rendered left of min/max/close | First app that needs one |
| App-scoped state | `state?: () => Record<string, unknown>` on `AppDef` | First app with cross-mount state that isn't module-scoped |
| `onRegister` handshake | called with a scoped AppOS capability object | Phase 3 (dynamic install) |
| Third-party manifests | `AppManifest` JSON + `/api/v1/apps` + permission model | Phase 3 |
Documenting these now prevents the current contract from painting itself
into a corner; building them now would be speculative. (Lazy-loaded
components were on this list and shipped in Phase 2 — `component` is now
`() => Promise<{ default: Component }>` and Vite code-splits each app.)
### App architecture — Status and known issues
Phase 1 (the docked kind, mascot-as-app, the docked visibility store) and
Phase 2 (lazy component loading — `component` as dynamic-import loader,
`LazyApp.svelte` for uniform loading state, per-app code-splitting) have
landed. Open items, by phase:
- **Phase 3 (dynamic install):** the AppOS table above becomes a real
injected capability object, not a documentation table; permissions
enforced at the store-access boundary; `AppManifest` format +
`/api/v1/apps` endpoint + install flow.
- **Late-registering apps (Phase 3 prerequisite):** `icons.ts:48` builds
`appIds` once at module load to validate persisted positions — fine
today (all apps are in the static `APPS` array; only their components
are lazy), fragile the moment apps register post-load. When dynamic
registration lands, revalidate against the live registry, not the
import-time snapshot. Likewise `WindowLayer`'s orphan-close `$effect`
must be gated on registry-ready so a not-yet-loaded app's persisted
window isn't killed on hydration.
The static-cycle trap that bit this View during Phase 1 implementation is
now resolved by Phase 2's lazy loading — recording it for context:
- `apps.ts` no longer statically imports any page or the mascot (they're
all `() => import(...)`), so there's no static edge from `apps.ts` into
the mascot/page module graph to cycle through `icons.ts` back to `APPS`.
The earlier `LazyMascot.svelte` wrapper (Phase 1's cycle break) was
deleted in Phase 2 — the lazy loader in the registry replaces it.
`docked.ts` still must not import `APPS` (it's reached from `apps.ts`'s
graph via `windows.ts`), and doesn't — defaults are implicit
(absent key = visible).
---
## Keeping this document current ## Keeping this document current
The same discipline as README.md's closing note applies here, scoped to The same discipline as README.md's closing note applies here, scoped to

View File

@@ -0,0 +1,647 @@
# Frontend as OS + Apps: architecture audit & refactor plan
> **Status:** Planned
> **Stakeholders:** Operator, Nomos
> **Confidence:** Verified (direct code audit against `web/src/` as of 2026-07-21)
---
## Summary
The Oikos frontend is already built on an implicit OS + Apps metaphor — a
desktop surface, floating windows, a taskbar, and a registry of
independently-rendered apps. This plan makes that metaphor **explicit**,
strengthens the contracts between Base OS and Apps, refactors the mascot
into a proper App, and lays out the extensibility path for dynamic app
installation without touching shell code.
The current codebase is remarkably close. The audit found one structural
gap (mascot is hardcoded into the shell, not a registry App) and three
contract weaknesses (positional content resolution, icon store assumes a
static registry, no stable OS-service contract for Apps). Fixing them
requires no architectural rewrite — the bones are correct.
---
## 1. Audit: what we have today
### 1.1 The implicit OS layer (exists, undocumented)
| Service | File | Role |
|---------|------|------|
| **Window Manager** | `lib/stores/windows.ts:19-31` | wmkit manager + desktop + persist. Single-instance, global. |
| **Desktop Surface** | `components/desktop-shell/Desktop.svelte` | Full-viewport shell: background, icons, launcher, windows, mascot, taskbar. |
| **Window Layer** | `components/desktop-shell/WindowLayer.svelte` | Content resolver: maps window ID → component. z-40. |
| **Taskbar** | `components/desktop-shell/Taskbar.svelte` | Window buttons + tray. Renders from `wmState.order`. |
| **Icon Grid** | `lib/stores/icons.ts` | Column/row grid, drag-to-reorder, localStorage persistence. |
| **Task Launcher** | `components/desktop-shell/TaskLauncher.svelte` | Centered text input → new task window. |
| **Auth Gate** | `App.svelte` | Config screen vs. Desktop. Token check, OIDC init, context/SSE subscribe. |
| **Session Windows** | `components/SessionChatWindow.svelte` | Per-session chat window, splitpanes layout. |
| **New Task Window** | `components/desktop-shell/NewTaskChat.svelte` | Singleton compose window. |
| **Entity Windows** | `components/EntityDetailContent.svelte` | Entity detail (bare slug window IDs). |
| **Legacy Hash Routes** | `App.svelte:17-39` | Backward compat for old `#/kb`, `#/entity/<slug>` bookmarks. |
The shell has **no hardcoded app list**`Desktop.svelte:90` reads `APPS`
from the registry, `WindowLayer.svelte:36-37` resolves app windows through
`appById`, `Taskbar.svelte:32` resolves icons the same way. Adding an app
is one entry in `apps.ts`.
### 1.2 The App Registry (exists, nearly complete)
**File:** `lib/apps.ts` (130 lines)
**Interface:** `AppDef` — id, title, icon (Lucide Component), component
(Svelte Component), width, height, minWidth, minHeight, optional badge
function.
**Window namespacing:** `app:<id>` (`apps.ts:122`) — distinct from
`session:<id>`, `new-task`, and bare entity slugs.
**Current apps (7):**
| ID | Page Component | Badge? |
|----|---------------|--------|
| `tasks` | `pages/Overview.svelte` | — |
| `kb` | `pages/KnowledgeBase.svelte` | — |
| `ops` | `pages/Ops.svelte` | approvals_pending |
| `signals` | `pages/Signals.svelte` | open signal count |
| `knowledge` | `pages/Knowledge.svelte` | — |
| `learning` | `pages/Learning.svelte` | — |
| `settings` | `pages/Settings.svelte` | — |
**What works:**
- Data-driven. One array → three surfaces auto-render.
- Namespaced window IDs prevent collisions with session/entity windows.
- Single-instance enforcement (double-click focuses, never duplicates).
- Badge system: pure function over `DashboardSummary`, consumed by icon +
taskbar.
- Tested (`apps.test.ts`): unique IDs, positive sizes, `appById` index,
round-trips.
- Orphan cleanup: `WindowLayer.svelte:25-30` closes persisted windows whose
app was removed from the registry.
**What's missing from the AppDef contract:**
1. **No stable OS-service surface.** Apps reach into the OS by importing
arbitrary `$lib` modules (`openEntityWindow` from `windows.ts`,
`summary` from `context.ts`). It works because apps are compiled in, but
there is no documented boundary between "stable OS API an App may use"
and "shell internals that happen to be exported." Phase 3 (installed
third-party apps) needs that boundary to exist first.
2. **No docked/overlay app kind.** An app that renders *on* the desktop
(above windows, no titlebar, no window at all) has no representation in
the contract — which is exactly why the mascot is hardcoded.
### 1.3 The Mascot: embedded, not an app
**Files:** `lib/mascot/` (12 files, ~2.8k lines)
**Integration:** `Desktop.svelte:105` — hardcoded `<MascotLayer />` at z-45,
after WindowLayer and before Taskbar.
**Key facts that shape the refactor (verified):**
- `MascotLayer.svelte` takes **no props**. It creates the `MascotRuntime`
per mount, seeds position from the persisted model, and attaches the
stimulus bus itself (`MascotLayer.svelte:38-61`, comment at line 6-7).
- The persistent model (stage, name, happiness, xp, **lastPos**) is
module-scoped in `state.svelte.ts` and survives unmount/remount.
- The sprite `Image` cache is module-scoped in `sprites.ts` — remounts do
not re-fetch the 19 PNG sheets.
- The stimulus bus subscribes to global stores (`focusedSessionId` from
`windows.ts`, per-session factories from `chat.ts`/`workspace.ts`) — no
dependency on how MascotLayer is mounted.
**Consequence:** hiding the mascot = `{#if visible}<MascotLayer />{/if}`.
State, sprites, and position all restore naturally. No `keepAlive`
machinery is needed.
### 1.4 Three contract weaknesses
#### Weakness 1: Positional content resolution
`WindowLayer.svelte:70-79` resolves content by checking ID patterns in a
hardcoded order:
```svelte
{#if id.startsWith(SESSION_PREFIX)}
<SessionChatWindow ... />
{:else if id === NEW_TASK_WINDOW_ID}
<NewTaskChat />
{:else if app}
<app.component />
{:else}
<EntityDetailContent ... />
{/if}
```
A new window category must be inserted at the right position in this chain.
Works today because prefixes are mutually exclusive by construction, but
it's a landmine: add `'lxc:'` container consoles or `'log:'` viewers and
you're editing shell internals.
#### Weakness 2: Icon store snapshots the registry at module load
`icons.ts:23` builds default positions from `APPS`, and `icons.ts:48`
freezes an `appIds` set used to filter persisted positions in `load()`.
Both evaluate **once at import time**. A late-registering app (lazy load,
Phase 2+) would have its persisted position silently dropped by the
`load()` filter — the merge-over-defaults logic only helps apps that were
already in `APPS` when the module first evaluated.
#### Weakness 3: Window chrome is fully shell-owned, with no extension point
Every window gets the same titlebar (`WindowLayer.svelte:40-67`): drag
handle, title, minimize/maximize/close. Correct default — apps should not
draw their own chrome — but there is no sanctioned way for an app to
contribute a titlebar affordance (e.g. Tasks might want an inline "New
task" button). **Decision: document as a designed extension point, defer
implementation until an app actually needs it** (see §2.5). Not a Phase 1
deliverable.
---
## 2. The OS + Apps model
### 2.1 Metaphor
```
┌──────────────────────────────────────────────────┐
│ Auth Gate (App.svelte) │
│ ┌──────────────────────────────────────────────┐│
│ │ Desktop Surface ││
│ │ ┌─────────────┐ ┌─────────────┐ ││
│ │ │ App Window │ │ App Window │ z-40 ││
│ │ │ (Tasks) │ │ (Signals) │ ││
│ │ └─────────────┘ └─────────────┘ ││
│ │ ┌──────────────────────┐ ││
│ │ │ Docked Apps (Cluck) │ z-45, no chrome ││
│ │ └──────────────────────┘ ││
│ │ ┌──────┐ ┌──────┐ ┌──────┐ z-0 ││
│ │ │ Icon │ │ Icon │ │ Icon │ ││
│ │ └──────┘ └──────┘ └──────┘ ││
│ └──────────────────────────────────────────────┘│
│ ┌──────────────────────────────────────────────┐│
│ │ Taskbar [Tasks] [Signals] 🎨 ⚙ v0.9 ││
│ └──────────────────────────────────────────────┘│
└──────────────────────────────────────────────────┘
Base OS = Auth Gate + Desktop Surface + Window Manager + Taskbar
+ Icon Grid + Docked Layer + OS-service surface
Apps = Tasks, KB, Ops, Signals, Knowledge, Learning, Settings, Cluck
```
### 2.2 App kinds
Two kinds, distinguished by one flag:
| Kind | Window | Titlebar | Taskbar button | Opened by |
|------|--------|----------|----------------|-----------|
| **Windowed** (default) | wmkit floating window | Yes | Yes (automatic) | `openAppWindow(id)``wm.open()` |
| **Docked** (`docked: true`) | None — renders on the Docked Layer | No | No | `openAppWindow(id)` → toggles visibility |
Docked apps are **not** wmkit citizens. They render in a dedicated layer
above the window layer, their visibility is a persisted boolean, and
clicking their desktop icon toggles show/hide. They never appear in the
taskbar because they never enter `wmState.order`.
### 2.3 The App contract
```typescript
interface AppDef {
// Identity (required)
id: string // unique; window IDs are "app:<id>"
title: string // desktop icon label + window titlebar
icon: Component // Lucide icon (desktop icon + taskbar)
component: Component // Svelte component; receives NO props
// Kind
docked?: boolean // true = Docked Layer app, no window (default false)
// Window geometry — required for windowed apps, forbidden for docked apps
width?: number
height?: number
minWidth?: number
minHeight?: number
// Behavior (all optional)
badge?: (summary: DashboardSummary | null) => number
noIcon?: boolean // true = registered but no desktop icon
}
```
**Validation rules** (enforced by `apps.test.ts`, not runtime checks):
- `id` unique, non-empty.
- Windowed apps: `width`/`height` present and positive.
- Docked apps: `width`/`height` absent (geometry is meaningless without a
window).
- Every app has an icon component (even `noIcon` apps — the taskbar and
future surfaces need it).
**Design decisions, and why:**
- **No `keepAlive`.** Module-scoped state (mascot model, sprite cache)
already survives unmount. If a future app needs close-to-hide semantics,
that's a wmkit feature request, not an AppDef field.
- **No `noTaskbar`.** Docked apps never reach the taskbar; windowed apps
always should. A windowed app with no taskbar button is an orphan the
operator can't find.
- **No lifecycle hooks in the contract.** Svelte's own `onMount`/`onDestroy`
already fire on window open/close. A shell-level `onRegister` is only
meaningful once apps register dynamically — deferred to Phase 3, where
it becomes the permission handshake.
- **Apps receive no props.** The component is the app. It imports OS
services (§2.4) directly. This keeps the shell→app edge one-way and
trivially mockable.
### 2.4 The OS-service surface (AppOS)
The stable set of `$lib` exports an App may import. Everything else in
`$lib` is shell-internal and may change without notice. This is a
**documentation contract** today (apps are compiled in); it becomes an
**enforced sandbox boundary** in Phase 3.
| Service | Import | Stability |
|---------|--------|-----------|
| Open an app window | `openAppWindow(id)` from `$lib/stores/windows` | Stable |
| Open an entity window | `openEntityWindow(slug)` from `$lib/stores/windows` | Stable |
| Open a task window | `openTaskWindow(sessionId, title)` from `$lib/stores/windows` | Stable |
| Dashboard summary | `summary`, `subscribeContext` from `$lib/stores/context` | Stable |
| Live events | `subscribeEvents` from `$lib/stores/events` | Stable |
| Per-session chat | `chatFor(sessionId)` from `$lib/stores/chat` | Stable |
| Per-session workspace | `workspaceFor(sessionId)` from `$lib/stores/workspace` | Stable |
| REST API | `$lib/api` functions | Stable (generated from OpenAPI) |
| UI primitives | `$lib/components/ui/*` | Stable |
| Theme | `getTheme`, `setTheme` from `$lib/stores/theme.svelte` | Stable |
### 2.5 Content resolution — fixed
Replace the positional `if/else` chain with a prefix → component map owned
by the shell:
```typescript
// WindowLayer.svelte — one map, dispatch by prefix. New window kinds
// register here, not in an if/else chain.
const CONTENT_RESOLVERS: Array<[prefix: string, resolve: (id: string) => Component | null]> = [
['session:', () => SessionChatWindow],
['app:', (id) => appById.get(id.slice(4))?.component ?? null],
]
function resolveContent(id: string): Component | null {
if (id === NEW_TASK_WINDOW_ID) return NewTaskChat
for (const [prefix, resolve] of CONTENT_RESOLVERS) {
if (id.startsWith(prefix)) return resolve(id)
}
return EntityDetailContent // bare entity slug fallback
}
```
Adding a `'lxc:'` console window kind later = one array entry. The
existing orphan-close effect (`WindowLayer.svelte:25-30`) is kept as-is;
Phase 2 must gate it on registry-ready (§5).
### 2.6 Designed extension points (documented, not built)
| Extension | Mechanism when built | Trigger |
|-----------|---------------------|---------|
| Titlebar actions | `titlebarActions?: Component` on AppDef, rendered left of min/max/close | First app that needs one |
| App-scoped state | `state?: () => Record<string, unknown>` on AppDef | First app with cross-mount state that isn't module-scoped |
| `onRegister` handshake | Called with a scoped AppOS capability object | Phase 3 (dynamic install) |
Documenting these now prevents the Phase 1 contract from painting itself
into a corner; building them now would be speculative.
---
## 3. The mascot as an App
### 3.1 Registration
```typescript
{
id: 'mascot',
title: 'Cluck',
icon: EggIcon, // Lucide egg (chick/adult swap is a future nicety)
component: MascotLayer,
docked: true,
// no width/height — docked
// no badge — a permanent "1" is noise, not information
}
```
### 3.2 The docked-visibility store (new)
```typescript
// lib/stores/docked.ts
// Visibility for docked apps — persisted, so "hidden" survives reloads.
// Keyed by app id; absent key = visible (default-on for new docked apps).
export const dockedVisibility: Readable<Record<string, boolean>>
export function toggleDocked(appId: string): void
export function isDockedVisible(appId: string): boolean
```
- localStorage key: `oikos-docked-apps`
- Default: visible (a fresh install shows the mascot; hiding is opt-out)
- Merge semantics mirror `icons.ts`: unknown persisted keys are kept (an
uninstalled docked app that gets reinstalled remembers its state)
### 3.3 Shell changes
**`windows.ts``openAppWindow` branches on kind:**
```typescript
export function openAppWindow(appId: string): void {
const app = appById.get(appId)
if (!app) return
if (app.docked) { toggleDocked(appId); return } // ← the branch the first draft missed
// ... existing wm.open path unchanged
}
```
This is the load-bearing detail: the icon click in `Desktop.svelte:93`
calls `openAppWindow(app.id)` for every app uniformly. Branching **inside**
`openAppWindow` means Desktop.svelte, legacy hash resolution, and any
future caller need no special cases.
**`Desktop.svelte` — replace hardcoded `<MascotLayer />` with:**
```svelte
<DockedLayer />
```
**`components/desktop-shell/DockedLayer.svelte` — new, ~30 lines:**
```svelte
{#each APPS.filter(a => a.docked) as app (app.id)}
{#if $dockedVisibility[app.id] ?? true}
<app.component />
{/if}
{/each}
```
Rendered after `<WindowLayer />` inside the surface div, so docked apps
share the surface's coordinate space (the mascot's ground-line computation
depends on this — `MascotLayer.svelte:9-13`).
**`MascotLayer.svelte` — zero changes.** No props today, no props after.
### 3.4 What the mascot gains
| Feature | Before | After |
|---------|--------|-------|
| Registry entry | None — hardcoded in shell | First-class AppDef |
| Show/hide | Impossible — always mounted | Icon click toggles; persists across reloads |
| Shell coupling | `Desktop.svelte` imports mascot internals | Shell knows only `AppDef` |
| Precedent for overlay apps | None | Any `docked: true` app (clock, net monitor) uses the same path |
### 3.5 What the mascot does *not* gain (deliberately)
- **No taskbar button.** No window → no taskbar entry. The desktop icon is
the control.
- **No window chrome.** It's a desktop creature, not a document.
- **No settings panel in v1.** Hatch/rename/pet/feed stay in the existing
radial menu. A mascot *settings* surface (volume, behavior toggles) would
be a separate windowed app later — noted as a follow-up idea, not
planned.
### 3.6 UX risk: "where did my chicken go?"
Hidden state persists across reloads. Mitigation: the desktop icon is
always present and is the obvious toggle; the icon's tooltip reads
"Cluck — click to show/hide". Acceptable.
---
## 4. Current apps — conformance audit
| App | Conforms? | Notes |
|-----|-----------|-------|
| **Tasks** (`Overview.svelte`) | ✅ Full | Self-contained. Opens session windows via `openTaskWindow`. |
| **Knowledge Base** (`KnowledgeBase.svelte`) | ✅ Full | Opens entity windows via `openEntityWindow`. |
| **Operations** (`Ops.svelte`) | ✅ Full | Badge reads `summary`. |
| **Signals** (`Signals.svelte`) | ✅ Full | Opens entity windows. |
| **Knowledge** (`Knowledge.svelte`) | ✅ Full | — |
| **Learning** (`Learning.svelte`) | ✅ Full | — |
| **Settings** (`Settings.svelte`) | ✅ Full | Opened from taskbar tray too — same `openAppWindow` path. |
| **Mascot** | ❌ Not an App | Hardcoded in Desktop.svelte. Refactored per §3. |
All seven windowed apps conform today. "Independently shippable" at Phase 1
means: add = one page file + one registry entry; remove = delete both. No
shell edits, no inter-app imports (apps open each other's surfaces only
through AppOS primitives).
---
## 5. Extensibility roadmap
### Phase 1: Strengthen the contract (this plan)
- [x] `AppDef` extended: `docked`, `noIcon`; geometry conditional on kind
- [x] `lib/stores/docked.ts`: docked-visibility store, persisted
- [x] `openAppWindow` branches on `docked`
- [x] `DockedLayer.svelte`: generic docked-app layer in Desktop.svelte
- [x] Mascot registered as `docked: true`; hardcoded `<MascotLayer />` removed
- [x] WindowLayer: prefix-map content resolution *(deferred — re-audited as gold-plating; original gate already handles orphans)*
- [x] `apps.test.ts`: validation rules per kind (§2.3)
- [x] AppOS contract documented (§2.4 lands in MBSE component doc)
### Phase 2: Lazy loading
- [x] `component` becomes `() => Promise<{ default: Component }>`; all apps use dynamic imports
- [x] Desktop icons render immediately (metadata only); component chunk loads on window open
- [x] `LazyApp.svelte` — shared loading skeleton (spinner) used by WindowLayer + DockedLayer
- [x] Deleted `LazyMascot.svelte` — the registry lazy loader breaks the cycle directly
- [x] Vite code-splits each app into its own chunk (main bundle 800KB → 482KB)
- [ ] Icon store revalidates against live registry *(Phase 3 prerequisite — not needed while apps are statically registered)*
- [ ] WindowLayer orphan-close gated on registry-ready *(Phase 3 prerequisite)*
### Phase 3: Dynamic app installation (frontend scaffold, local bundles)
Scoped at execution time to **local bundles only** (remote-URL loading +
sandboxing deferred to Phase 4 — security-critical, needs ADR + careful
design). The mechanism built here generalizes to remote bundles by
swapping the catalog for a fetched manifest + `import(/* @vite-ignore */ url)`.
- [x] `AppManifest` format (id, title, permissions, version, geometry) — `web/src/app-store/catalog.ts`
- [x] `AppPermission` enum (declaration-only; enforcement is Phase 4)
- [x] Static catalog with one demo app (Notes) — `web/src/app-store/apps/Notes.svelte`
- [x] Runtime registry: `APPS` → derived store (built-ins + installed); `appById` → derived Map
- [x] `installApp` / `uninstallApp` + localStorage persistence (`oikos-installed-apps`)
- [x] `icons.ts` reactive to app registration (late-registering apps get free cells; reset re-seeds from live registry)
- [x] WindowLayer orphan-close reactive to `$appById` (reinstall revives, uninstall closes)
- [x] App Store page (`web/src/pages/AppStore.svelte`) — list / install / uninstall
- [x] Installed apps appear on desktop immediately (no reload); uninstall removes icon + closes window
- [x] Icon store revalidates against live registry *(the Phase 3 prerequisite — now done)*
- [ ] `/api/v1/apps` endpoint + DB-backed manifest storage *(Phase 4)*
- [ ] Remote bundle loading from URLs + CSP + capability sandboxing *(Phase 4)*
- [ ] Permission enforcement at AppOS boundary *(Phase 4)*
### Phase 4: Marketplace (vision)
- [ ] Community apps (network map, backup dashboard, energy monitor)
- [ ] Versioning + auto-update
- [ ] Mascot skin packs as installable docked-app variants
---
## 6. Implementation — Phase 1, file by file
| # | File | Change |
|---|------|--------|
| 1 | `lib/apps.ts` | Extend `AppDef` (`docked?`, `noIcon?`, geometry optional). Register mascot. Import `MascotLayer` + `EggIcon`. |
| 2 | `lib/stores/docked.ts` | **New.** `dockedVisibility` store, `toggleDocked`, `isDockedVisible`, localStorage persistence. |
| 3 | `lib/stores/windows.ts` | `openAppWindow`: docked branch → `toggleDocked`. |
| 4 | `components/desktop-shell/DockedLayer.svelte` | **New.** Renders visible docked apps after WindowLayer. |
| 5 | `components/desktop-shell/Desktop.svelte` | Replace `import MascotLayer` + `<MascotLayer />` with `<DockedLayer />`. |
| 6 | `components/desktop-shell/WindowLayer.svelte` | **Deferred during implementation.** The positional if/else was re-audited and found to already handle orphans cleanly (`{#if win && (!appId || app)}`), and any new window kind needs a prop-dispatch branch in markup regardless — so a prefix→component map adds machinery without decoupling. Documented as an extension point (§2.5) like `titlebarActions`; not built (YAGNI). |
| 7 | `lib/apps.test.ts` | Mock `MascotLayer` import (same pattern as pages). Per-kind validation tests. Docked apps exempt from positive-size test. |
| 8 | `lib/stores/docked.test.ts` | **New.** Toggle, persistence, default-visible, unknown-key merge. |
| 9 | `docs/mbse/components.md` | Add Component 9: Web Control Room — App Architecture (§7). |
**Out of scope for Phase 1:** `titlebarActions`, app-scoped state,
lazy loading, manifests, permissions.
**Verification:**
```bash
cd web
npm run test # vitest — registry + docked store
npm run check # svelte-check + tsc
npm run lint
npm run build # vite build — confirms no import cycles from DockedLayer
```
Manual smoke: icon toggle hides/shows mascot → reload → stays hidden →
toggle → returns at last position (model `lastPos` restore). All seven
windowed apps open/focus/close identically to before. Legacy hash
`#/signals` still opens the Signals window.
---
## 7. MBSE documentation
Add **Component 9: Web Control Room — App Architecture** to
`docs/mbse/components.md`:
```
9. Web Control Room — App Architecture
9.1 Purpose — OS + Apps metaphor, why apps are independently shippable
9.2 Structural View — shell modules, registry, docked layer (mermaid)
9.3 App Contract — AppDef, validation rules, app kinds
9.4 OS-Service Surface — the AppOS table
9.5 Content Resolution — prefix map, window kinds, orphan cleanup
9.6 Behavior — window state machine, docked visibility lifecycle
9.7 Requirements — WEB-APP-* traceability
9.8 Verification — test coverage, manual smoke
```
### Requirements
| ID | Requirement | Status |
|----|-------------|--------|
| WEB-APP-1 | Apps register via data-driven AppDef entries; no shell edits to add/remove | ✅ live |
| WEB-APP-2 | Apps render in wmkit floating windows | ✅ live |
| WEB-APP-3 | Window IDs namespaced (`app:`/`session:`/entity) — no collisions | ✅ live |
| WEB-APP-4 | Desktop icons render from the registry | ✅ live |
| WEB-APP-5 | Taskbar buttons derive from window state, icons resolved via registry | ✅ live |
| WEB-APP-6 | Removed apps' persisted windows self-close | ✅ live (`WindowLayer.svelte:25-30`) |
| WEB-APP-7 | Content resolution dispatches via prefix map, not positional if/else | ⬜ Deferred — re-audited; original gate already handles orphans, map adds no decoupling (§2.5) |
| WEB-APP-8 | Docked app kind: no window, no chrome, visibility toggled via icon | ⬜ Phase 1 |
| WEB-APP-9 | Mascot is a registered docked App, not a hardcoded shell component | ⬜ Phase 1 |
| WEB-APP-10 | Docked visibility persists across reloads | ⬜ Phase 1 |
| WEB-APP-11 | OS-service surface (AppOS) documented as the stable App API | ⬜ Phase 1 |
| WEB-APP-12 | Registry validation: per-kind geometry rules enforced by tests | ⬜ Phase 1 |
| WEB-APP-13 | Apps lazy-load; icons render from static metadata | ✅ Phase 2 |
| WEB-APP-14 | Icon store revalidates against live registry, not import-time snapshot | ✅ Phase 3 |
| WEB-APP-15 | Third-party apps install from manifests with declared permissions | ✅ Phase 3 (local bundles; enforcement Phase 4) |
### Sequence — windowed app open
```mermaid
sequenceDiagram
participant User
participant Desktop
participant WM as Window Manager
participant WL as Window Layer
participant App
User->>Desktop: click icon
Desktop->>WM: openAppWindow("signals")
Note over WM: docked? no → wm path
alt window exists
WM->>WM: restore + focus
else new
WM->>WM: wm.open({ id: "app:signals", ... })
WM->>WL: render frame
WL->>WL: resolveContent → prefix 'app:' → registry
WL->>App: mount component
end
WM->>Taskbar: new button in wmState.order
```
### Sequence — docked app toggle
```mermaid
sequenceDiagram
participant User
participant Desktop
participant Dock as docked.ts
participant Layer as DockedLayer
participant App
User->>Desktop: click Cluck icon
Desktop->>Dock: openAppWindow("mascot") → docked → toggleDocked
Dock->>Dock: flip visibility, persist localStorage
Dock->>Layer: store update
alt now visible
Layer->>App: mount MascotLayer
Note over App: model + sprites restore<br/>from module scope
else now hidden
Layer->>App: unmount (state survives)
end
```
### State machine — app window
```mermaid
stateDiagram-v2
[*] --> Closed: registered, no window
Closed --> Open: openAppWindow
Open --> Focused: focus
Focused --> Open: blur
Open --> Minimized: minimize
Minimized --> Focused: restore
Open --> Closed: close
Minimized --> Closed: close
```
---
## 8. Risk & safety
| Risk | Severity | Mitigation |
|------|----------|------------|
| Mascot refactor breaks stimuli or ground-line computation | Medium | MascotLayer unchanged; DockedLayer mounts it in the same surface div, same position in the stacking order as today. |
| Hidden mascot never rediscovered | Low | Icon always present, tooltip says show/hide. |
| `openAppWindow` docked branch leaks into windowed path | Low | Branch is the first statement; windowed path byte-identical. Covered by existing call sites (icon click, taskbar settings, legacy hash). |
| Docked visibility store desyncs from registry | Low | Unknown keys kept on load; layer filters by `a.docked` from the live registry. |
| Phase 2 lazy loading kills persisted windows of not-yet-loaded apps | Medium | Explicit Phase 2 gate: orphan-close waits for registry-ready (§5). Called out now so it isn't discovered in production. |
---
## 9. Appendix: relevant existing artifacts
| Artifact | Relevance |
|----------|-----------|
| `docs/mbse/README.md` §5 | MCP tools / REST / SSE — the data surface Apps consume |
| `docs/mbse/components.md` §5 | Current web control room component doc — Phase 1 extends it |
| `docs/mascot/README.md` | Mascot subsystem model (MASC-1..12); MASC-9's registry philosophy is the template for this plan |
| `plans/2026-07-08-control-room-webui.md` | Original control-room plan |
| `plans/done/2026-07-11-ui-review-ia-usability.md` | IA review that produced the desktop metaphor |
| `plans/2026-07-20-desktop-mascot.md` | Mascot plan; extension registries |
| `lib/apps.ts` header comment | Already documents the one-entry-to-add-an-app philosophy |
---
*Plan opened 2026-07-21. Phase 1 ready for execution — estimated small
(~half a day of focused work; nine file touches, two new files). Phases
24 are context for future sessions and do not block Phase 1.*

View File

@@ -21,6 +21,7 @@ went sideways, open an investigation.
| 2026-07-20 | [Desktop mascot ("Cluck")](2026-07-20-desktop-mascot.md) | Implemented in v0.8.0 — see deviation note; physics/window-interaction follow-ups tracked separately | | 2026-07-20 | [Desktop mascot ("Cluck")](2026-07-20-desktop-mascot.md) | Implemented in v0.8.0 — see deviation note; physics/window-interaction follow-ups tracked separately |
| 2026-07-20 | [Session review: past 10 sessions](2026-07-20-session-review-ten-sessions.md) | Implemented in v0.7.13 — all P0/P1/P2 items landed | | 2026-07-20 | [Session review: past 10 sessions](2026-07-20-session-review-ten-sessions.md) | Implemented in v0.7.13 — all P0/P1/P2 items landed |
| 2026-07-20 | [Mascot physics/window-interaction audit](2026-07-20-mascot-physics-audit.md) | P0P2 implemented; P3 ("cool stuff") ideas open | | 2026-07-20 | [Mascot physics/window-interaction audit](2026-07-20-mascot-physics-audit.md) | P0P2 implemented; P3 ("cool stuff") ideas open |
| 2026-07-21 | [Frontend as OS + Apps — architecture audit & refactor](2026-07-21-frontend-os-apps-architecture.md) | Planned — Phase 1 ready |
## Done ## Done

View File

@@ -0,0 +1,57 @@
<script lang="ts">
// Notes — a trivial installable app demoing the App Store lifecycle.
// Installed from the App Store, gets a desktop icon, opens in a window,
// has its own localStorage-backed state, and uninstalls cleanly. No
// shell-internal imports — this is a self-contained app that could be
// shipped as a standalone bundle (Phase 4 will load such bundles from
// a URL; here it's bundled and discovered via the catalog).
let { storageKey = 'oikos-app-notes' }: { storageKey?: string } = $props()
let text = $state('')
let saved = $state(false)
function load(): string {
if (typeof localStorage === 'undefined') return ''
return localStorage.getItem(storageKey) ?? ''
}
function save(): void {
if (typeof localStorage === 'undefined') return
localStorage.setItem(storageKey, text)
saved = true
setTimeout(() => (saved = false), 1500)
}
function onKeydown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
e.preventDefault()
save()
}
}
text = load()
$effect(() => {
if (!text) return
const t = setTimeout(() => save(), 2000)
return () => clearTimeout(t)
})
</script>
<div class="flex h-full min-h-0 flex-col gap-2 p-4">
<div class="flex shrink-0 items-center justify-between">
<h2 class="text-sm font-medium">Notes</h2>
<span class="text-xs text-muted-foreground">
{#if saved}saved{:else}unsaved{/if}
</span>
</div>
<textarea
bind:value={text}
onkeydown={onKeydown}
placeholder="Type here. Auto-saves 2s after you stop, or Cmd/Ctrl+S."
class="min-h-0 flex-1 resize-none rounded-md border bg-background p-3 font-mono text-sm leading-relaxed focus-visible:outline-2 focus-visible:outline-ring"
></textarea>
<p class="shrink-0 text-xs text-muted-foreground">
A demo installable app — uninstall it from the App Store to remove its
icon and window. Its notes persist in localStorage under
<code class="font-mono">{storageKey}</code>.
</p>
</div>

View File

@@ -0,0 +1,81 @@
// App Store — installable app catalog + manifest format.
//
// This is Phase 3's "frontend scaffold, local bundles only" path: a static
// catalog of apps that ship with the build, each described by a persistable
// manifest (metadata) and resolved at runtime to a loader + icon (runtime
// bits that are NOT persisted — they're looked up from the catalog by
// manifest id on load). Installing an app = persisting its manifest id;
// uninstalling = removing it. The mechanism generalizes to remote bundles
// in Phase 4 by swapping the catalog for a fetched manifest + a
// `import(/* @vite-ignore */ entryUrl)` loader.
//
// Permissions are DECLARED on the manifest but NOT YET ENFORCED — that's
// Phase 4 (sandboxing). They're part of the contract now so a manifest
// author has to name what the app needs, and the operator can see it in
// the App Store before installing. Enforcement will land at the AppOS
// boundary (docs/mbse/components.md §9 "OS-service surface") in Phase 4.
import type { Component } from 'svelte'
import NotesIcon from '@lucide/svelte/icons/sticky-note'
// A permission an installable app can request. Maps 1:1 to entries in the
// AppOS table (docs/mbse/components.md §9). Phase 4 will enforce these at
// the store-access boundary; today they're declaration-only.
export type AppPermission =
| 'open-window' // openAppWindow / openEntityWindow / openTaskWindow
| 'read-context' // dashboard summary, subscribeContext
| 'read-events' // subscribeEvents (SSE)
| 'api:entities' // $lib/api entity endpoints
| 'api:knowledge' // knowledge search/content
| 'api:executions' // executions/approvals
| 'theme' // getTheme / setTheme
// Persistable metadata describing an installable app. This is what's
// stored in localStorage when an app is installed (just the manifest id is
// persisted, actually — the manifest is re-resolved from the catalog on
// load — but the shape is the unit of interchange and will be what a
// remote `/api/v1/apps` endpoint returns in Phase 4).
export interface AppManifest {
id: string
title: string
description: string
version: string
author?: string
permissions: AppPermission[]
docked?: boolean
noIcon?: boolean
width?: number
height?: number
minWidth?: number
minHeight?: number
}
// A catalog entry: the manifest (persistable metadata) plus the runtime
// bits the catalog resolves by id — the Lucide icon component and the
// dynamic-import loader. These runtime bits are never persisted; they're
// re-looked-up from this static catalog on every load.
export interface CatalogEntry {
manifest: AppManifest
icon: Component
load: () => Promise<{ default: Component }>
}
export const CATALOG: CatalogEntry[] = [
{
manifest: {
id: 'notes',
title: 'Notes',
description: 'A scratchpad. Auto-saves to localStorage. Demo installable app.',
version: '0.1.0',
author: 'oikos',
permissions: ['theme'],
width: 640,
height: 480,
minWidth: 360,
minHeight: 320
},
icon: NotesIcon,
load: () => import('./apps/Notes.svelte')
}
]
export const catalogById = new Map(CATALOG.map((e) => [e.manifest.id, e]))

View File

@@ -1,46 +1,56 @@
import { describe, it, expect, vi } from 'vitest' import { describe, it, expect, beforeEach, vi } from 'vitest'
// apps.ts wires in every page component for real use, but that drags a // apps.ts holds only app metadata + a reactive registry. `component` is a
// heavy transitive graph into a unit test for no benefit here (and one of // dynamic-import loader, not the page itself, so importing apps.ts pulls no
// those pages imports svelte-sonner, which fails to resolve under vitest's // page modules. The install/uninstall tests touch localStorage and the
// bundled Vite — an unrelated, pre-existing package quirk). These tests only // module-scoped installedIds store, so each re-imports the module fresh (see
// care about the registry's own shape (ids, sizes, window-id helpers), so // docked.test.ts for the same pattern).
// stub the component imports out rather than pull all of that in. import { builtinApps, appWindowId, appIdFromWindowId } from './apps'
vi.mock('../pages/Overview.svelte', () => ({ default: {} }))
vi.mock('../pages/KnowledgeBase.svelte', () => ({ default: {} }))
vi.mock('../pages/Ops.svelte', () => ({ default: {} }))
vi.mock('../pages/Signals.svelte', () => ({ default: {} }))
vi.mock('../pages/Knowledge.svelte', () => ({ default: {} }))
vi.mock('../pages/Learning.svelte', () => ({ default: {} }))
import { APPS, appById, appWindowId, appIdFromWindowId } from './apps' describe('builtinApps registry', () => {
describe('APPS registry', () => {
it('has unique, non-empty ids', () => { it('has unique, non-empty ids', () => {
const ids = APPS.map((a) => a.id) const ids = builtinApps.map((a) => a.id)
expect(ids.length).toBeGreaterThan(0) expect(ids.length).toBeGreaterThan(0)
expect(new Set(ids).size).toBe(ids.length) expect(new Set(ids).size).toBe(ids.length)
for (const id of ids) expect(id).not.toBe('') for (const id of ids) expect(id).not.toBe('')
}) })
it('gives every app a positive default size', () => { it('component is a loader function, not the component itself', () => {
for (const app of APPS) { for (const app of builtinApps) {
expect(typeof app.component).toBe('function')
}
})
it('every built-in is source: builtin', () => {
for (const app of builtinApps) expect(app.source).toBe('builtin')
})
it('windowed apps have positive default geometry', () => {
for (const app of builtinApps.filter((a) => !a.docked)) {
expect(app.width).toBeGreaterThan(0) expect(app.width).toBeGreaterThan(0)
expect(app.height).toBeGreaterThan(0) expect(app.height).toBeGreaterThan(0)
} }
}) })
it('is indexed by id in appById', () => { it('docked apps forbid window geometry', () => {
for (const app of APPS) { for (const app of builtinApps.filter((a) => a.docked)) {
expect(appById.get(app.id)).toBe(app) expect(app.width).toBeUndefined()
expect(app.height).toBeUndefined()
expect(app.minWidth).toBeUndefined()
expect(app.minHeight).toBeUndefined()
} }
expect(appById.size).toBe(APPS.length) })
it('includes the App Store and mascot as built-ins', () => {
expect(builtinApps.find((a) => a.id === 'app-store')).toBeTruthy()
const mascot = builtinApps.find((a) => a.id === 'mascot')
expect(mascot?.docked).toBe(true)
}) })
}) })
describe('appWindowId / appIdFromWindowId', () => { describe('appWindowId / appIdFromWindowId', () => {
it('round-trips an app id through its window id', () => { it('round-trips an app id through its window id', () => {
for (const app of APPS) { for (const app of builtinApps) {
expect(appIdFromWindowId(appWindowId(app.id))).toBe(app.id) expect(appIdFromWindowId(appWindowId(app.id))).toBe(app.id)
} }
}) })
@@ -52,10 +62,74 @@ describe('appWindowId / appIdFromWindowId', () => {
}) })
it('namespaces window ids so they cannot collide with entity slugs', () => { it('namespaces window ids so they cannot collide with entity slugs', () => {
// Entity slugs are bare `type:identifier` strings (see windows.ts's for (const app of builtinApps) {
// openEntityWindow) — app window ids must never look like one.
for (const app of APPS) {
expect(appWindowId(app.id).startsWith('app:')).toBe(true) expect(appWindowId(app.id).startsWith('app:')).toBe(true)
} }
}) })
}) })
// install/uninstall lifecycle — each test re-imports fresh so the
// module-scoped installedIds store starts empty and localStorage is clean.
describe('install / uninstall', () => {
beforeEach(() => {
localStorage.clear()
vi.resetModules()
})
it('installApp adds a catalog app to the installed set', async () => {
const fresh = await import('./apps')
fresh.installApp('notes')
let snap: string[] = []
const unsub = fresh.installedAppIds.subscribe((v) => (snap = v))
expect(snap).toContain('notes')
unsub()
})
it('install is idempotent', async () => {
const fresh = await import('./apps')
fresh.installApp('notes')
fresh.installApp('notes')
let snap: string[] = []
fresh.installedAppIds.subscribe((v) => (snap = v))
expect(snap.filter((id) => id === 'notes')).toHaveLength(1)
})
it('installing an unknown manifest id is a no-op', async () => {
const fresh = await import('./apps')
fresh.installApp('does-not-exist')
let snap: string[] = []
fresh.installedAppIds.subscribe((v) => (snap = v))
expect(snap).not.toContain('does-not-exist')
})
it('uninstall removes the app', async () => {
const fresh = await import('./apps')
fresh.installApp('notes')
fresh.uninstallApp('notes')
let snap: string[] = []
fresh.installedAppIds.subscribe((v) => (snap = v))
expect(snap).not.toContain('notes')
})
it('uninstall is idempotent', async () => {
const fresh = await import('./apps')
expect(() => fresh.uninstallApp('notes')).not.toThrow()
})
it('persists the installed set to localStorage', async () => {
const fresh = await import('./apps')
fresh.installApp('notes')
const raw = localStorage.getItem('oikos-installed-apps')
expect(raw).toBeTruthy()
expect(JSON.parse(raw!)).toContain('notes')
})
it('drops persisted ids that no longer resolve to a catalog entry', async () => {
localStorage.setItem('oikos-installed-apps', JSON.stringify(['notes', 'removed-app']))
const fresh = await import('./apps')
let snap: string[] = []
fresh.installedAppIds.subscribe((v) => (snap = v))
expect(snap).toContain('notes')
expect(snap).not.toContain('removed-app')
})
})

View File

@@ -1,21 +1,29 @@
// The desktop's app registry — single source of truth for what shows up as // The app registry — single source of truth for what shows up as a desktop
// a desktop icon and what opens in its window. Adding a new app is one entry // icon and what opens in its window.
// here; nothing else needs to change (Desktop.svelte renders icons from //
// APPS, WindowLayer.svelte resolves `app:<id>` window ids back through // Two layers:
// appById, Taskbar.svelte reads title/icon the same way). Compare to the old // - **Built-in apps** (always installed): the static `builtinApps` array
// App.svelte's hardcoded navItems array + if/else page branch, which required // below. These ship with the build and can't be removed.
// touching three places (nav list, header title, main content branch) to add // - **Installed apps** (operator-installed from the App Store): persisted
// one page. // manifest ids in localStorage, re-resolved against the catalog at
// load time. `installApp`/`uninstallApp` mutate this set.
//
// The public surface is reactive: `apps` is a derived store (built-in +
// installed) and `appById` is a derived Map. Consumers (Desktop.svelte,
// DockedLayer.svelte, Taskbar.svelte, icons.ts, windows.ts) subscribe or
// use `get()` for synchronous lookups. This is what lets an installed app
// appear on the desktop the moment it's registered, with no reload.
//
// App components are loaded lazily (`component: () => Promise<{ default:
// Component }>` — a dynamic-import loader). Desktop icons render from
// metadata alone; the chunk fetches on first window open, and Vite
// code-splits each app into its own chunk. See
// docs/mbse/components.md §9 for the full contract.
import type { Component } from 'svelte' import type { Component } from 'svelte'
import { writable, derived, get, type Readable } from 'svelte/store'
import type { DashboardSummary } from '$lib/api' import type { DashboardSummary } from '$lib/api'
import { openSignalCount } from '$lib/stores/context' import { openSignalCount } from '$lib/stores/context'
import Overview from '../pages/Overview.svelte' import { catalogById, type AppManifest, type AppPermission, type CatalogEntry } from '$lib/app-store/catalog'
import KnowledgeBase from '../pages/KnowledgeBase.svelte'
import Ops from '../pages/Ops.svelte'
import Signals from '../pages/Signals.svelte'
import Knowledge from '../pages/Knowledge.svelte'
import Learning from '../pages/Learning.svelte'
import Settings from '../pages/Settings.svelte'
import ListTodoIcon from '@lucide/svelte/icons/list-todo' import ListTodoIcon from '@lucide/svelte/icons/list-todo'
import DatabaseIcon from '@lucide/svelte/icons/database' import DatabaseIcon from '@lucide/svelte/icons/database'
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check' import ShieldCheckIcon from '@lucide/svelte/icons/shield-check'
@@ -23,98 +31,231 @@ import SirenIcon from '@lucide/svelte/icons/siren'
import SearchIcon from '@lucide/svelte/icons/search' import SearchIcon from '@lucide/svelte/icons/search'
import TrendingUpIcon from '@lucide/svelte/icons/trending-up' import TrendingUpIcon from '@lucide/svelte/icons/trending-up'
import SettingsIcon from '@lucide/svelte/icons/settings' import SettingsIcon from '@lucide/svelte/icons/settings'
import EggIcon from '@lucide/svelte/icons/egg'
import StoreIcon from '@lucide/svelte/icons/store'
export type { AppManifest, AppPermission }
// Two app kinds, picked by one flag:
// - Windowed (default): renders in a wmkit floating window. Geometry
// (width/height/min*) is required.
// - Docked (docked: true): renders on the Docked Layer above the window
// layer, with no window chrome and no taskbar button. Clicking its
// desktop icon toggles visibility (see stores/docked.ts) rather than
// opening a window. Geometry is forbidden — there is no window to size.
// Apps receive no props from the shell; they import the OS-service surface
// ($lib/stores/windows, $lib/stores/context, $lib/api, ...) directly. See
// docs/mbse/components.md §9 for the stable surface contract.
export interface AppDef { export interface AppDef {
id: string id: string
title: string title: string
icon: Component icon: Component
component: Component // Dynamic-import loader. Invoked when an app window opens (windowed) or
width: number // when the Docked Layer first mounts the app (docked). Vite's module cache
height: number // makes the second open cheap (promise resolves from cache). The resolved
// module is a standard Svelte module namespace — `mod.default` is the
// component; LazyApp.svelte unwraps it.
component: () => Promise<{ default: Component }>
docked?: boolean
noIcon?: boolean
width?: number
height?: number
minWidth?: number minWidth?: number
minHeight?: number minHeight?: number
// Pure function over the shared dashboard summary — used for both the
// desktop icon's badge and the taskbar button's badge, so a new app that
// wants one just supplies this instead of each surface reimplementing it.
badge?: (summary: DashboardSummary | null) => number badge?: (summary: DashboardSummary | null) => number
// Source — 'builtin' (always installed) or 'installed' (from the App
// Store). Used by the App Store UI to distinguish uninstallable apps from
// built-ins.
source: 'builtin' | 'installed'
} }
export const APPS: AppDef[] = [ // Built-in apps — always installed, can't be removed. All components use
// dynamic-import loaders so apps.ts stays out of the page module graph at
// import time (Phase 2 code-splitting: each page is its own chunk, the
// main bundle stays small). The mascot uses the same path — deferring its
// module graph also breaks what would otherwise be a static cycle through
// icons.ts back to APPS.
export const builtinApps: AppDef[] = [
{ {
id: 'tasks', id: 'tasks',
title: 'Tasks', title: 'Tasks',
icon: ListTodoIcon, icon: ListTodoIcon,
component: Overview, component: () => import('../pages/Overview.svelte'),
width: 960, width: 960,
height: 680, height: 680,
minWidth: 480, minWidth: 480,
minHeight: 420 minHeight: 420,
source: 'builtin'
}, },
{ {
id: 'kb', id: 'kb',
title: 'Knowledge Base', title: 'Knowledge Base',
icon: DatabaseIcon, icon: DatabaseIcon,
component: KnowledgeBase, component: () => import('../pages/KnowledgeBase.svelte'),
width: 1000, width: 1000,
height: 700, height: 700,
minWidth: 520, minWidth: 520,
minHeight: 420 minHeight: 420,
source: 'builtin'
}, },
{ {
id: 'ops', id: 'ops',
title: 'Operations', title: 'Operations',
icon: ShieldCheckIcon, icon: ShieldCheckIcon,
component: Ops, component: () => import('../pages/Ops.svelte'),
width: 860, width: 860,
height: 620, height: 620,
minWidth: 480, minWidth: 480,
minHeight: 360, minHeight: 360,
badge: (s) => s?.approvals_pending ?? 0 badge: (s) => s?.approvals_pending ?? 0,
source: 'builtin'
}, },
{ {
id: 'signals', id: 'signals',
title: 'Signals', title: 'Signals',
icon: SirenIcon, icon: SirenIcon,
component: Signals, component: () => import('../pages/Signals.svelte'),
width: 860, width: 860,
height: 620, height: 620,
minWidth: 480, minWidth: 480,
minHeight: 360, minHeight: 360,
badge: (s) => openSignalCount(s) badge: (s) => openSignalCount(s),
source: 'builtin'
}, },
{ {
id: 'knowledge', id: 'knowledge',
title: 'Knowledge', title: 'Knowledge',
icon: SearchIcon, icon: SearchIcon,
component: Knowledge, component: () => import('../pages/Knowledge.svelte'),
width: 800, width: 800,
height: 600, height: 600,
minWidth: 440, minWidth: 440,
minHeight: 340 minHeight: 340,
source: 'builtin'
}, },
{ {
id: 'learning', id: 'learning',
title: 'Learning', title: 'Learning',
icon: TrendingUpIcon, icon: TrendingUpIcon,
component: Learning, component: () => import('../pages/Learning.svelte'),
width: 800, width: 800,
height: 600, height: 600,
minWidth: 440, minWidth: 440,
minHeight: 340 minHeight: 340,
source: 'builtin'
}, },
{ {
id: 'settings', id: 'settings',
title: 'Settings', title: 'Settings',
icon: SettingsIcon, icon: SettingsIcon,
component: Settings, component: () => import('../pages/Settings.svelte'),
width: 640, width: 640,
height: 480, height: 480,
minWidth: 480, minWidth: 480,
minHeight: 360 minHeight: 360,
source: 'builtin'
},
{
id: 'app-store',
title: 'App Store',
icon: StoreIcon,
component: () => import('../pages/AppStore.svelte'),
width: 720,
height: 560,
minWidth: 480,
minHeight: 360,
source: 'builtin'
},
{
id: 'mascot',
title: 'Cluck',
icon: EggIcon,
component: () => import('./mascot/MascotLayer.svelte'),
docked: true,
source: 'builtin'
} }
] ]
export const appById = new Map(APPS.map((a) => [a.id, a])) // --- Installed (operator-installed from the App Store) ---------------------
const INSTALLED_KEY = 'oikos-installed-apps'
function loadInstalled(): string[] {
if (typeof localStorage === 'undefined') return []
try {
const raw = localStorage.getItem(INSTALLED_KEY)
if (!raw) return []
const ids = JSON.parse(raw) as string[]
// Drop ids that no longer resolve to a catalog entry (the app was
// removed from the catalog in a later build) so they don't linger as
// phantom desktop icons.
return ids.filter((id) => catalogById.has(id))
} catch {
return []
}
}
// Persisted as the list of catalog manifest ids the operator has installed.
const installedIds = writable<string[]>(loadInstalled())
// Readable view for components (App Store UI) that need to re-render on
// install/uninstall. Mutations go through installApp/uninstallApp.
export const installedAppIds: Readable<string[]> = { subscribe: installedIds.subscribe }
function persist(ids: string[]): void {
if (typeof localStorage === 'undefined') return
localStorage.setItem(INSTALLED_KEY, JSON.stringify(ids))
}
installedIds.subscribe(persist)
function catalogEntryToAppDef(entry: CatalogEntry): AppDef {
const m = entry.manifest
return {
id: m.id,
title: m.title,
icon: entry.icon,
component: entry.load,
docked: m.docked,
noIcon: m.noIcon,
width: m.width,
height: m.height,
minWidth: m.minWidth,
minHeight: m.minHeight,
source: 'installed'
}
}
// The full app set: built-ins + installed catalog apps. Reactive so an
// install/uninstall is reflected on the desktop immediately, with no reload.
export const apps: Readable<AppDef[]> = derived(installedIds, (ids) => {
const installed = ids
.map((id) => catalogById.get(id))
.filter((e): e is CatalogEntry => !!e)
.map(catalogEntryToAppDef)
return [...builtinApps, ...installed]
})
export const appById: Readable<Map<string, AppDef>> = derived(apps, (list) =>
new Map(list.map((a) => [a.id, a]))
)
// Install/uninstall. Idempotent — installing an already-installed app or
// uninstalling a not-installed one is a no-op. Uninstalling a built-in is
// refused (built-ins can't be removed).
export function installApp(manifestId: string): void {
if (!catalogById.has(manifestId)) return
installedIds.update((ids) => (ids.includes(manifestId) ? ids : [...ids, manifestId]))
}
export function uninstallApp(manifestId: string): void {
installedIds.update((ids) => ids.filter((id) => id !== manifestId))
}
export function isInstalled(manifestId: string): boolean {
return get(installedIds).includes(manifestId)
}
// --- Window-id helpers (unchanged from the static-registry era) -----------
// Window ids are namespaced so WindowLayer.svelte can tell at a glance which // Window ids are namespaced so WindowLayer.svelte can tell at a glance which
// content branch owns an id: `app:<id>` for registry apps, `session:<id>` // content branch owns an id: `app:<id>` for registry apps, `session:<id>`

View File

@@ -3,7 +3,7 @@
import * as Table from '$lib/components/ui/table' import * as Table from '$lib/components/ui/table'
import { Badge } from '$lib/components/ui/badge' import { Badge } from '$lib/components/ui/badge'
import { Skeleton } from '$lib/components/ui/skeleton' import { Skeleton } from '$lib/components/ui/skeleton'
import SortHeader from '$lib/components/data-table/sort-header.svelte' import SortHeader from '$lib/components/data-table/SortHeader.svelte'
import EmptyState from '$lib/components/EmptyState.svelte' import EmptyState from '$lib/components/EmptyState.svelte'
import HealthDotRenderer from '$lib/components/data-table/renderers/HealthDotRenderer.svelte' import HealthDotRenderer from '$lib/components/data-table/renderers/HealthDotRenderer.svelte'
import StatusBadgeRenderer from '$lib/components/data-table/renderers/StatusBadgeRenderer.svelte' import StatusBadgeRenderer from '$lib/components/data-table/renderers/StatusBadgeRenderer.svelte'

View File

@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { TableHandler } from '@vincjo/datatables' import { TableHandler } from '@vincjo/datatables'
import { Skeleton } from '$lib/components/ui/skeleton' import { Skeleton } from '$lib/components/ui/skeleton'
import SortHeader from './sort-header.svelte' import SortHeader from './SortHeader.svelte'
import Toolbar from './toolbar.svelte' import Toolbar from './Toolbar.svelte'
import Pagination from './pagination/Pagination.svelte' import Pagination from './pagination/Pagination.svelte'
import EmptyState from '$lib/components/EmptyState.svelte' import EmptyState from '$lib/components/EmptyState.svelte'
import BadgeRenderer from './renderers/BadgeRenderer.svelte' import BadgeRenderer from './renderers/BadgeRenderer.svelte'
@@ -11,7 +11,7 @@
import DateRenderer from './renderers/DateRenderer.svelte' import DateRenderer from './renderers/DateRenderer.svelte'
import StatusBadgeRenderer from './renderers/StatusBadgeRenderer.svelte' import StatusBadgeRenderer from './renderers/StatusBadgeRenderer.svelte'
import { resolveCellValue } from './columns' import { resolveCellValue } from './columns'
import type { DataTableColumn, BuiltinRenderer } from './DataTable.svelte.ts' import type { DataTableColumn, BuiltinRenderer } from './types'
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
type Row = Record<string, any> type Row = Record<string, any>

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import SearchInput from './search-input.svelte' import SearchInput from './SearchInput.svelte'
import RowsPerPage from './pagination/RowsPerPage.svelte' import RowsPerPage from './pagination/RowsPerPage.svelte'
import type { TableHandler } from '@vincjo/datatables' import type { TableHandler } from '@vincjo/datatables'

View File

@@ -1,4 +1,4 @@
import type { DataTableColumn } from './DataTable.svelte.ts' import type { DataTableColumn } from './types'
export function resolveCellValue<T>(row: T, col: DataTableColumn<T>): unknown { export function resolveCellValue<T>(row: T, col: DataTableColumn<T>): unknown {
if (col.accessor) return col.accessor(row) if (col.accessor) return col.accessor(row)

View File

@@ -5,7 +5,7 @@
// or dragged window can never end up underneath the taskbar. This replaces // 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 // the old sidebar + hash-routed page shell in App.svelte entirely; apps are
// desktop icons now (see $lib/apps.ts), not nav items. // desktop icons now (see $lib/apps.ts), not nav items.
import { APPS } from '$lib/apps' import { apps } from '$lib/apps'
import { iconPositions, resetIconLayout } from '$lib/stores/icons' import { iconPositions, resetIconLayout } from '$lib/stores/icons'
import { wm, openAppWindow, toggleShowDesktop } from '$lib/stores/windows' import { wm, openAppWindow, toggleShowDesktop } from '$lib/stores/windows'
import { summary } from '$lib/stores/context' import { summary } from '$lib/stores/context'
@@ -13,8 +13,8 @@
import DesktopIcon from './DesktopIcon.svelte' import DesktopIcon from './DesktopIcon.svelte'
import TaskLauncher from './TaskLauncher.svelte' import TaskLauncher from './TaskLauncher.svelte'
import WindowLayer from './WindowLayer.svelte' import WindowLayer from './WindowLayer.svelte'
import DockedLayer from './DockedLayer.svelte'
import Taskbar from './Taskbar.svelte' import Taskbar from './Taskbar.svelte'
import MascotLayer from '$lib/mascot/MascotLayer.svelte'
import LayersIcon from '@lucide/svelte/icons/layers' import LayersIcon from '@lucide/svelte/icons/layers'
import Rows3Icon from '@lucide/svelte/icons/rows-3' import Rows3Icon from '@lucide/svelte/icons/rows-3'
import MonitorIcon from '@lucide/svelte/icons/monitor' import MonitorIcon from '@lucide/svelte/icons/monitor'
@@ -87,7 +87,7 @@
<GraphBackground /> <GraphBackground />
<div class="pointer-events-none absolute inset-0 z-0"> <div class="pointer-events-none absolute inset-0 z-0">
{#each APPS as app (app.id)} {#each $apps as app (app.id)}
{@const pos = $iconPositions[app.id] ?? { col: 0, row: 0 }} {@const pos = $iconPositions[app.id] ?? { col: 0, row: 0 }}
{@const badge = app.badge?.($summary) ?? 0} {@const badge = app.badge?.($summary) ?? 0}
<DesktopIcon {app} {pos} {badge} onOpen={() => openAppWindow(app.id)} /> <DesktopIcon {app} {pos} {badge} onOpen={() => openAppWindow(app.id)} />
@@ -102,7 +102,7 @@
<WindowLayer /> <WindowLayer />
<MascotLayer /> <DockedLayer />
</div> </div>
<Taskbar /> <Taskbar />

View File

@@ -0,0 +1,24 @@
<script lang="ts">
// The docked-app layer: renders apps flagged `docked: true` on a
// pointer-events-none absolute inset-0 overlay above WindowLayer's z-40,
// below the desktop context menu's z-50. Docked apps have no wmkit window,
// no titlebar, and no taskbar button; their visibility is toggled by
// clicking their desktop icon (see stores/docked.ts). Replaces the
// previously-hardcoded <MascotLayer /> in Desktop.svelte — the mascot is
// now the first docked app, not a shell special case. Rendered as a sibling
// inside the surface div so docked apps share the surface's coordinate
// space (the mascot's ground-line computation depends on this).
import { apps } from '$lib/apps'
import { dockedVisibility } from '$lib/stores/docked'
import LazyApp from '$lib/components/desktop-shell/LazyApp.svelte'
const dockedApps = $derived($apps.filter((a) => a.docked))
</script>
<div class="pointer-events-none absolute inset-0 z-45">
{#each dockedApps as app (app.id)}
{#if $dockedVisibility[app.id] ?? true}
<LazyApp load={app.component} />
{/if}
{/each}
</div>

View File

@@ -0,0 +1,35 @@
<script lang="ts">
// Renders an App's lazily-loaded component (AppDef.component is a
// dynamic-import loader, not the component itself). Shows the shared
// spinner while the chunk fetches; Vite's module cache makes repeat
// opens resolve from cache on the next microtask, so the spinner is
// one-tick at most after first load. Used by both WindowLayer
// (windowed apps) and DockedLayer (docked apps) so the loading state
// is uniform across app kinds.
import type { Component } from 'svelte'
import { untrack } from 'svelte'
import Spinner from '../Spinner.svelte'
let { load }: { load: () => Promise<{ default: Component }> } = $props()
// Created once per mount, not per render. `load` is the app's stable
// registry loader (app.component — defined once in the APPS array, never
// reassigned), so reading it at init is correct; untrack tells Svelte the
// one-shot read is intentional and silences the state_referenced_locally
// lint. Without pinning, {#await} would re-subscribe to a fresh Promise on
// every reactive re-evaluation of load() and loop.
const promise = untrack(() => load())
</script>
{#await promise}
<div class="flex h-full min-h-0 items-center justify-center text-muted-foreground">
<Spinner class="size-5" />
</div>
{:then mod}
{@const C = mod.default}
<C />
{:catch error}
<div class="flex h-full min-h-0 items-center justify-center p-4 text-center text-sm text-destructive">
Failed to load app: {(error as Error).message}
</div>
{/await}

View File

@@ -30,14 +30,14 @@
function iconFor(id: string) { function iconFor(id: string) {
const appId = appIdFromWindowId(id) const appId = appIdFromWindowId(id)
if (appId) return appById.get(appId)?.icon if (appId) return $appById.get(appId)?.icon
if (id.startsWith(SESSION_PREFIX)) return MessageSquareIcon if (id.startsWith(SESSION_PREFIX)) return MessageSquareIcon
return DatabaseIcon return DatabaseIcon
} }
function badgeFor(id: string): number { function badgeFor(id: string): number {
const appId = appIdFromWindowId(id) const appId = appIdFromWindowId(id)
const app = appId ? appById.get(appId) : undefined const app = appId ? $appById.get(appId) : undefined
return app?.badge?.($summary) ?? 0 return app?.badge?.($summary) ?? 0
} }

View File

@@ -14,18 +14,22 @@
import EntityDetailContent from '../EntityDetailContent.svelte' import EntityDetailContent from '../EntityDetailContent.svelte'
import SessionChatWindow from '../SessionChatWindow.svelte' import SessionChatWindow from '../SessionChatWindow.svelte'
import NewTaskChat from './NewTaskChat.svelte' import NewTaskChat from './NewTaskChat.svelte'
import LazyApp from '$lib/components/desktop-shell/LazyApp.svelte'
import XIcon from '@lucide/svelte/icons/x' import XIcon from '@lucide/svelte/icons/x'
import MinusIcon from '@lucide/svelte/icons/minus' import MinusIcon from '@lucide/svelte/icons/minus'
import Maximize2Icon from '@lucide/svelte/icons/maximize-2' import Maximize2Icon from '@lucide/svelte/icons/maximize-2'
// A hydrated `app:<id>` window whose id no longer matches any registry // A hydrated `app:<id>` window whose id no longer matches any registry
// entry (the app was renamed/removed since the layout was persisted) has // entry (the app was uninstalled/removed since the layout was persisted)
// nothing to render — close it rather than leaving a permanently-blank // has nothing to render — close it rather than leaving a permanently-blank
// window stuck in the taskbar. // window stuck in the taskbar. Reactive on `appById` so reinstalling an
// app revives its persisted window on the next tick rather than requiring
// a reload, and uninstalling closes its orphan window immediately.
$effect(() => { $effect(() => {
const idx = $appById
for (const id of $wmState.order) { for (const id of $wmState.order) {
const appId = appIdFromWindowId(id) const appId = appIdFromWindowId(id)
if (appId && !appById.has(appId)) wm.close(id) if (appId && !idx.has(appId)) wm.close(id)
} }
}) })
</script> </script>
@@ -34,7 +38,7 @@
{#each $wmState.order as id (id)} {#each $wmState.order as id (id)}
{@const win = $wmState.windows[id]} {@const win = $wmState.windows[id]}
{@const appId = appIdFromWindowId(id)} {@const appId = appIdFromWindowId(id)}
{@const app = appId ? appById.get(appId) : undefined} {@const app = appId ? $appById.get(appId) : undefined}
{#if win && (!appId || app)} {#if win && (!appId || app)}
<section use:dk.window={{ id }} class="min-w-0" aria-label={win.title}> <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"> <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">
@@ -73,7 +77,7 @@
{:else if id === NEW_TASK_WINDOW_ID} {:else if id === NEW_TASK_WINDOW_ID}
<NewTaskChat /> <NewTaskChat />
{:else if app} {:else if app}
<app.component /> <LazyApp load={app.component} />
{:else} {:else}
<EntityDetailContent slug={id} onSelectEntity={openEntityWindow} /> <EntityDetailContent slug={id} onSelectEntity={openEntityWindow} />
{/if} {/if}

View File

@@ -32,7 +32,6 @@
} from '$lib/mascot/state.svelte' } from '$lib/mascot/state.svelte'
import { wmState } from '$lib/stores/windows' import { wmState } from '$lib/stores/windows'
import { getIconPositions, iconPixelPos, GRID } from '$lib/stores/icons' import { getIconPositions, iconPixelPos, GRID } from '$lib/stores/icons'
import { APPS, type AppDef } from '$lib/apps'
// MascotRuntime is created fresh per mount; the long-lived MascotModel // MascotRuntime is created fresh per mount; the long-lived MascotModel
// (with lastPos, stage, name, ...) persists across mounts via localStorage. // (with lastPos, stage, name, ...) persists across mounts via localStorage.
@@ -217,13 +216,13 @@
} }
const ICON_INVESTIGATE_FALLBACK = '👀 Ooh!' const ICON_INVESTIGATE_FALLBACK = '👀 Ooh!'
/** Which desktop-icon app (if any) the given surface coords land on, using the same grid DesktopIcon.svelte renders with. */ /** Which desktop-icon app (if any) the given surface coords land on, using the same grid DesktopIcon.svelte renders with. Returns the app id (the key the investigate-bubble map is indexed by) — iterating the icon-positions store rather than the APPS registry avoids a static import cycle (apps.ts -> MascotLayer -> here -> apps.ts), see docked visibility store wiring. */
function iconAt(x: number, y: number): AppDef | null { function iconAt(x: number, y: number): string | null {
const positions = getIconPositions() const positions = getIconPositions()
for (const app of APPS) { for (const id of Object.keys(positions)) {
const pos = positions[app.id] ?? { col: 0, row: 0 } const pos = positions[id] ?? { col: 0, row: 0 }
const { x: ix, y: iy } = iconPixelPos(pos) const { x: ix, y: iy } = iconPixelPos(pos)
if (x >= ix && x <= ix + GRID.cell && y >= iy && y <= iy + GRID.cell) return app if (x >= ix && x <= ix + GRID.cell && y >= iy && y <= iy + GRID.cell) return id
} }
return null return null
} }

View File

@@ -0,0 +1,62 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
// docked.ts no longer imports $lib/apps (defaults are implicit: absent key
// = visible), so no mock is needed. Each test re-imports the module fresh
// (after clearing localStorage) so the module-scoped store starts from the
// cleared state every time — without this, the store's state leaks between
// tests since it's cached with the module instance.
import type * as Docked from './docked'
let mod: typeof Docked
beforeEach(async () => {
localStorage.clear()
vi.resetModules()
mod = await import('./docked')
})
describe('docked visibility store', () => {
it('defaults docked apps to visible', () => {
expect(mod.isDockedVisible('mascot')).toBe(true)
expect(mod.isDockedVisible('other-docked')).toBe(true)
})
it('treats unknown ids as visible (absent key = visible)', () => {
expect(mod.isDockedVisible('nope')).toBe(true)
})
it('toggle flips visibility', () => {
mod.toggleDocked('mascot')
expect(mod.isDockedVisible('mascot')).toBe(false)
mod.toggleDocked('mascot')
expect(mod.isDockedVisible('mascot')).toBe(true)
})
it('persists to localStorage', () => {
mod.toggleDocked('mascot')
const raw = localStorage.getItem(mod.STORAGE_KEY)
expect(raw).toBeTruthy()
expect(JSON.parse(raw!).mascot).toBe(false)
})
it('merge over defaults so a newly-registered docked app is visible', async () => {
// Simulate a persisted blob from before 'other-docked' existed.
localStorage.setItem(mod.STORAGE_KEY, JSON.stringify({ mascot: false }))
vi.resetModules()
const fresh = await import('./docked')
expect(fresh.isDockedVisible('mascot')).toBe(false)
expect(fresh.isDockedVisible('other-docked')).toBe(true)
})
it('dockedVisibility store is subscribable', () => {
let latest: Record<string, boolean> | undefined
const unsub = mod.dockedVisibility.subscribe((v) => (latest = v))
// Store starts empty — absent keys mean visible (isDockedVisible fallback).
expect(latest).toEqual({})
expect(mod.isDockedVisible('mascot')).toBe(true)
mod.toggleDocked('mascot')
expect(latest?.mascot).toBe(false)
expect(mod.isDockedVisible('mascot')).toBe(false)
unsub()
})
})

View File

@@ -0,0 +1,43 @@
// Visibility for docked apps — persisted so "hidden" survives reloads.
// Keyed by app id; an ABSENT key means visible (the default for a newly
// registered docked app, so a fresh install shows the mascot without the
// operator opting in). This means the store only holds *overrides* — it
// doesn't need to enumerate the docked apps to seed defaults, which would
// require importing APPS and create a static cycle (apps.ts -> pages ->
// windows.ts -> here -> apps.ts, TDZ on APPS at init). Unknown persisted
// keys are kept (merge semantics), so an uninstalled-then-reinstalled
// docked app remembers its visibility across the gap.
import { writable, get } from 'svelte/store'
export const STORAGE_KEY = 'oikos-docked-apps'
function load(): Record<string, boolean> {
if (typeof localStorage === 'undefined') return {}
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return {}
return JSON.parse(raw) as Record<string, boolean>
} catch {
return {}
}
}
const _visibility = writable<Record<string, boolean>>(load())
function persist(vis: Record<string, boolean>): void {
if (typeof localStorage === 'undefined') return
localStorage.setItem(STORAGE_KEY, JSON.stringify(vis))
}
_visibility.subscribe(persist)
// Read-only surface for components; mutations go through toggleDocked.
export const dockedVisibility = { subscribe: _visibility.subscribe }
export function toggleDocked(appId: string): void {
_visibility.update((vis) => ({ ...vis, [appId]: !(vis[appId] ?? true) }))
}
export function isDockedVisible(appId: string): boolean {
return get(_visibility)[appId] ?? true
}

View File

@@ -1,11 +1,19 @@
import { describe, it, expect, beforeEach, vi } from 'vitest' import { describe, it, expect, beforeEach, vi } from 'vitest'
// icons.ts only needs APPS for its default-layout ids — stub it out rather // icons.ts reads the reactive `apps` store (derived from built-ins +
// than pull in the real registry's full page-component graph (see // installed apps) to seed default positions. Stub it as a minimal Readable
// apps.test.ts for why that graph is expensive/broken under vitest). // emitting a fixed list rather than pull in the real registry.
vi.mock('$lib/apps', () => ({ vi.mock('$lib/apps', () => {
APPS: [{ id: 'tasks' }, { id: 'kb' }, { id: 'ops' }, { id: 'signals' }, { id: 'knowledge' }, { id: 'learning' }] const list = [
})) { id: 'tasks' },
{ id: 'kb' },
{ id: 'ops' },
{ id: 'signals' },
{ id: 'knowledge' },
{ id: 'learning' }
]
return { apps: { subscribe: (cb: (v: typeof list) => void) => { cb(list); return () => {} } } }
})
import { GRID, iconPositions, placeIcon, iconPixelPos, maxCols, getIconPositions, resetIconLayout } from './icons' import { GRID, iconPositions, placeIcon, iconPixelPos, maxCols, getIconPositions, resetIconLayout } from './icons'

View File

@@ -5,8 +5,14 @@
// simpler drag model (snap-to-cell, no resize/minimize/stack). Reinventing // simpler drag model (snap-to-cell, no resize/minimize/stack). Reinventing
// that inside wmkit would mean fighting its window-shaped abstractions for // that inside wmkit would mean fighting its window-shaped abstractions for
// no benefit. // no benefit.
//
// Reactive to the apps store (Phase 3): when an installed app registers
// after init, it gets a free cell on the next emission. Uninstalled apps'
// positions are KEPT (so reinstall remembers where the icon was) but their
// icons simply don't render — the desktop's {#each $apps} is the source of
// truth for what's visible.
import { writable, get } from 'svelte/store' import { writable, get } from 'svelte/store'
import { APPS } from '$lib/apps' import { apps } from '$lib/apps'
export interface IconPos { export interface IconPos {
col: number col: number
@@ -17,36 +23,17 @@ export const GRID = { cell: 96, gap: 12, padding: 16 }
const STORAGE_KEY = 'oikos-desktop-icons' const STORAGE_KEY = 'oikos-desktop-icons'
function defaultPositions(): Record<string, IconPos> {
// Classic OS default: one left-edge column, registry order.
const out: Record<string, IconPos> = {}
APPS.forEach((app, i) => {
out[app.id] = { col: 0, row: i }
})
return out
}
function load(): Record<string, IconPos> { function load(): Record<string, IconPos> {
if (typeof localStorage === 'undefined') return defaultPositions() if (typeof localStorage === 'undefined') return {}
try { try {
const raw = localStorage.getItem(STORAGE_KEY) const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return defaultPositions() if (!raw) return {}
const parsed = JSON.parse(raw) as Record<string, IconPos> return JSON.parse(raw) as Record<string, IconPos>
const out = defaultPositions()
// Merge over the defaults so a newly-registered app (not in the saved
// blob yet) still gets a sane starting position instead of being absent
// from the grid entirely.
for (const [id, pos] of Object.entries(parsed)) {
if (appIds.has(id)) out[id] = pos
}
return out
} catch { } catch {
return defaultPositions() return {}
} }
} }
const appIds = new Set(APPS.map((a) => a.id))
export const iconPositions = writable<Record<string, IconPos>>(load()) export const iconPositions = writable<Record<string, IconPos>>(load())
function persist(positions: Record<string, IconPos>): void { function persist(positions: Record<string, IconPos>): void {
@@ -54,7 +41,7 @@ function persist(positions: Record<string, IconPos>): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(positions)) localStorage.setItem(STORAGE_KEY, JSON.stringify(positions))
} }
iconPositions.subscribe((positions) => persist(positions)) iconPositions.subscribe(persist)
function occupied(positions: Record<string, IconPos>, col: number, row: number, exceptId: string): boolean { function occupied(positions: Record<string, IconPos>, col: number, row: number, exceptId: string): boolean {
return Object.entries(positions).some(([id, p]) => id !== exceptId && p.col === col && p.row === row) return Object.entries(positions).some(([id, p]) => id !== exceptId && p.col === col && p.row === row)
@@ -108,6 +95,35 @@ export function getIconPositions(): Record<string, IconPos> {
// Bails a messy manual layout back to the classic left-edge column, // Bails a messy manual layout back to the classic left-edge column,
// registry order — the desktop's right-click menu's "Reset icon layout". // registry order — the desktop's right-click menu's "Reset icon layout".
// Clears all positions then re-seeds from the current app list, so it
// respects the live registry (including installed apps) rather than a
// static snapshot.
export function resetIconLayout(): void { export function resetIconLayout(): void {
iconPositions.set(defaultPositions()) iconPositions.set(seedMissing({}, get(apps)))
} }
// Seeds positions for any app in `list` that doesn't have one yet —
// classic OS default: one left-edge column, registry order. Returns the
// same positions object if nothing needed seeding (so callers can skip a
// no-op set), otherwise a fresh merged object.
function seedMissing(positions: Record<string, IconPos>, list: { id: string }[]): Record<string, IconPos> {
let next: Record<string, IconPos> | null = null
let row = 0
for (const app of list) {
if (positions[app.id]) continue
if (!next) next = { ...positions }
while (occupied(next, 0, row, app.id)) row++
next![app.id] = { col: 0, row }
row++
}
return next ?? positions
}
// Seed on every apps-store emission so a newly-installed app gets a cell
// immediately. Existing positions (including uninstalled apps' remembered
// spots) are preserved.
apps.subscribe((list) => {
const current = get(iconPositions)
const next = seedMissing(current, list)
if (next !== current) iconPositions.set(next)
})

View File

@@ -3,10 +3,11 @@
// opened from Knowledge Base, chat, or anywhere else land in the same // opened from Knowledge Base, chat, or anywhere else land in the same
// floating window layer, with several windows open side by side, rather than // floating window layer, with several windows open side by side, rather than
// each page owning its own single-entity sidebar/sheet. // each page owning its own single-entity sidebar/sheet.
import { derived, type Readable } from 'svelte/store' import { derived, get, type Readable } from 'svelte/store'
import { createManager, createDesktop, wmStore } from '@surdeddd/wmkit/svelte' import { createManager, createDesktop, wmStore } from '@surdeddd/wmkit/svelte'
import { persist } from '@surdeddd/wmkit/persist' import { persist } from '@surdeddd/wmkit/persist'
import { appById, appWindowId } from '$lib/apps' import { appById, appWindowId } from '$lib/apps'
import { toggleDocked } from '$lib/stores/docked'
import { sessions } from '$lib/stores/chat' import { sessions } from '$lib/stores/chat'
import { heading } from '$lib/tasks' import { heading } from '$lib/tasks'
@@ -75,9 +76,18 @@ export function toggleShowDesktop(): void {
// Opens (or focuses/restores) a registry app's window. Apps are // Opens (or focuses/restores) a registry app's window. Apps are
// single-instance — double-clicking an already-open app's icon should never // single-instance — double-clicking an already-open app's icon should never
// stack a second window, same dedupe pattern as openEntityWindow below. // stack a second window, same dedupe pattern as openEntityWindow below.
// Docked apps (e.g. the mascot) have no wmkit window at all — clicking their
// icon toggles visibility on the Docked Layer instead, so this branches on
// kind before touching the window manager. All callers (the desktop icon,
// the taskbar settings button, legacy hash resolution) go through here, so
// none of them need a kind-specific branch.
export function openAppWindow(appId: string): void { export function openAppWindow(appId: string): void {
const app = appById.get(appId) const app = get(appById).get(appId)
if (!app) return if (!app) return
if (app.docked) {
toggleDocked(appId)
return
}
const id = appWindowId(appId) const id = appWindowId(appId)
if (wm.get(id)) { if (wm.get(id)) {
wm.restore(id) wm.restore(id)

View File

@@ -0,0 +1,92 @@
<script lang="ts">
// App Store — lists the installable-app catalog (web/src/lib/app-store/catalog.ts),
// shows install state, and installs/uninstalls. Installing persists the
// manifest id (localStorage) and the registry's reactive `apps` store
// immediately renders the new app's desktop icon — no reload. Uninstalling
// removes the icon and (via WindowLayer's reactive orphan-close effect)
// closes any open window for that app.
//
// Phase 3 scope: local bundles only (apps that ship with the build,
// discovered via the catalog). Phase 4 will swap the catalog for a
// fetched `/api/v1/apps` endpoint + remote bundle loading with
// sandboxing; the install/uninstall lifecycle here is the mechanism that
// generalizes.
import { CATALOG } from '$lib/app-store/catalog'
import { installApp, uninstallApp, installedAppIds } from '$lib/apps'
import CheckCircleIcon from '@lucide/svelte/icons/circle-check-big'
import DownloadIcon from '@lucide/svelte/icons/download'
import TrashIcon from '@lucide/svelte/icons/trash'
import ShieldIcon from '@lucide/svelte/icons/shield'
// Reactive membership test — re-renders when the installed set changes.
const installedSet = $derived(new Set($installedAppIds))
</script>
<div class="flex h-full min-h-0 flex-col">
<header class="shrink-0 border-b px-4 py-3">
<h1 class="text-base font-semibold">App Store</h1>
<p class="mt-0.5 text-xs text-muted-foreground">
Installable apps. Local bundles for now — a remote catalog + sandboxing
is Phase 4.
</p>
</header>
<div class="min-h-0 flex-1 overflow-auto p-4">
{#if CATALOG.length === 0}
<p class="text-sm text-muted-foreground">No apps available yet.</p>
{:else}
<ul class="flex flex-col gap-3">
{#each CATALOG as entry (entry.manifest.id)}
{@const m = entry.manifest}
{@const isOn = installedSet.has(m.id)}
<li class="flex items-start gap-3 rounded-lg border p-3">
<div class="flex size-10 shrink-0 items-center justify-center rounded-md border bg-muted/40">
<entry.icon class="size-5" />
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<h2 class="truncate text-sm font-medium">{m.title}</h2>
<span class="shrink-0 text-[10px] font-mono text-muted-foreground">v{m.version}</span>
{#if m.author}<span class="shrink-0 text-[10px] text-muted-foreground">by {m.author}</span>{/if}
</div>
<p class="mt-0.5 line-clamp-2 text-xs text-muted-foreground">{m.description}</p>
<div class="mt-1.5 flex items-center gap-1 text-[10px] text-muted-foreground">
<ShieldIcon class="size-3" />
<span>
{#if m.permissions.length}{m.permissions.join(', ')}{:else}no permissions requested{/if}
</span>
</div>
</div>
<div class="shrink-0">
{#if isOn}
<button
type="button"
class="flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-destructive/10 hover:text-destructive hover:border-destructive/50"
onclick={() => uninstallApp(m.id)}
>
<TrashIcon class="size-3.5" /> Uninstall
</button>
{:else}
<button
type="button"
class="flex items-center gap-1.5 rounded-md bg-primary px-2.5 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
onclick={() => installApp(m.id)}
>
<DownloadIcon class="size-3.5" /> Install
</button>
{/if}
</div>
</li>
{/each}
</ul>
{/if}
<div class="mt-6 rounded-md border border-dashed p-3 text-xs text-muted-foreground">
<p class="flex items-center gap-1.5">
<CheckCircleIcon class="size-3.5" />
Installed apps appear on the desktop immediately. Uninstalling closes
any open window for that app.
</p>
</div>
</div>
</div>

View File

@@ -13,7 +13,7 @@
import { Badge } from '$lib/components/ui/badge' import { Badge } from '$lib/components/ui/badge'
import { toast } from 'svelte-sonner' import { toast } from 'svelte-sonner'
import DataTable from '$lib/components/data-table/DataTable.svelte' import DataTable from '$lib/components/data-table/DataTable.svelte'
import type { DataTableColumn } from '$lib/components/data-table/DataTable.svelte.ts' import type { DataTableColumn } from '$lib/components/data-table/types'
import ApprovalActions from '$lib/components/data-table/renderers/ApprovalActions.svelte' import ApprovalActions from '$lib/components/data-table/renderers/ApprovalActions.svelte'
import ActivityCancel from '$lib/components/data-table/renderers/ActivityCancel.svelte' import ActivityCancel from '$lib/components/data-table/renderers/ActivityCancel.svelte'
import ActivityAction from '$lib/components/data-table/renderers/ActivityAction.svelte' import ActivityAction from '$lib/components/data-table/renderers/ActivityAction.svelte'

View File

@@ -6,7 +6,7 @@
import { bucket, FILTERS, TASK_EVENTS, heading, type Bucket } from '$lib/tasks' import { bucket, FILTERS, TASK_EVENTS, heading, type Bucket } from '$lib/tasks'
import { Button } from '$lib/components/ui/button' import { Button } from '$lib/components/ui/button'
import DataTable from '$lib/components/data-table/DataTable.svelte' import DataTable from '$lib/components/data-table/DataTable.svelte'
import type { DataTableColumn } from '$lib/components/data-table/DataTable.svelte.ts' import type { DataTableColumn } from '$lib/components/data-table/types'
import StatusDotRenderer from '$lib/components/data-table/renderers/StatusDotRenderer.svelte' import StatusDotRenderer from '$lib/components/data-table/renderers/StatusDotRenderer.svelte'
import PlusIcon from '@lucide/svelte/icons/plus' import PlusIcon from '@lucide/svelte/icons/plus'
import type { Session } from '$lib/api' import type { Session } from '$lib/api'

View File

@@ -7,7 +7,7 @@
import * as Select from '$lib/components/ui/select' import * as Select from '$lib/components/ui/select'
import { toast } from 'svelte-sonner' import { toast } from 'svelte-sonner'
import DataTable from '$lib/components/data-table/DataTable.svelte' import DataTable from '$lib/components/data-table/DataTable.svelte'
import type { DataTableColumn } from '$lib/components/data-table/DataTable.svelte.ts' import type { DataTableColumn } from '$lib/components/data-table/types'
import SignalActions from '$lib/components/data-table/renderers/SignalActions.svelte' import SignalActions from '$lib/components/data-table/renderers/SignalActions.svelte'
let signals = $state<Signal[]>([]) let signals = $state<Signal[]>([])