Compare commits
3 Commits
claude/des
...
claude/fro
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f4d645d06 | |||
| aee458ce83 | |||
| 58a11ca872 |
@@ -8,6 +8,7 @@ procedures, and the system model.
|
||||
| ---- | -------- |
|
||||
| [adr/](adr/README.md) | Architecture Decision Records (numbered, append-only) |
|
||||
| [mbse/](mbse/README.md) | Model-Based Systems Engineering views of the platform |
|
||||
| [mascot/](mascot/README.md) | MBSE subsystem model for the desktop mascot (planned) |
|
||||
| [operations/](operations/README.md) | Operator runbooks (deploy, rollback, recovery) |
|
||||
|
||||
For agent orientation see [AGENTS.md](../AGENTS.md); for the operating model
|
||||
|
||||
335
docs/mascot/README.md
Normal file
335
docs/mascot/README.md
Normal file
@@ -0,0 +1,335 @@
|
||||
# Oikos — Desktop Mascot Subsystem Model
|
||||
|
||||
> Companion to [the platform Model](../mbse/README.md) and
|
||||
> [the Framework](../mbse/framework.md). This document is a **subsystem
|
||||
> Model** in Holt's sense — it conforms to the same Framework (Ontology +
|
||||
> Viewpoints, Markdown + Mermaid Notation) rather than restating it, scoped
|
||||
> to a single not-yet-built subsystem of the `web` component: the desktop
|
||||
> mascot ("Cluck"), a pixel-art chicken that lives on the desktop shell.
|
||||
> Where the platform-wide Views in [../mbse/README.md](../mbse/README.md)
|
||||
> and the component View for `web/src` in
|
||||
> [../mbse/components.md](../mbse/components.md#5-web-control-room) speak
|
||||
> at the level of "the SPA," this document goes one layer deeper into one
|
||||
> feature of it — the same relationship [components.md](../mbse/components.md)
|
||||
> has to [README.md](../mbse/README.md), applied recursively.
|
||||
|
||||
**Status of this Model:** the subsystem it describes does not exist in
|
||||
code yet. Every View below is marked **Planned**, not **Verified** —
|
||||
compare to [../mbse/README.md](../mbse/README.md)'s confidence grading,
|
||||
which this document borrows. The corresponding implementation plan is
|
||||
[plans/2026-07-20-desktop-mascot.md](../../plans/2026-07-20-desktop-mascot.md).
|
||||
|
||||
## Views in this model
|
||||
|
||||
| # | View | Concern it addresses |
|
||||
|---|---|---|
|
||||
| [1](#1-mission--system-context) | Mission & System Context | Why a mascot, and what is it never allowed to do? |
|
||||
| [2](#2-requirements) | Requirements | What must it do, traced from the original request? |
|
||||
| [3](#3-structural-view) | Structural View | What modules make it up, and which are the extension points? |
|
||||
| [4](#4-behavioral-view) | Behavioral View | How does it move, live, and react, moment to moment? |
|
||||
| [5](#5-interfaces-view) | Interfaces View | What does it read from the rest of the system, and how does it persist itself? |
|
||||
| [6](#6-extension-guide) | Extension Guide | How does a future engineer add an animation, behavior, menu action, or reaction? |
|
||||
| [7](#7-verification-view) | Verification View | How will we know it works, once built? |
|
||||
|
||||
## 1. Mission & System Context
|
||||
|
||||
**Stakeholders:** the operator (delight, ambient awareness of system
|
||||
state without opening a window); future engineers extending the mascot's
|
||||
behaviors/reactions/menu.
|
||||
|
||||
**Mission:** give the desktop shell a persistent, living presence that
|
||||
makes background system activity legible at a glance — a chat streaming,
|
||||
a knowledge-graph write, a critical signal — without requiring a window to
|
||||
be open, while doubling as a lightweight tamagotchi for its own sake
|
||||
(delight is a legitimate requirement here, not a side effect).
|
||||
|
||||
**Boundary — what the mascot is, and is not:**
|
||||
|
||||
- It is a **purely client-side, read-only observer**. It subscribes to
|
||||
existing `web` stores (chat, activity, events, dashboard summary) the
|
||||
same way any other UI component does.
|
||||
- It **never calls a mutating API endpoint** and is not a new actuation
|
||||
path — it has no relationship to the `run` gate, `Execution`, or
|
||||
`Approval` entities described in [the platform Ontology](../mbse/ontology.md).
|
||||
Its only "mutation" is its own tamagotchi state, stored client-side.
|
||||
- It is scoped entirely inside the `web` component
|
||||
([../mbse/components.md §5](../mbse/components.md#5-web-control-room));
|
||||
it introduces no new backend surface, no new MCP tool, no new REST route.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph SURFACE["Desktop shell surface (Desktop.svelte)"]
|
||||
ICONS["Icon layer\nz-0"]
|
||||
LAUNCH["Task launcher\nz-10"]
|
||||
WIN["WindowLayer\nz-40"]
|
||||
MASCOT["MascotLayer\nz-45\n(this subsystem)"]
|
||||
MENU["Desktop context menu\nz-50"]
|
||||
end
|
||||
|
||||
MASCOT -->|subscribes, read-only| EVENTS["stores/events.ts\nliveEvents (SSE)"]
|
||||
MASCOT -->|subscribes, read-only| CHAT["stores/chat.ts\nstreaming"]
|
||||
MASCOT -->|subscribes, read-only| ACTIVITY["stores/activity.ts\nactivityLog"]
|
||||
MASCOT -->|subscribes, read-only| CONTEXT["stores/context.ts\nsummary"]
|
||||
MASCOT -->|reads/writes| LS["localStorage\noikos-mascot"]
|
||||
|
||||
style MASCOT fill:#fff3e0,stroke:#e65100
|
||||
```
|
||||
|
||||
## 2. Requirements
|
||||
|
||||
Traced from the original feature request. All **Planned**.
|
||||
|
||||
| ID | Statement | Source | Status |
|
||||
|---|---|---|---|
|
||||
| MASC-1 | The mascot SHALL render as pixel-art, drawn from code (string pixel-grids + palette), not binary sprite assets | User request | Planned |
|
||||
| MASC-2 | The mascot SHALL roam the desktop surface autonomously, walking along the ground (surface bottom, above the taskbar) under gravity | User request + design decision | Planned |
|
||||
| MASC-3 | The mascot SHALL be draggable with the mouse; releasing it mid-air SHALL trigger a flutter-fall back to the ground | User request + design decision | Planned |
|
||||
| MASC-4 | Right-clicking the mascot SHALL open a round (Sims-style) interaction menu supporting nested submenus | User request | Planned |
|
||||
| MASC-5 | The mascot SHALL have a tamagotchi lifecycle: egg → chick → adult, with a user-assignable name | User request | Planned |
|
||||
| MASC-6 | The mascot's stage, name, and stats SHALL persist across reloads | User request (implied by "tamagotchi") | Planned |
|
||||
| MASC-7 | The mascot SHALL have idle states (autonomous behavior when untouched) and interactive states (drag, click, menu) | User request | Planned |
|
||||
| MASC-8 | The mascot SHALL react visibly to real application activity: chat streaming, knowledge-graph writes, critical signals | User request ("aware of its environment... feels alive and connected") | Planned |
|
||||
| MASC-9 | Animations, behaviors, menu actions, and reactions SHALL each be defined in a single data-driven registry, so a new one can be added without touching the engine code | User request ("easily expansible") | Planned |
|
||||
| MASC-10 (NFR) | The mascot's game loop SHALL run at ~30fps via `setTimeout`, not `requestAnimationFrame`, matching the repo's existing [`GraphBackground.svelte`](../../web/src/lib/components/GraphBackground.svelte) convention (rAF suspends in some hidden-tab embeddings) | Codebase convention | Planned |
|
||||
| MASC-11 (NFR) | The mascot SHALL never write to the API; all mutation is local (localStorage) | Design decision, this document §1 | Planned |
|
||||
| MASC-12 (NFR) | Persistence writes SHALL be debounced (~300ms), never per animation frame | Codebase convention ([`stores/windows.ts`](../../web/src/lib/stores/windows.ts) wmkit persist) | Planned |
|
||||
|
||||
## 3. Structural View
|
||||
|
||||
**Stakeholders:** an engineer implementing or extending the mascot.
|
||||
**Why this View earns its place:** MASC-9 (extensibility) is only real if
|
||||
the module boundaries actually separate data (registries) from engine
|
||||
code; this View is the check that they do.
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class types_ts {
|
||||
<<module>>
|
||||
PixelGrid
|
||||
AnimName
|
||||
MascotStage
|
||||
BehaviorId
|
||||
Stimulus
|
||||
RadialAction
|
||||
}
|
||||
class palette_ts {
|
||||
<<module, registry>>
|
||||
PALETTE: char to CSS color
|
||||
}
|
||||
class sprites_ts {
|
||||
<<module, registry>>
|
||||
SPRITES: Stage to AnimName to AnimDef
|
||||
resolveAnim(stage, name)
|
||||
}
|
||||
class render_ts {
|
||||
<<module, stateless>>
|
||||
drawFrame(ctx, grid, palette, flip)
|
||||
}
|
||||
class state_svelte_ts {
|
||||
<<module, runes>>
|
||||
MascotModel state
|
||||
grantXp() feed() pet() setName()
|
||||
tickLifecycle() advanceStageIfReady()
|
||||
persist (debounced, oikos-mascot)
|
||||
}
|
||||
class behavior_ts {
|
||||
<<module, registry>>
|
||||
BEHAVIORS: BehaviorId to BehaviorDef
|
||||
stepMascot(rt, model, now, dt)
|
||||
}
|
||||
class stimuli_ts {
|
||||
<<module, registry>>
|
||||
REACTIONS: id to ReactionDef
|
||||
attachStimuli(emit)
|
||||
}
|
||||
class actions_ts {
|
||||
<<module, registry>>
|
||||
MASCOT_ACTIONS: RadialAction tree
|
||||
registerMascotAction()
|
||||
}
|
||||
class Mascot_svelte {
|
||||
<<component>>
|
||||
canvas render loop 30fps
|
||||
pointer drag/click/contextmenu
|
||||
}
|
||||
class MascotLayer_svelte {
|
||||
<<component>>
|
||||
z-45 absolute overlay
|
||||
hosts Mascot + RadialMenu + bubble
|
||||
}
|
||||
class RadialMenu_svelte {
|
||||
<<component>>
|
||||
z-60 fixed, nested rings
|
||||
}
|
||||
class NameDialog_svelte {
|
||||
<<component>>
|
||||
}
|
||||
|
||||
sprites_ts --> palette_ts : indexes
|
||||
sprites_ts --> types_ts : uses
|
||||
Mascot_svelte --> render_ts : draws frames
|
||||
Mascot_svelte --> sprites_ts : resolves anim
|
||||
Mascot_svelte --> behavior_ts : steps FSM
|
||||
Mascot_svelte --> state_svelte_ts : reads/mutates model
|
||||
MascotLayer_svelte --> Mascot_svelte : hosts
|
||||
MascotLayer_svelte --> RadialMenu_svelte : hosts, on contextmenu
|
||||
MascotLayer_svelte --> stimuli_ts : attaches on mount
|
||||
MascotLayer_svelte --> NameDialog_svelte : hosts, on hatch/rename
|
||||
RadialMenu_svelte --> actions_ts : renders tree
|
||||
stimuli_ts --> behavior_ts : forceBehavior(react)
|
||||
```
|
||||
|
||||
**The four extension registries** (MASC-9's concrete answer — see also
|
||||
[§6 Extension Guide](#6-extension-guide)): `SPRITES` (animations),
|
||||
`BEHAVIORS` (autonomous states), `MASCOT_ACTIONS` (radial menu tree),
|
||||
`REACTIONS` (environment stimuli). Each is plain data; the engine
|
||||
(`behavior.ts`'s `stepMascot`, `Mascot.svelte`'s loop, `RadialMenu.svelte`'s
|
||||
renderer) is generic over whatever the registry currently contains.
|
||||
|
||||
**Mount point:** two lines in
|
||||
[`Desktop.svelte`](../../web/src/lib/components/desktop-shell/Desktop.svelte) —
|
||||
`<MascotLayer />` rendered inside the surface `<div>` (the `relative
|
||||
min-h-0 flex-1 overflow-hidden` element), after `<WindowLayer />`, so its
|
||||
`absolute inset-0` shares the surface's coordinate space and its ground
|
||||
line is exactly the surface's bottom edge (= the taskbar's top edge).
|
||||
|
||||
## 4. Behavioral View
|
||||
|
||||
**Stakeholders:** an engineer reasoning about "what does the mascot do
|
||||
right now, and why." **Why this View earns its place:** a mascot with an
|
||||
implicit, ad-hoc state machine is unmaintainable the moment a second
|
||||
behavior or reaction is added; this View is the state machine made
|
||||
explicit before any of it is coded.
|
||||
|
||||
### 4.1 Behavior FSM (moment-to-moment autonomy)
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> egg
|
||||
egg --> chick : hatchProgress reaches 1\n(advanceStageIfReady)
|
||||
|
||||
state chick_and_adult_behaviors {
|
||||
[*] --> idle
|
||||
idle --> wander : weighted random pick\non behaviorUntil expiry
|
||||
wander --> idle
|
||||
idle --> peck : weighted random pick
|
||||
peck --> idle
|
||||
idle --> sleep : weighted random pick
|
||||
sleep --> idle
|
||||
wander --> falling : y below ground\n(off a dragged edge, etc.)
|
||||
idle --> dragged : pointerdown + move\npast 5px threshold
|
||||
wander --> dragged : pointerdown + move
|
||||
sleep --> dragged : pointerdown + move\n(interrupts sleep)
|
||||
dragged --> falling : pointerup, released mid-air
|
||||
falling --> land : y reaches ground
|
||||
land --> idle
|
||||
[*] --> react : stimulus dispatched\n(priority/cooldown gated)
|
||||
react --> idle : durationMs elapsed,\nreturns to prior-or-idle
|
||||
}
|
||||
|
||||
chick --> adult : xp reaches ADULT_XP\n(advanceStageIfReady)
|
||||
```
|
||||
|
||||
`dragged` always wins over any autonomous behavior; `sleep` is broken only
|
||||
by a reaction whose `ReactionDef.interruptsSleep` is true (§4.3) or by a
|
||||
drag. Weighted-random idle selection (`weight` field in `BehaviorDef`)
|
||||
picks the next autonomous behavior only when the current one's `next()`
|
||||
returns null past `behaviorUntil` — see
|
||||
[plans/2026-07-20-desktop-mascot.md](../../plans/2026-07-20-desktop-mascot.md)
|
||||
for the concrete weights.
|
||||
|
||||
### 4.2 Tamagotchi lifecycle (long-lived state)
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> egg : first load,\ndefaultModel()
|
||||
egg --> chick : active time >= HATCH_MS (3min)\n+ NameDialog shown
|
||||
chick --> adult : xp >= ADULT_XP (200)
|
||||
adult --> [*]
|
||||
```
|
||||
|
||||
This is a separate state machine from §4.1: §4.1 governs frame-to-frame
|
||||
motion/animation, §4.2 governs the tamagotchi's slow-moving `MascotModel`
|
||||
(persisted, ticked ~1x/sec via `tickLifecycle`, not every frame).
|
||||
|
||||
### 4.3 Example sequence — an environment stimulus becomes a visible reaction
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant SSE as stores/events.ts (SSE)
|
||||
participant Stim as stimuli.ts attachStimuli
|
||||
participant FSM as behavior.ts
|
||||
participant Mascot as Mascot.svelte (canvas)
|
||||
|
||||
SSE->>Stim: liveEvents updates,\nnew head event severity=critical
|
||||
Stim->>Stim: check REACTIONS['alarmed']\ncooldown + priority
|
||||
Stim->>FSM: forceBehavior(rt, 'react', {anim: 'react-alarm', durationMs})
|
||||
FSM->>FSM: interrupts current behavior\n(even sleep, interruptsSleep=true)
|
||||
FSM->>Mascot: rt.behavior = 'react', rt.anim = 'react-alarm'
|
||||
Mascot->>Mascot: next 30fps tick draws\nreact-alarm frame
|
||||
Note over FSM: after durationMs,\nnext() returns to idle
|
||||
```
|
||||
|
||||
## 5. Interfaces View
|
||||
|
||||
**Stakeholders:** an engineer wiring a new store into the mascot's
|
||||
awareness, or auditing what it depends on.
|
||||
|
||||
| Interface | Direction | Shape | Notes |
|
||||
|---|---|---|---|
|
||||
| [`stores/events.ts`](../../web/src/lib/stores/events.ts) `liveEvents` | consumed | `Writable<OikosEvent[]>`, newest-first, ref-counted via `subscribeEvents()` | `OikosEvent.type` families: `approval.*`, `signal.*`, `execution.*`, `health.changed`; `severity: 'info'\|'warning'\|'critical'` |
|
||||
| [`stores/chat.ts`](../../web/src/lib/stores/chat.ts) `streaming` | consumed | `Writable<boolean>` | false→true edge triggers the `thinking` reaction, held while true |
|
||||
| [`stores/activity.ts`](../../web/src/lib/stores/activity.ts) `activityLog` | consumed | derived `Readable<ActivityEntry[]>`, **recomputed wholesale** on every emission — not append-only | new entries with `type === 'knowledge'` detected by diffing entry `id`s between emissions, not by treating it as a stream |
|
||||
| [`stores/context.ts`](../../web/src/lib/stores/context.ts) `summary` | consumed | `Writable<DashboardSummary\|null>` | ambient state (open signal counts via `openSignalCount(summary)`) |
|
||||
| `localStorage['oikos-mascot']` | owned | `MascotModel` JSON, `{ version: 1, stage, name, hatchProgress, happiness, xp, hatchedAt, lastPos: {x}, lastSeen }` | debounced write (~300ms, mirrors [`stores/windows.ts`](../../web/src/lib/stores/windows.ts) wmkit persist) + `beforeunload` flush; `version` field reserved for a future `migrate()`; multi-tab is last-writer-wins (accepted, documented, not solved) |
|
||||
| [`Desktop.svelte`](../../web/src/lib/components/desktop-shell/Desktop.svelte) mount | owned | `<MascotLayer />`, 2-line insertion | see §3 |
|
||||
|
||||
No interface in this table is a write path to the Oikos API — consistent
|
||||
with §1's boundary statement (MASC-11).
|
||||
|
||||
## 6. Extension Guide
|
||||
|
||||
**Stakeholders:** a future engineer adding one new animation, behavior,
|
||||
menu action, or reaction — this is the Viewpoint 4's "why" made concrete
|
||||
as a recipe rather than prose (mirrors [../mbse/framework.md §7](../mbse/framework.md)'s
|
||||
Process Set treatment).
|
||||
|
||||
| To add a... | Touch only | Nothing else changes because |
|
||||
|---|---|---|
|
||||
| **Animation** | Add the name to the `AnimName` union in `types.ts`; add frames to `SPRITES[stage]` in `sprites.ts` | `resolveAnim()` and the renderer are generic over the registry |
|
||||
| **Behavior** | Add the id to `BehaviorId`; add one `BehaviorDef` entry to `BEHAVIORS` in `behavior.ts` | `stepMascot()` and the weighted-random idle selector consume `BEHAVIORS` generically |
|
||||
| **Radial menu action** | Add a `RadialAction` node to `MASCOT_ACTIONS` in `actions.ts` (or call `registerMascotAction()`), optionally nested under `children` | `RadialMenu.svelte` renders whatever tree it's given, including nesting depth |
|
||||
| **Environment reaction** | Add a `ReactionDef` to `REACTIONS` in `stimuli.ts`; wire one `store subscription -> predicate -> emit(reaction)` block inside `attachStimuli()` | priority/cooldown/interrupt dispatch logic in `attachStimuli()` is generic over `REACTIONS` |
|
||||
|
||||
## 7. Verification View
|
||||
|
||||
**Stakeholders:** whoever implements this subsystem and needs to know
|
||||
when it's actually done, not just compiled.
|
||||
|
||||
Manual browser checklist (no automated test harness planned for v1 — see
|
||||
[plans/2026-07-20-desktop-mascot.md](../../plans/2026-07-20-desktop-mascot.md)
|
||||
for the same list in implementation-order context):
|
||||
|
||||
- Egg renders grounded at the surface bottom, wiggles occasionally, survives
|
||||
a reload at the same x (confirm `oikos-mascot` is debounced — no writes
|
||||
fire from mere walking, only from discrete transitions).
|
||||
- Dragging the egg up and releasing triggers a flutter-fall with no
|
||||
tunneling below the taskbar; dragging past the surface edges clamps.
|
||||
- Forcing hatch (debug menu action) transitions to chick, opens the name
|
||||
dialog, and the name persists across reload.
|
||||
- Chick wanders and flips sprite at surface edges, pecks, sleeps
|
||||
autonomously; a plain click (no drag) triggers a pet/hop reaction.
|
||||
- Right-clicking the chicken opens the radial menu centered on it, without
|
||||
triggering the desktop's own right-click menu; a nested submenu (Feed)
|
||||
opens correctly; Escape pops one level then closes; an outside click
|
||||
closes it; the menu stays fully visible when the chicken is near a
|
||||
screen edge or corner.
|
||||
- With one or more windows open (including a maximized one), the chicken
|
||||
visibly walks above them without breaking window drag/resize/close.
|
||||
- Starting a chat and observing it stream triggers the `thinking` reaction
|
||||
for the duration; a simulated knowledge-graph write triggers `eureka`
|
||||
once per cooldown window; a simulated critical signal triggers `alarmed`
|
||||
even while the chicken is asleep.
|
||||
- Resizing the browser viewport re-grounds and re-clamps the chicken.
|
||||
- Both the Terracotta and Carbon themes keep the pixel-art palette legible.
|
||||
- `npm run build` passes with no new errors.
|
||||
376
plans/2026-07-20-desktop-mascot.md
Normal file
376
plans/2026-07-20-desktop-mascot.md
Normal file
@@ -0,0 +1,376 @@
|
||||
# 2026-07-20 — Desktop mascot ("Cluck")
|
||||
|
||||
**Status:** Planned
|
||||
|
||||
## Why
|
||||
|
||||
The web control room is an OS-style desktop shell (icons, floating
|
||||
windows, taskbar) but has no ambient, always-visible signal of what the
|
||||
system is doing — you have to open a window to see a chat streaming, a
|
||||
knowledge-graph write, or a critical signal land. The user asked for a
|
||||
pixel-art chicken mascot that roams the desktop, is draggable and
|
||||
interactable (Sims-style radial right-click menu with nested actions), and
|
||||
is itself a tamagotchi (egg → chick → adult, nameable, persistent) that
|
||||
visibly reacts to real app activity. This plan is scaffolding: every piece
|
||||
(sprites, autonomous behaviors, menu actions, environment reactions) is a
|
||||
data-driven registry so each can be extended independently later without
|
||||
touching the engine code.
|
||||
|
||||
The MBSE subsystem model for this feature (Mission, Requirements,
|
||||
Structural/Behavioral/Interfaces views, Verification) lives at
|
||||
[docs/mascot/README.md](../docs/mascot/README.md) — read it first for the
|
||||
full rationale and diagrams; this document is the concrete file-by-file
|
||||
implementation plan derived from it.
|
||||
|
||||
**Design decisions already made with the user:**
|
||||
- Renders **above windows** (desktop-pet style) — mascot layer `z-45`,
|
||||
radial menu `z-[60]` (must beat the desktop's own right-click menu,
|
||||
which is `z-50`).
|
||||
- Art is **code-drawn pixel art** — string pixel-grids + a palette map in
|
||||
TypeScript, rendered to a small canvas, no binary sprite assets.
|
||||
- Movement is **gravity + ground** — walks along the desktop surface's
|
||||
bottom edge (= the taskbar's top edge), flutter-falls when dropped
|
||||
mid-air.
|
||||
|
||||
## Verified codebase facts this plan builds on
|
||||
|
||||
- `web/src/lib/components/desktop-shell/Desktop.svelte` — the surface div
|
||||
(`relative min-h-0 flex-1 overflow-hidden`) hosts layered children: icon
|
||||
layer `z-0`, `TaskLauncher` wrapper `z-10`, `WindowLayer` `z-40` — every
|
||||
wrapper is `pointer-events-none` with interactive children re-enabling
|
||||
`pointer-events-auto`. The desktop's own right-click menu is `fixed
|
||||
z-50`, dismissed via `<svelte:window onclick={closeMenu}>` + Escape.
|
||||
Bare-surface clicks are gated with `e.currentTarget === e.target`.
|
||||
- Drag pattern to copy: `desktop-shell/DesktopIcon.svelte` —
|
||||
`pointerdown` + `el.setPointerCapture(e.pointerId)`, a 5px movement
|
||||
threshold distinguishes a click from a drag, move/up listeners attached
|
||||
to the element itself (not `window`), position blended via `$derived`
|
||||
between rest and drag-in-progress values.
|
||||
- Game loop convention: `GraphBackground.svelte` drives its canvas with
|
||||
`setTimeout(() => draw(performance.now()), 33)` (~30fps), **not**
|
||||
`requestAnimationFrame` — the code comment there explains some embedding
|
||||
contexts report `document.hidden=true` and suspend rAF, which would
|
||||
freeze the animation; `setTimeout` keeps ticking. Follow this for the
|
||||
mascot loop, and clamp `dt` to 100ms so a throttled/backgrounded tab
|
||||
doesn't produce a physics-breaking huge step on resume.
|
||||
- Persistence convention: hyphenated `oikos-*` localStorage keys
|
||||
(`oikos-desktop-icons`, `oikos-theme`, `oikos-windows`). Window layout
|
||||
uses wmkit's `persist(wm, { key: 'oikos-windows', debounce: 300,
|
||||
autoRestore: true })` — mirror the 300ms debounce for `oikos-mascot`;
|
||||
never write on every animation frame, only on discrete state
|
||||
transitions (behavior change, drag end, stage change, rename).
|
||||
- Runes idiom for cross-component client state: a `.svelte.ts` module with
|
||||
module-level `$state` plus exported getter/mutator functions —
|
||||
`web/src/lib/stores/theme.svelte.ts` is the canonical example
|
||||
(`let current: Theme = $state(initialTheme)`, `getTheme()`,
|
||||
`setTheme()`, `toggleTheme()`).
|
||||
- Awareness sources, all plain Svelte stores already in the codebase:
|
||||
- `web/src/lib/stores/events.ts` — `liveEvents: Writable<OikosEvent[]>`
|
||||
(newest-first, capped at 200), fed by a ref-counted SSE subscription
|
||||
`subscribeEvents()`. `OikosEvent.type` families: `approval.*`,
|
||||
`signal.*`, `execution.*`, `health.changed`; `severity: 'info' |
|
||||
'warning' | 'critical'`.
|
||||
- `web/src/lib/stores/chat.ts` — `streaming: Writable<boolean>`.
|
||||
- `web/src/lib/stores/activity.ts` — `activityLog` is a **derived**
|
||||
store recomputed wholesale from `messages`/`planSteps`/`currentTask`
|
||||
on every emission, **not an append-only log** — detecting a "new"
|
||||
entry (e.g. `type === 'knowledge'`) requires diffing entry `id`s
|
||||
against the previous emission, not just reacting to the store firing.
|
||||
- `web/src/lib/stores/context.ts` — `summary: Writable<DashboardSummary
|
||||
| null>`, `openSignalCount(summary)`.
|
||||
- No `@keyframes`, no `requestAnimationFrame`, no sprite/pixel-art code
|
||||
exists anywhere in the repo today — this is greenfield within the
|
||||
established canvas-loop convention above.
|
||||
|
||||
## File layout
|
||||
|
||||
All new, under `web/src/lib/mascot/`:
|
||||
|
||||
```
|
||||
types.ts PixelGrid, AnimName, MascotStage, BehaviorId, Stimulus, RadialAction
|
||||
palette.ts Record<char, cssColor>; '.' = transparent
|
||||
sprites.ts SPRITES: Record<MascotStage, Partial<Record<AnimName, AnimDef>>> + resolveAnim() fallback
|
||||
render.ts drawFrame(ctx, grid, palette, flip) — stateless canvas painter
|
||||
state.svelte.ts Tamagotchi model: module $state + mutators, debounced persist, versioned schema
|
||||
behavior.ts FSM: BEHAVIORS registry + stepMascot(rt, model, now, dt)
|
||||
stimuli.ts Stimulus bus: REACTIONS registry + attachStimuli(emit), ref-counted
|
||||
actions.ts MASCOT_ACTIONS radial tree + registerMascotAction()
|
||||
Mascot.svelte canvas sprite, 30fps loop, pointer drag/click/contextmenu
|
||||
MascotLayer.svelte pointer-events-none absolute inset-0 z-45 overlay; hosts Mascot + RadialMenu + bubble
|
||||
RadialMenu.svelte round nested menu, fixed z-[60]
|
||||
NameDialog.svelte naming prompt (hatch + rename)
|
||||
```
|
||||
|
||||
**Integration — 2 lines in `Desktop.svelte`:** import `MascotLayer` and
|
||||
render `<MascotLayer />` inside the surface `<div>`, after `<WindowLayer
|
||||
/>`, so its `absolute inset-0` shares the surface's coordinate space and
|
||||
its ground line lands exactly at the surface's bottom edge (the taskbar's
|
||||
top edge).
|
||||
|
||||
## Sprite system (`types.ts`, `palette.ts`, `sprites.ts`, `render.ts`)
|
||||
|
||||
Frames are human-editable string pixel-grids indexing a palette, e.g.:
|
||||
|
||||
```ts
|
||||
export type PixelGrid = string[] // rows of same-length strings, one char per pixel
|
||||
export interface AnimDef { frames: PixelGrid[]; fps: number; loop: boolean }
|
||||
export type AnimName =
|
||||
| 'egg-idle' | 'egg-wiggle' | 'egg-crack' | 'hatch'
|
||||
| 'idle' | 'blink' | 'walk' | 'peck' | 'flap' | 'sleep'
|
||||
| 'dragged' | 'fall-flutter' | 'land'
|
||||
| 'react-eureka' | 'react-alarm' | 'react-think' | 'react-happy'
|
||||
```
|
||||
|
||||
- Grids: egg 12×12, chick 14×14, adult 16×16, all bottom-anchored inside a
|
||||
fixed 20×20 logical canvas so feet land on the ground line consistently
|
||||
across stages.
|
||||
- `SPRITES: Record<MascotStage, Partial<Record<AnimName, AnimDef>>>` is
|
||||
the registry; `resolveAnim(stage, name)` falls back to that stage's
|
||||
`idle` and finally a 1-frame placeholder, so a missing animation never
|
||||
crashes the renderer.
|
||||
- Canvas is sized to the logical grid; screen scale is pure CSS (`width:
|
||||
20*SCALE px; image-rendering: pixelated`), `ctx.imageSmoothingEnabled =
|
||||
false` set once. Horizontal facing flip via `ctx.translate(w,0);
|
||||
ctx.scale(-1,1)` — no mirrored frame data needed.
|
||||
- Initial animation set (2–4 frames each): egg-idle/egg-wiggle/egg-crack/
|
||||
hatch; idle/blink/walk/peck/flap/sleep; dragged/fall-flutter/land;
|
||||
react-think/react-eureka/react-alarm/react-happy.
|
||||
- Frame index = `floor((now - animStart) / 1000 * fps)`, wrapped if
|
||||
`loop`.
|
||||
|
||||
## Behavior engine (`behavior.ts`)
|
||||
|
||||
```ts
|
||||
export interface MascotRuntime {
|
||||
x: number; y: number // sprite bottom-center, surface coords
|
||||
vx: number; vy: number
|
||||
facing: 1 | -1
|
||||
behavior: BehaviorId // 'egg' | 'idle' | 'wander' | 'peck' | 'sleep' | 'dragged' | 'falling' | 'react'
|
||||
behaviorUntil: number
|
||||
anim: AnimName
|
||||
animStart: number
|
||||
reactAnim: AnimName | null
|
||||
bounds: { w: number; h: number }
|
||||
}
|
||||
|
||||
export interface BehaviorDef {
|
||||
id: BehaviorId
|
||||
anim: (rt: MascotRuntime, model: MascotModel) => AnimName
|
||||
enter?: (rt: MascotRuntime) => void
|
||||
tick: (rt: MascotRuntime, dt: number, now: number) => void
|
||||
next: (rt: MascotRuntime, now: number) => BehaviorId | null
|
||||
weight?: number // idle-selectable when > 0; undefined/0 = not auto-picked
|
||||
minMs: number; maxMs: number
|
||||
}
|
||||
export const BEHAVIORS: Record<BehaviorId, BehaviorDef>
|
||||
export function stepMascot(rt: MascotRuntime, model: MascotModel, now: number, dt: number): void
|
||||
export function forceBehavior(rt: MascotRuntime, id: BehaviorId, opts?: { anim?: AnimName; durationMs?: number }): void
|
||||
```
|
||||
|
||||
- **Ground/gravity**: `GROUND_Y = bounds.h`. When above ground and not
|
||||
dragged, behavior is `falling`: `vy += GRAVITY * dt`, capped at a slow
|
||||
flutter terminal velocity, anim `fall-flutter` with occasional `flap`;
|
||||
on reaching ground, snap `y`, brief `land`, then `idle`.
|
||||
- **Wander**: constant `vx = facing * ~40px/s`, flip `facing` at the
|
||||
surface margins.
|
||||
- **Idle selection**: when `now > behaviorUntil` and the current
|
||||
behavior's `next()` returns null, roll a weighted random pick over
|
||||
`BEHAVIORS` entries that declare `weight` — starting weights: idle 3,
|
||||
wander 4, peck 2, sleep 1.
|
||||
- **Non-self-selecting behaviors** (`dragged`, `falling`, `react`) have no
|
||||
`weight` and are entered only via `forceBehavior()` — pointer code calls
|
||||
it for `dragged`, gravity logic for `falling`, the stimulus bus for
|
||||
`react`.
|
||||
- **Egg stage**: `behavior` locked to `'egg'` (periodic `egg-wiggle`,
|
||||
`egg-crack` as `hatchProgress` nears 1); dragging is still allowed (the
|
||||
egg can be picked up and moved).
|
||||
- Loop lives in `Mascot.svelte`: `setTimeout(() => tick(performance.now()),
|
||||
33)` inside an `$effect`, cleared on teardown, `dt` clamped to 100ms.
|
||||
|
||||
## Tamagotchi model (`state.svelte.ts`)
|
||||
|
||||
```ts
|
||||
export type MascotStage = 'egg' | 'chick' | 'adult'
|
||||
export interface MascotModel {
|
||||
version: 1
|
||||
stage: MascotStage
|
||||
name: string | null
|
||||
hatchProgress: number // 0..1, egg stage only
|
||||
happiness: number // 0..100, slow decay, boosted by pet/feed
|
||||
xp: number // chick -> adult growth hook
|
||||
hatchedAt: number | null
|
||||
lastPos: { x: number } | null
|
||||
lastSeen: number // for capped offline egg-incubation progress
|
||||
}
|
||||
export const HATCH_MS = 3 * 60_000 // active time to hatch (demo-friendly)
|
||||
export const ADULT_XP = 200
|
||||
export function grantXp(n: number): void
|
||||
export function feed(): void
|
||||
export function pet(): void
|
||||
export function setName(name: string): void
|
||||
export function tickLifecycle(dt: number): void // called ~1x/sec, not per frame
|
||||
export function advanceStageIfReady(): void
|
||||
```
|
||||
|
||||
- `load()` parses `localStorage['oikos-mascot']`, checks `version`, falls
|
||||
back to `defaultModel()` on mismatch/corruption. `migrate(raw):
|
||||
MascotModel` is a stub switch on `version` for future schema changes —
|
||||
v1 has no migrations to perform, the stub just documents where they go.
|
||||
- Every mutator calls a shared `schedulePersist()` — a 300ms trailing
|
||||
debounce, plus a `beforeunload` flush so a quick reload doesn't lose a
|
||||
rename. `lastPos.x` is written only on behavior transitions and
|
||||
drag-end, never per frame.
|
||||
- Multi-tab races (two tabs both writing `oikos-mascot`) are
|
||||
last-writer-wins — accepted for this scaffolding, not solved; a future
|
||||
pass could listen to the `storage` event if it becomes a real problem.
|
||||
|
||||
## Radial menu (`actions.ts`, `RadialMenu.svelte`)
|
||||
|
||||
```ts
|
||||
export interface RadialAction {
|
||||
id: string
|
||||
label: string
|
||||
icon?: Component // lucide, same convention as the desktop menu
|
||||
visible?: (model: MascotModel) => boolean // e.g. Rename only once hatched
|
||||
children?: RadialAction[]
|
||||
action?: (ctx: MascotActionCtx) => void // leaf only
|
||||
}
|
||||
export const MASCOT_ACTIONS: RadialAction[]
|
||||
export function registerMascotAction(a: RadialAction, parentId?: string): void
|
||||
```
|
||||
|
||||
v1 tree: **Interact** [Pet, Feed → [Seeds, Worm]], **Care** [Sleep, Wake],
|
||||
**Identity** [Rename], **Debug** [Force hatch/stage, Reset].
|
||||
|
||||
- Rendered by `MascotLayer.svelte` as `fixed`, positioned at the
|
||||
chicken's screen center, **`z-[60]`** (must beat the desktop context
|
||||
menu's `z-50`, comfortably above `WindowLayer`'s `z-40`).
|
||||
- Layout: items on a circle (radius ≈ 70px) via polar `transform`s around
|
||||
the menu's anchor point. Open animation: buttons start scale-0 at
|
||||
center and transition to their polar position with `transform 120ms
|
||||
cubic-bezier(.2,1.4,.4,1)`, staggered ~20ms per item — pure CSS, no
|
||||
keyframes, reads as snappy/springy per the "snappy" requirement.
|
||||
- **Nesting**: selecting a node with `children` swaps the ring's contents
|
||||
to those children plus a center "back" button; track the breadcrumb as
|
||||
a local `$state<RadialAction[][]>` stack.
|
||||
- Dismissal mirrors the desktop menu's existing pattern:
|
||||
`<svelte:window onclick={close}>`, Escape pops one level then closes on
|
||||
the next press; the menu's own clicks `stopPropagation()`. Clamp the
|
||||
ring's screen position so it never renders off-viewport (relevant near
|
||||
screen edges/corners).
|
||||
- Opened from `Mascot.svelte`'s `oncontextmenu`:
|
||||
`e.preventDefault(); e.stopPropagation();` then tell `MascotLayer` to
|
||||
open at the sprite's center (the surface's own `onSurfaceContextMenu`
|
||||
already gates on `currentTarget === target`, so this is defensive, not
|
||||
strictly required — but keep it for clarity).
|
||||
|
||||
## Stimulus / reaction system (`stimuli.ts`)
|
||||
|
||||
```ts
|
||||
export interface ReactionDef {
|
||||
id: string
|
||||
anim: AnimName
|
||||
priority: number
|
||||
cooldownMs: number
|
||||
durationMs: number
|
||||
interruptsSleep?: boolean
|
||||
effect?: () => void // e.g. grantXp(5) on eureka
|
||||
}
|
||||
export const REACTIONS: Record<string, ReactionDef>
|
||||
export function attachStimuli(emit: (r: ReactionDef) => void): () => void // ref-counted, owns subscribeEvents()
|
||||
```
|
||||
|
||||
Initial wiring:
|
||||
|
||||
| Source | Trigger | Reaction |
|
||||
|---|---|---|
|
||||
| `chat.ts` `streaming` | `false → true` edge, held while `true` | `thinking` (`react-think`, priority 1) |
|
||||
| `activity.ts` `activityLog` | new entry with `type === 'knowledge'`, detected by diffing entry ids against the last-seen set (see note above — the store is recomputed wholesale) | `eureka` (`react-eureka`, priority 2, cooldown 10s, `effect: grantXp(5)`) |
|
||||
| `events.ts` `liveEvents` | new head event (`id > lastSeen`) with `severity === 'critical'` or `type` starting `signal.` | `alarmed` (`react-alarm`, priority 3, cooldown 15s, `interruptsSleep: true`) |
|
||||
| `events.ts` `liveEvents` | new head event, `type` starting `execution.`, success-ish | `happy` (`react-happy`, priority 1, cooldown 20s) |
|
||||
|
||||
- `attachStimuli` calls `subscribeEvents()` itself and folds its
|
||||
unsubscribe into the returned teardown, so the mascot keeps the SSE
|
||||
stream open (ref-counted alongside any page that also subscribes) only
|
||||
while mounted.
|
||||
- On first emission of `liveEvents`, just record the head event id — do
|
||||
not replay history as reactions on mount.
|
||||
- Dispatch: `emit(reaction)` checks the cooldown map and
|
||||
`priority >= currentReactPriority` (or current behavior isn't
|
||||
`dragged`), then calls `forceBehavior(rt, 'react', { anim,
|
||||
durationMs })`. `dragged` always wins over any reaction; `sleep` is
|
||||
broken only when `interruptsSleep` is true.
|
||||
|
||||
## Implementation order (sized for one PR each)
|
||||
|
||||
1. `types.ts`, `palette.ts`, `sprites.ts` (egg + chick idle/walk only),
|
||||
`render.ts` — pure data/functions, no UI yet.
|
||||
2. `state.svelte.ts` model + debounced persistence — verify by hand via
|
||||
devtools console before wiring any UI.
|
||||
3. `behavior.ts` FSM (egg/idle/wander/falling/dragged) + `Mascot.svelte` +
|
||||
`MascotLayer.svelte`, insert into `Desktop.svelte`. **First visible
|
||||
milestone** — an egg sits on the ground and can be dragged.
|
||||
4. Hatch flow: `tickLifecycle` wired into the loop, egg→chick transition +
|
||||
`NameDialog.svelte`, remaining animations, `peck`/`sleep` behaviors.
|
||||
5. `actions.ts` + `RadialMenu.svelte` (nested rings, open animation,
|
||||
dismissal).
|
||||
6. `stimuli.ts` + the four reaction animations + the wiring table above.
|
||||
7. Adult stage sprites + XP threshold; polish (speech/name bubble, a
|
||||
squash frame on `land`).
|
||||
8. A short "how to add an animation / behavior / action / reaction" doc
|
||||
comment at the top of `sprites.ts`, `behavior.ts`, `actions.ts`, and
|
||||
`stimuli.ts` respectively (this plan's registry tables above become
|
||||
those comments, condensed).
|
||||
|
||||
## Verification checklist
|
||||
|
||||
Run `npm run dev` in `web/`, then in the browser:
|
||||
|
||||
- Egg renders on the ground at the surface bottom, wiggles occasionally,
|
||||
and is at the same `x` after a reload (`oikos-mascot` in localStorage —
|
||||
confirm it is *not* being written on every frame while merely idling or
|
||||
walking, only on discrete transitions).
|
||||
- Dragging the egg up and releasing triggers a flutter-fall back down with
|
||||
no tunneling below the taskbar; dragging past the surface's left/right
|
||||
edges clamps rather than escaping the viewport.
|
||||
- The debug "force hatch" action transitions egg → chick, opens the name
|
||||
dialog, and the chosen name persists across a reload.
|
||||
- The chick wanders and flips its sprite at the surface edges, pecks, and
|
||||
sleeps on its own; a plain click (below the 5px drag threshold) triggers
|
||||
a pet/hop reaction and grants a little xp.
|
||||
- Right-clicking the chicken opens the radial menu centered on it — and
|
||||
right-clicking bare desktop elsewhere still opens the *original* desktop
|
||||
menu, unaffected. The nested Feed submenu opens; Escape pops one level
|
||||
then closes on the next press; clicking outside the menu closes it;
|
||||
the menu stays fully on-screen when the chicken is near a corner.
|
||||
- With a maximized window open, the chicken visibly walks above it; window
|
||||
drag/resize/close still work normally when the chicken merely passes
|
||||
under the cursor (not when it's directly over a button being clicked —
|
||||
known, accepted overlap per the "renders above windows" decision).
|
||||
- Sending a chat message and watching it stream triggers the `thinking`
|
||||
animation for the duration; simulate (or trigger for real) a
|
||||
knowledge-graph write and confirm `eureka` fires once and respects its
|
||||
cooldown on a second write; simulate a critical signal and confirm
|
||||
`alarmed` fires even while the chicken is asleep.
|
||||
- Resizing the browser viewport re-grounds the chicken and keeps it
|
||||
within the new bounds.
|
||||
- Both the Terracotta and Carbon themes keep the pixel palette legible.
|
||||
- `npm run build` passes with no new errors or warnings beyond the
|
||||
pre-existing baseline.
|
||||
|
||||
## Risks
|
||||
|
||||
- Z-index ordering is easy to get subtly wrong: radial menu must be
|
||||
`z-[60]` to beat the desktop context menu's `z-50`; the mascot layer
|
||||
itself is `z-45` (above `WindowLayer`'s `z-40`, below both menus).
|
||||
- The mascot rendering above windows means it can occlude/steal clicks on
|
||||
window chrome directly beneath it — accepted per the "above windows"
|
||||
decision; mitigate by keeping the pointer hitbox tight to the canvas
|
||||
element only (no oversized invisible padding).
|
||||
- `activityLog` is a derived store recomputed wholesale on every
|
||||
emission, not an append-only log — any "new entry" detection must diff
|
||||
entry ids between emissions, never assume the store only ever grows by
|
||||
appending.
|
||||
- `setTimeout`-driven loops can receive large `dt` spikes after tab
|
||||
throttling/backgrounding resumes — clamp `dt` before feeding it into
|
||||
physics or lifecycle ticking.
|
||||
@@ -18,6 +18,7 @@ went sideways, open an investigation.
|
||||
| 2026-07-14 | [Activity timeline](2026-07-14-activity-timeline.md) | In Progress |
|
||||
| 2026-07-17 | [Codebase review, lint audit, and documentation maintenance](2026-07-17-codebase-review-and-cleanup.md) | Report delivered — doc/tooling fixes applied; code refactors pending |
|
||||
| 2026-07-18 | [Session review: three recent sessions](2026-07-18-session-review-three-sessions.md) | Implemented in v0.7.12 — P0.1/P0.2/P1.3/P1.4/P1.5/P1.6/P1.8/P2.10; P1.7 and P2.9 deferred (retry cap covers) |
|
||||
| 2026-07-20 | [Desktop mascot ("Cluck")](2026-07-20-desktop-mascot.md) | Planned — not started |
|
||||
|
||||
## Done
|
||||
|
||||
|
||||
31
web/package-lock.json
generated
31
web/package-lock.json
generated
@@ -13,6 +13,7 @@
|
||||
"d3-force": "^3.0.0",
|
||||
"dompurify": "^3.4.11",
|
||||
"marked": "^18.0.5",
|
||||
"svelte-splitpanes": "^8.0.12",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"uplot": "^1.6.32"
|
||||
},
|
||||
@@ -877,7 +878,6 @@
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
@@ -888,7 +888,6 @@
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
@@ -899,7 +898,6 @@
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@@ -909,14 +907,12 @@
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
@@ -1320,7 +1316,6 @@
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz",
|
||||
"integrity": "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"acorn": "^8.9.0"
|
||||
@@ -1677,7 +1672,6 @@
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
@@ -1691,7 +1685,6 @@
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
@@ -1844,7 +1837,7 @@
|
||||
"version": "8.64.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.64.0.tgz",
|
||||
"integrity": "sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -2066,7 +2059,6 @@
|
||||
"version": "8.17.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
|
||||
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
@@ -2139,7 +2131,6 @@
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
|
||||
"integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -2166,7 +2157,6 @@
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -2548,7 +2538,6 @@
|
||||
"version": "5.8.1",
|
||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz",
|
||||
"integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
@@ -2858,7 +2847,6 @@
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
|
||||
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/espree": {
|
||||
@@ -2896,7 +2884,6 @@
|
||||
"version": "2.2.13",
|
||||
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.13.tgz",
|
||||
"integrity": "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.4.15"
|
||||
@@ -3364,7 +3351,6 @@
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
|
||||
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.6"
|
||||
@@ -3788,7 +3774,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
|
||||
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
@@ -3842,7 +3827,6 @@
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
@@ -4504,7 +4488,6 @@
|
||||
"version": "5.56.4",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz",
|
||||
"integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.4",
|
||||
@@ -4659,6 +4642,15 @@
|
||||
"svelte": "^5.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/svelte-splitpanes": {
|
||||
"version": "8.0.12",
|
||||
"resolved": "https://registry.npmjs.org/svelte-splitpanes/-/svelte-splitpanes-8.0.12.tgz",
|
||||
"integrity": "sha512-HJ07HgbtY0Q/35TEuJquGy47dtgCVavV7ay9r1FhWRx3boyUs3RpeiHlwMKliznCKy2ZotNeT+8GG+mIoNjgRA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"svelte": "^5.43.0"
|
||||
}
|
||||
},
|
||||
"node_modules/svelte-toolbelt": {
|
||||
"version": "0.10.6",
|
||||
"resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz",
|
||||
@@ -6315,7 +6307,6 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
|
||||
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"d3-force": "^3.0.0",
|
||||
"dompurify": "^3.4.11",
|
||||
"marked": "^18.0.5",
|
||||
"svelte-splitpanes": "^8.0.12",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"uplot": "^1.6.32"
|
||||
}
|
||||
|
||||
@@ -62,5 +62,5 @@
|
||||
/>
|
||||
{:else}
|
||||
<Toaster />
|
||||
<Desktop onOpenConnection={() => (configured = false)} />
|
||||
<Desktop />
|
||||
{/if}
|
||||
|
||||
@@ -289,3 +289,32 @@ a {
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* svelte-splitpanes theming (TaskContextPanel, SessionChatWindow rail) —
|
||||
mapped onto the app's border/primary tokens instead of the library's
|
||||
default-theme, so splitters follow the terracotta/dark theme toggle. */
|
||||
.splitpanes.oikos-theme .splitpanes__pane {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.splitpanes.oikos-theme .splitpanes__splitter {
|
||||
background-color: transparent;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.splitpanes.oikos-theme .splitpanes__splitter:hover,
|
||||
.splitpanes.oikos-theme .splitpanes__splitter.splitpanes__splitter__active {
|
||||
background-color: color-mix(in oklab, var(--primary) 30%, transparent);
|
||||
}
|
||||
|
||||
.oikos-theme.splitpanes--horizontal > .splitpanes__splitter {
|
||||
height: 6px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: row-resize;
|
||||
}
|
||||
|
||||
.oikos-theme.splitpanes--vertical > .splitpanes__splitter {
|
||||
width: 6px;
|
||||
border-left: 1px solid var(--border);
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
@@ -15,12 +15,14 @@ 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 DatabaseIcon from '@lucide/svelte/icons/database'
|
||||
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check'
|
||||
import SirenIcon from '@lucide/svelte/icons/siren'
|
||||
import SearchIcon from '@lucide/svelte/icons/search'
|
||||
import TrendingUpIcon from '@lucide/svelte/icons/trending-up'
|
||||
import SettingsIcon from '@lucide/svelte/icons/settings'
|
||||
|
||||
export interface AppDef {
|
||||
id: string
|
||||
@@ -99,6 +101,16 @@ export const APPS: AppDef[] = [
|
||||
height: 600,
|
||||
minWidth: 440,
|
||||
minHeight: 340
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
title: 'Settings',
|
||||
icon: SettingsIcon,
|
||||
component: Settings,
|
||||
width: 640,
|
||||
height: 480,
|
||||
minWidth: 480,
|
||||
minHeight: 360
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
// window (its own per-session store bundle from chat.ts's chatFor) render
|
||||
// through this, so the message-bubble/markdown styling lives in one place
|
||||
// instead of being copy-pasted between the two.
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import { activityLog } from '$lib/stores/activity'
|
||||
import AgentIndicator from '$lib/components/AgentIndicator.svelte'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import { Textarea } from '$lib/components/ui/textarea'
|
||||
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
|
||||
import CornerDownLeftIcon from '@lucide/svelte/icons/corner-down-left'
|
||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw'
|
||||
import SquareIcon from '@lucide/svelte/icons/square'
|
||||
import { marked } from 'marked'
|
||||
@@ -44,6 +45,44 @@
|
||||
let scrolledUp = $state(false)
|
||||
let container = $state<HTMLDivElement | null>(null)
|
||||
|
||||
// Resizable input area — drag the splitter above it to grow the textarea,
|
||||
// capped so it can't swallow the whole thread. Both the minimum and the
|
||||
// default are exactly one line: measured from the textarea's own
|
||||
// line-height/padding/border rather than hardcoded, so it stays correct if
|
||||
// that styling ever changes.
|
||||
let threadHeight = $state(0)
|
||||
let textareaRef = $state<HTMLTextAreaElement | null>(null)
|
||||
let inputWrapperRef = $state<HTMLDivElement | null>(null)
|
||||
let oneLinePx = $state(64)
|
||||
$effect(() => {
|
||||
if (!textareaRef || !inputWrapperRef) return
|
||||
const taCs = getComputedStyle(textareaRef)
|
||||
const lineHeight = parseFloat(taCs.lineHeight)
|
||||
if (!Number.isFinite(lineHeight)) return
|
||||
const taBoxY =
|
||||
parseFloat(taCs.paddingTop) + parseFloat(taCs.paddingBottom) + parseFloat(taCs.borderTopWidth) + parseFloat(taCs.borderBottomWidth)
|
||||
// The wrapper's own padding/border (space around the textarea, not part
|
||||
// of it) also has to fit inside the minimum, or the textarea gets
|
||||
// squeezed below one line once the pane is dragged down to it.
|
||||
const wrapperCs = getComputedStyle(inputWrapperRef)
|
||||
const wrapperBoxY =
|
||||
parseFloat(wrapperCs.paddingTop) +
|
||||
parseFloat(wrapperCs.paddingBottom) +
|
||||
parseFloat(wrapperCs.borderTopWidth) +
|
||||
parseFloat(wrapperCs.borderBottomWidth)
|
||||
oneLinePx = lineHeight + taBoxY + wrapperBoxY
|
||||
})
|
||||
const inputMinSize = $derived(threadHeight > 0 ? (oneLinePx / threadHeight) * 100 : 12)
|
||||
|
||||
let inputSize = $state(12)
|
||||
let inputSizeDefaulted = false
|
||||
$effect(() => {
|
||||
if (!inputSizeDefaulted && threadHeight > 0) {
|
||||
inputSize = inputMinSize
|
||||
inputSizeDefaulted = true
|
||||
}
|
||||
})
|
||||
|
||||
function isNearBottom(): boolean {
|
||||
if (!container) return true
|
||||
const { scrollTop, scrollHeight, clientHeight } = container
|
||||
@@ -87,7 +126,9 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<div class="flex h-full min-h-0 min-w-0 flex-col" bind:clientHeight={threadHeight}>
|
||||
<Splitpanes horizontal theme="oikos-theme" dblClickSplitter={false} class="min-h-0 flex-1">
|
||||
<Pane class="flex flex-col">
|
||||
<div class="min-h-0 flex-1 overflow-y-auto" bind:this={container} onscroll={onScroll}>
|
||||
<div class="mx-auto flex max-w-3xl flex-col gap-5 p-4">
|
||||
{#if messages.length === 0}
|
||||
@@ -169,34 +210,52 @@
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</Pane>
|
||||
|
||||
<div class="border-t bg-card/50 p-3 input-ornament relative">
|
||||
<form
|
||||
class="mx-auto flex max-w-3xl items-end gap-2"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault()
|
||||
submit()
|
||||
}}
|
||||
>
|
||||
<Textarea
|
||||
bind:value={input}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder="Ask Nomos anything…"
|
||||
rows={2}
|
||||
class="max-h-40 min-h-0 resize-none"
|
||||
disabled={streaming}
|
||||
/>
|
||||
{#if streaming}
|
||||
<Button type="button" size="icon" variant="destructive" onclick={onCancel} aria-label="Stop">
|
||||
<SquareIcon />
|
||||
</Button>
|
||||
{:else}
|
||||
<Button type="submit" size="icon" disabled={!input.trim()} aria-label="Send">
|
||||
<ArrowUpIcon />
|
||||
</Button>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
<Pane bind:size={inputSize} minSize={inputMinSize} maxSize={45} class="flex flex-col">
|
||||
<div class="flex h-full min-h-0 flex-col border-t bg-card/50 p-3 input-ornament relative" bind:this={inputWrapperRef}>
|
||||
<form
|
||||
class="relative mx-auto flex h-full w-full max-w-3xl"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault()
|
||||
submit()
|
||||
}}
|
||||
>
|
||||
<Textarea
|
||||
bind:ref={textareaRef}
|
||||
bind:value={input}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder="Ask Nomos anything…"
|
||||
class="h-full max-h-none min-h-0 resize-none rounded-2xl px-4 py-3 pr-12 field-sizing-fixed"
|
||||
disabled={streaming}
|
||||
/>
|
||||
{#if streaming}
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="secondary"
|
||||
class="absolute right-2 bottom-2 rounded-lg"
|
||||
onclick={onCancel}
|
||||
aria-label="Stop"
|
||||
>
|
||||
<SquareIcon class="size-3.5" />
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon-sm"
|
||||
variant="secondary"
|
||||
class="absolute right-2 bottom-2 rounded-lg"
|
||||
disabled={!input.trim()}
|
||||
aria-label="Send"
|
||||
>
|
||||
<CornerDownLeftIcon class="size-3.5" />
|
||||
</Button>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// these can be open (and independently live) at once without the "which
|
||||
// one's on screen" guarding the main page's singleton stores need.
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import { chatFor, loadSessionChat, sendSessionMessage, cancelSessionStream, stopSessionPolling, dismissError, chatErrors } from '$lib/stores/chat'
|
||||
import ChatThread from '$lib/components/ChatThread.svelte'
|
||||
import TaskContextPanel from '$lib/components/TaskContextPanel.svelte'
|
||||
@@ -30,29 +31,9 @@
|
||||
|
||||
onDestroy(() => stopSessionPolling(sessionId))
|
||||
|
||||
// Resizable right rail — same behavior as Chat.svelte's, sized smaller by
|
||||
// default since task windows open narrower than the full page.
|
||||
const RAIL_MIN = 220
|
||||
const RAIL_MAX = 480
|
||||
let railWidth = $state(260)
|
||||
let resizing = $state(false)
|
||||
|
||||
function startResize(e: PointerEvent) {
|
||||
e.preventDefault()
|
||||
resizing = true
|
||||
const startX = e.clientX
|
||||
const startW = railWidth
|
||||
function move(ev: PointerEvent) {
|
||||
railWidth = Math.min(RAIL_MAX, Math.max(RAIL_MIN, startW + (startX - ev.clientX)))
|
||||
}
|
||||
function up() {
|
||||
resizing = false
|
||||
window.removeEventListener('pointermove', move)
|
||||
window.removeEventListener('pointerup', up)
|
||||
}
|
||||
window.addEventListener('pointermove', move)
|
||||
window.addEventListener('pointerup', up)
|
||||
}
|
||||
// Resizable right rail — sized smaller by default since task windows open
|
||||
// narrower than the full page.
|
||||
let railSize = $state(24)
|
||||
</script>
|
||||
|
||||
<div class="flex h-full min-h-0">
|
||||
@@ -64,34 +45,23 @@
|
||||
<p class="text-xs text-muted-foreground/70">It may have been deleted.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<ChatThread
|
||||
messages={$chatMessages}
|
||||
streaming={$chatStreaming}
|
||||
connectionState={$chatConnectionState}
|
||||
error={$chatError}
|
||||
chatErrors={$chatErrors}
|
||||
onSend={(text) => sendSessionMessage(sessionId, text)}
|
||||
onCancel={() => cancelSessionStream(sessionId)}
|
||||
onReconnect={() => loadSessionChat(sessionId)}
|
||||
onDismissError={dismissError}
|
||||
/>
|
||||
|
||||
<div class="flex shrink-0" style="width: {railWidth}px">
|
||||
<button
|
||||
type="button"
|
||||
class="group/rz relative w-1.5 shrink-0 cursor-col-resize touch-none"
|
||||
onpointerdown={startResize}
|
||||
aria-label="Resize task panel"
|
||||
>
|
||||
<span
|
||||
class="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 transition-colors {resizing
|
||||
? 'bg-primary/60'
|
||||
: 'bg-border group-hover/rz:bg-primary/50'}"
|
||||
></span>
|
||||
</button>
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<Splitpanes theme="oikos-theme" dblClickSplitter={false}>
|
||||
<Pane>
|
||||
<ChatThread
|
||||
messages={$chatMessages}
|
||||
streaming={$chatStreaming}
|
||||
connectionState={$chatConnectionState}
|
||||
error={$chatError}
|
||||
chatErrors={$chatErrors}
|
||||
onSend={(text) => sendSessionMessage(sessionId, text)}
|
||||
onCancel={() => cancelSessionStream(sessionId)}
|
||||
onReconnect={() => loadSessionChat(sessionId)}
|
||||
onDismissError={dismissError}
|
||||
/>
|
||||
</Pane>
|
||||
<Pane bind:size={railSize} minSize={18} maxSize={40}>
|
||||
<TaskContextPanel {sessionId} />
|
||||
</div>
|
||||
</div>
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import { startWorkspace, startSessionWorkspace, planSteps, currentTask, openQuestion, touched, healthDiffs, workspaceFor, taskFor } from '$lib/stores/workspace'
|
||||
import { streaming, messages, currentSession, chatFor } from '$lib/stores/chat'
|
||||
import { activityLog, activityLogFor } from '$lib/stores/activity'
|
||||
@@ -44,39 +45,27 @@
|
||||
let planOpen = $state(true)
|
||||
let activityOpen = $state(true)
|
||||
|
||||
// Resize: distribute height across the 3 content areas.
|
||||
// Heights stored in px, minus header sizes. Default even split.
|
||||
let heights = $state([300, 200, 200])
|
||||
let resizing = $state(-1)
|
||||
let resizeStartY = $state(0)
|
||||
let resizeStartH = $state<[number, number]>([0, 0])
|
||||
// Resize: each section is a Pane in one vertical Splitpanes. Sizes are
|
||||
// percentages of the panel's height; undefined means "share the space
|
||||
// evenly with the other auto sections". Collapsing a section pins it to
|
||||
// COLLAPSED_SIZE (roughly a header's worth of height) and remembers its
|
||||
// last size so reopening restores it.
|
||||
const COLLAPSED_SIZE = 6
|
||||
const OPEN_MIN_SIZE = 12
|
||||
let sizes = $state<(number | undefined)[]>([undefined, undefined, undefined])
|
||||
// Reopening must restore a concrete number, never `undefined` — the pane
|
||||
// only re-triggers the library's resize/equalize pass when `size` changes
|
||||
// to a different *number*, so setting it back to `undefined` silently
|
||||
// no-ops and leaves the section stuck at its collapsed height.
|
||||
let savedSizes: number[] = [34, 33, 33]
|
||||
|
||||
function onPointerDown(i: number, e: PointerEvent) {
|
||||
e.preventDefault()
|
||||
resizing = i
|
||||
resizeStartY = e.clientY
|
||||
resizeStartH = [heights[i], heights[i + 1]]
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
window.addEventListener('pointerup', onPointerUp)
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (resizing < 0) return
|
||||
const dy = e.clientY - resizeStartY
|
||||
const minH = 80
|
||||
// Clamp each section to minimum
|
||||
const newA = Math.max(minH, resizeStartH[0] + dy)
|
||||
const newB = Math.max(minH, resizeStartH[1] - dy)
|
||||
const newHeights = [...heights]
|
||||
newHeights[resizing] = newA
|
||||
newHeights[resizing + 1] = newB
|
||||
heights = newHeights
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
resizing = -1
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
function toggleSection(i: number, isOpen: boolean) {
|
||||
if (isOpen) {
|
||||
savedSizes[i] = sizes[i] ?? savedSizes[i]
|
||||
sizes[i] = COLLAPSED_SIZE
|
||||
} else {
|
||||
sizes[i] = savedSizes[i]
|
||||
}
|
||||
}
|
||||
|
||||
// Plan collapsed status
|
||||
@@ -103,54 +92,54 @@
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<OperatorQuestion sessionId={effectiveSessionId} question={$openQuestionStore} />
|
||||
|
||||
<!-- Scope -->
|
||||
<div class="flex shrink-0 flex-col border-b">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground"
|
||||
onclick={() => (scopeOpen = !scopeOpen)}
|
||||
>
|
||||
{#if scopeOpen}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon class="size-3" />{/if}
|
||||
<span>Scope</span>
|
||||
{#if !scopeOpen}
|
||||
<span class="ml-auto font-normal normal-case">{$touchedStore.length ? `${$touchedStore.length} entit${$touchedStore.length === 1 ? 'y' : 'ies'}` : 'Graph'}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if scopeOpen}
|
||||
<div style="height: {heights[0]}px">
|
||||
<SessionGraph messages={$messagesStore} touched={$touchedStore} healthDiffs={$healthDiffsStore} />
|
||||
</div>
|
||||
<!-- resize handle -->
|
||||
<div
|
||||
class="h-1.5 cursor-row-resize border-b hover:bg-primary/30 touch-none"
|
||||
onpointerdown={(e) => onPointerDown(0, e)}
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Plan -->
|
||||
<div class="flex shrink-0 flex-col border-b">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground"
|
||||
onclick={() => (planOpen = !planOpen)}
|
||||
>
|
||||
{#if planOpen}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon class="size-3" />{/if}
|
||||
<span>Plan</span>
|
||||
{#if !planOpen}
|
||||
{#if planTotal > 0}
|
||||
<span class="ml-auto font-normal normal-case">Step {planDone}/{planTotal}</span>
|
||||
{:else if $taskStore?.goal}
|
||||
<span class="ml-auto max-w-[120px] truncate font-normal normal-case">{$taskStore.goal}</span>
|
||||
{:else}
|
||||
<span class="ml-auto font-normal normal-case text-muted-foreground">No plan yet</span>
|
||||
<Splitpanes horizontal theme="oikos-theme" dblClickSplitter={false} class="min-h-0 flex-1">
|
||||
<!-- Scope -->
|
||||
<Pane bind:size={sizes[0]} minSize={scopeOpen ? OPEN_MIN_SIZE : COLLAPSED_SIZE} maxSize={scopeOpen ? 100 : COLLAPSED_SIZE} class="flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
class="flex shrink-0 items-center gap-1.5 px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground"
|
||||
onclick={() => {
|
||||
toggleSection(0, scopeOpen)
|
||||
scopeOpen = !scopeOpen
|
||||
}}
|
||||
>
|
||||
{#if scopeOpen}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon class="size-3" />{/if}
|
||||
<span>Scope</span>
|
||||
{#if !scopeOpen}
|
||||
<span class="ml-auto font-normal normal-case">{$touchedStore.length ? `${$touchedStore.length} entit${$touchedStore.length === 1 ? 'y' : 'ies'}` : 'Graph'}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if scopeOpen}
|
||||
<div class="min-h-0 flex-1">
|
||||
<SessionGraph messages={$messagesStore} touched={$touchedStore} healthDiffs={$healthDiffsStore} />
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
{#if planOpen}
|
||||
<div style="height: {heights[1]}px" class="flex flex-col overflow-y-auto">
|
||||
</Pane>
|
||||
|
||||
<!-- Plan -->
|
||||
<Pane bind:size={sizes[1]} minSize={planOpen ? OPEN_MIN_SIZE : COLLAPSED_SIZE} maxSize={planOpen ? 100 : COLLAPSED_SIZE} class="flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
class="flex shrink-0 items-center gap-1.5 px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground"
|
||||
onclick={() => {
|
||||
toggleSection(1, planOpen)
|
||||
planOpen = !planOpen
|
||||
}}
|
||||
>
|
||||
{#if planOpen}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon class="size-3" />{/if}
|
||||
<span>Plan</span>
|
||||
{#if !planOpen}
|
||||
{#if planTotal > 0}
|
||||
<span class="ml-auto font-normal normal-case">Step {planDone}/{planTotal}</span>
|
||||
{:else if $taskStore?.goal}
|
||||
<span class="ml-auto max-w-[120px] truncate font-normal normal-case">{$taskStore.goal}</span>
|
||||
{:else}
|
||||
<span class="ml-auto font-normal normal-case text-muted-foreground">No plan yet</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</button>
|
||||
{#if planOpen}
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
{#if $taskStore?.goal}
|
||||
<div class="flex items-start gap-2 px-3 py-2">
|
||||
<MilestoneIcon class="mt-0.5 size-3 shrink-0 text-primary" />
|
||||
@@ -244,38 +233,35 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- resize handle -->
|
||||
<div
|
||||
class="h-1.5 cursor-row-resize border-b hover:bg-primary/30 touch-none"
|
||||
onpointerdown={(e) => onPointerDown(1, e)}
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Activity -->
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<button
|
||||
type="button"
|
||||
class="flex shrink-0 items-center gap-1.5 px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground"
|
||||
onclick={() => (activityOpen = !activityOpen)}
|
||||
>
|
||||
{#if activityOpen}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon class="size-3" />{/if}
|
||||
<span>Event log</span>
|
||||
{#if !activityOpen}
|
||||
{#if $streamingStore && activityRunning > 0}
|
||||
<Spinner class="size-3 text-primary" />
|
||||
<span class="font-normal normal-case text-primary">{activityRunning} running</span>
|
||||
{:else}
|
||||
<span class="ml-auto font-normal normal-case">{activityCount || '—'} action{activityCount === 1 ? '' : 's'}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</button>
|
||||
{#if activityOpen}
|
||||
<div class="min-h-0 flex-1 overflow-hidden">
|
||||
<ActivityTimeline entries={$activityLogStore} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Pane>
|
||||
|
||||
<!-- Activity -->
|
||||
<Pane bind:size={sizes[2]} minSize={activityOpen ? OPEN_MIN_SIZE : COLLAPSED_SIZE} maxSize={activityOpen ? 100 : COLLAPSED_SIZE} class="flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
class="flex shrink-0 items-center gap-1.5 px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground"
|
||||
onclick={() => {
|
||||
toggleSection(2, activityOpen)
|
||||
activityOpen = !activityOpen
|
||||
}}
|
||||
>
|
||||
{#if activityOpen}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon class="size-3" />{/if}
|
||||
<span>Event log</span>
|
||||
{#if !activityOpen}
|
||||
{#if $streamingStore && activityRunning > 0}
|
||||
<Spinner class="size-3 text-primary" />
|
||||
<span class="font-normal normal-case text-primary">{activityRunning} running</span>
|
||||
{:else}
|
||||
<span class="ml-auto font-normal normal-case">{activityCount || '—'} action{activityCount === 1 ? '' : 's'}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</button>
|
||||
{#if activityOpen}
|
||||
<div class="min-h-0 flex-1 overflow-hidden">
|
||||
<ActivityTimeline entries={$activityLogStore} />
|
||||
</div>
|
||||
{/if}
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
</div>
|
||||
|
||||
@@ -21,8 +21,6 @@
|
||||
import Undo2Icon from '@lucide/svelte/icons/undo-2'
|
||||
import Redo2Icon from '@lucide/svelte/icons/redo-2'
|
||||
|
||||
let { onOpenConnection }: { onOpenConnection: () => void } = $props()
|
||||
|
||||
// Clicking the bare desktop (not an icon, not a window) blurs the focused
|
||||
// window — the familiar "click empty desktop to deselect" affordance.
|
||||
function onSurfaceClick(e: MouseEvent) {
|
||||
@@ -104,7 +102,7 @@
|
||||
<WindowLayer />
|
||||
</div>
|
||||
|
||||
<Taskbar {onOpenConnection} />
|
||||
<Taskbar />
|
||||
</div>
|
||||
|
||||
{#if menuPos}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// to the old MinimizedWindowsBar, which only ever showed minimized windows
|
||||
// and gave no way to see/switch between windows that were merely
|
||||
// unfocused), plus a system tray for theme/connection/version.
|
||||
import { wm, wmState, toggleShowDesktop } from '$lib/stores/windows'
|
||||
import { wm, wmState, toggleShowDesktop, openAppWindow } from '$lib/stores/windows'
|
||||
import { appById, appIdFromWindowId } from '$lib/apps'
|
||||
import { summary } from '$lib/stores/context'
|
||||
import { truncateMiddle } from '$lib/utils'
|
||||
@@ -19,8 +19,6 @@
|
||||
import LayoutGridIcon from '@lucide/svelte/icons/layout-grid'
|
||||
import XIcon from '@lucide/svelte/icons/x'
|
||||
|
||||
let { onOpenConnection }: { onOpenConnection: () => void } = $props()
|
||||
|
||||
const SESSION_PREFIX = 'session:'
|
||||
|
||||
const buttons = $derived(
|
||||
@@ -121,8 +119,8 @@
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
onclick={onOpenConnection}
|
||||
title="Server connection settings"
|
||||
onclick={() => openAppWindow('settings')}
|
||||
title="Settings"
|
||||
>
|
||||
<SettingsIcon class="size-4" />
|
||||
</button>
|
||||
|
||||
4
web/src/lib/shims/app-environment.ts
Normal file
4
web/src/lib/shims/app-environment.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Shim for SvelteKit's `$app/environment` module — this is a plain Vite app,
|
||||
// not SvelteKit, but svelte-splitpanes imports `browser` from it for its
|
||||
// browser-detection utility. Aliased in vite.config.ts.
|
||||
export const browser = typeof window !== 'undefined'
|
||||
193
web/src/pages/Settings.svelte
Normal file
193
web/src/pages/Settings.svelte
Normal file
@@ -0,0 +1,193 @@
|
||||
<script lang="ts">
|
||||
// The desktop's Settings window — a generic shell (section list + content
|
||||
// pane) so future settings just add a SECTIONS entry instead of a whole
|
||||
// new window/screen. Connection reuses the same config.ts/oidc.ts calls as
|
||||
// pages/Config.svelte (the full-page initial setup screen), just without
|
||||
// that screen's "first run" framing — this is for changing settings while
|
||||
// already inside the desktop.
|
||||
import { Input } from '$lib/components/ui/input'
|
||||
import { Label } from '$lib/components/ui/label'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import { Separator } from '$lib/components/ui/separator'
|
||||
import { fetchWithAuth, setConfig, initConfig, getConfig, clearConfig, type OikosConfig } from '$lib/config'
|
||||
import { startLogin, logout as oidcLogout, getUser, isOIDCConfigured } from '$lib/oidc'
|
||||
import { getTheme, setTheme, THEME_LABELS, type Theme } from '$lib/stores/theme.svelte'
|
||||
import { VERSION } from '$lib/version'
|
||||
import { toast } from 'svelte-sonner'
|
||||
import PlugIcon from '@lucide/svelte/icons/plug'
|
||||
import PaletteIcon from '@lucide/svelte/icons/palette'
|
||||
import LockIcon from '@lucide/svelte/icons/lock'
|
||||
import LogInIcon from '@lucide/svelte/icons/log-in'
|
||||
import ServerIcon from '@lucide/svelte/icons/server'
|
||||
import CheckIcon from '@lucide/svelte/icons/check'
|
||||
|
||||
const SECTIONS = [
|
||||
{ id: 'connection', label: 'Connection', icon: PlugIcon },
|
||||
{ id: 'appearance', label: 'Appearance', icon: PaletteIcon }
|
||||
] as const
|
||||
type SectionId = (typeof SECTIONS)[number]['id']
|
||||
|
||||
let section = $state<SectionId>('connection')
|
||||
|
||||
const existing = getConfig()
|
||||
let apiUrl = $state(existing.apiUrl ?? '')
|
||||
let token = $state(existing.token ?? '')
|
||||
let saving = $state(false)
|
||||
let error = $state('')
|
||||
let oidcLoggingIn = $state(false)
|
||||
const oidcUser = getUser()
|
||||
const oidcConfigured = isOIDCConfigured()
|
||||
|
||||
async function save() {
|
||||
error = ''
|
||||
if (!token.trim()) {
|
||||
error = 'Token is required'
|
||||
return
|
||||
}
|
||||
saving = true
|
||||
const prev: OikosConfig = getConfig()
|
||||
setConfig({ apiUrl: apiUrl.trim(), token: token.trim() })
|
||||
initConfig({ apiUrl: apiUrl.trim(), token: token.trim() })
|
||||
try {
|
||||
const res = await fetchWithAuth('/api/v1/dashboard/summary')
|
||||
if (!res.ok) {
|
||||
error = res.status === 401 ? 'Invalid token' : `Server responded ${res.status}`
|
||||
setConfig(prev)
|
||||
initConfig(prev)
|
||||
return
|
||||
}
|
||||
toast.success('Connection saved')
|
||||
} catch {
|
||||
error = 'Could not reach server — check the URL'
|
||||
setConfig(prev)
|
||||
initConfig(prev)
|
||||
} finally {
|
||||
saving = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loginWithAuthentik() {
|
||||
error = ''
|
||||
oidcLoggingIn = true
|
||||
setConfig({ apiUrl: apiUrl.trim(), token: '' })
|
||||
initConfig({ apiUrl: apiUrl.trim(), token: '' })
|
||||
try {
|
||||
await startLogin()
|
||||
} catch (e: unknown) {
|
||||
error = e instanceof Error ? e.message : 'OIDC login failed'
|
||||
oidcLoggingIn = false
|
||||
}
|
||||
}
|
||||
|
||||
// Disconnecting invalidates the token every open window's data depends on
|
||||
// — a reload is the simplest way back to a clean state (lands on the
|
||||
// full-page Config screen since isConfigured() is now false).
|
||||
function forgetConnection() {
|
||||
clearConfig()
|
||||
oidcLogout()
|
||||
location.reload()
|
||||
}
|
||||
|
||||
function pickTheme(t: Theme) {
|
||||
setTheme(t)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<nav class="flex w-44 shrink-0 flex-col gap-0.5 border-r bg-muted/20 p-2">
|
||||
{#each SECTIONS as s (s.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-sm transition-colors {section === s.id
|
||||
? 'bg-muted font-medium text-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'}"
|
||||
onclick={() => (section = s.id)}
|
||||
>
|
||||
<s.icon class="size-4 shrink-0" />
|
||||
{s.label}
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto p-6">
|
||||
{#if section === 'connection'}
|
||||
<div class="mx-auto flex max-w-md flex-col gap-5">
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold">Connection</h2>
|
||||
<p class="mt-0.5 text-xs text-muted-foreground">Where the control room talks to your Oikos server.</p>
|
||||
</div>
|
||||
|
||||
{#if oidcUser}
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Signed in via Authentik as <span class="font-medium text-foreground">{oidcUser}</span>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="settings-server-url" class="text-xs font-medium">Server URL</Label>
|
||||
<div class="relative">
|
||||
<ServerIcon class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground/50" />
|
||||
<Input id="settings-server-url" type="url" placeholder="https://oikos.hubris.network" class="pl-9" bind:value={apiUrl} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="button" variant="secondary" disabled={oidcLoggingIn} onclick={loginWithAuthentik} class="w-full gap-2">
|
||||
<LogInIcon class="size-4" />
|
||||
{oidcLoggingIn ? 'Redirecting…' : oidcConfigured ? 'Switch account with Authentik' : 'Login with Authentik'}
|
||||
</Button>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<Separator decorative class="flex-1" />
|
||||
<span class="text-[10px] font-medium uppercase tracking-widest text-muted-foreground/40">or use</span>
|
||||
<Separator decorative class="flex-1" />
|
||||
</div>
|
||||
|
||||
<form class="flex flex-col gap-2.5" onsubmit={(e) => { e.preventDefault(); save() }}>
|
||||
<div class="relative">
|
||||
<LockIcon class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground/50" />
|
||||
<Input type="password" placeholder="Bearer token" class="pl-9" bind:value={token} />
|
||||
</div>
|
||||
<Button type="submit" disabled={saving} class="w-full">
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{#if error}
|
||||
<p class="rounded-lg bg-destructive/10 px-3 py-2 text-center text-sm text-destructive">{error}</p>
|
||||
{/if}
|
||||
|
||||
{#if existing.token || oidcConfigured}
|
||||
<div class="flex justify-center pt-1">
|
||||
<Button type="button" variant="ghost" size="sm" onclick={forgetConnection}>Forget saved connection</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if section === 'appearance'}
|
||||
<div class="mx-auto flex max-w-md flex-col gap-5">
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold">Appearance</h2>
|
||||
<p class="mt-0.5 text-xs text-muted-foreground">Pick the theme for the whole desktop.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each Object.entries(THEME_LABELS) as [id, label] (id)}
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-between rounded-lg border px-3.5 py-2.5 text-left text-sm transition-colors {getTheme() === (id as Theme)
|
||||
? 'border-primary/50 bg-primary/5 text-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted/50'}"
|
||||
onclick={() => pickTheme(id as Theme)}
|
||||
>
|
||||
{label}
|
||||
{#if getTheme() === (id as Theme)}<CheckIcon class="size-4 text-primary" />{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0 border-t px-3 py-1.5 text-right text-[11px] text-muted-foreground">Oikos {VERSION}</div>
|
||||
</div>
|
||||
@@ -3,6 +3,7 @@ import type { ProxyOptions } from 'vite'
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { readFileSync, existsSync } from 'fs'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
// In Docker, VERSION is copied into the build WORKDIR (/build/web/VERSION).
|
||||
@@ -45,12 +46,27 @@ export default defineConfig({
|
||||
__OIKOS_DEV_TOKEN__: JSON.stringify(process.env.OIKOS_API_TOKEN ?? ''),
|
||||
},
|
||||
resolve: {
|
||||
alias: { $lib: '/src/lib' }
|
||||
alias: {
|
||||
$lib: '/src/lib',
|
||||
// svelte-splitpanes imports SvelteKit's browser-detection module; this
|
||||
// isn't a SvelteKit app, so point it at a plain shim (see the file).
|
||||
// Needs a real filesystem path (not the /src/... shorthand $lib uses)
|
||||
// so esbuild's dependency pre-bundler can resolve it too.
|
||||
'$app/environment': fileURLToPath(new URL('./src/lib/shims/app-environment.ts', import.meta.url))
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true
|
||||
},
|
||||
optimizeDeps: {
|
||||
// svelte-splitpanes imports SvelteKit's $app/environment (aliased above
|
||||
// to a shim), but esbuild's dependency pre-bundler resolves that
|
||||
// differently and fails before the dev server even starts — skip
|
||||
// pre-bundling it so it goes through Vite's normal (alias-aware)
|
||||
// transform pipeline instead.
|
||||
exclude: ['svelte-splitpanes']
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': authProxy('http://localhost:8090'),
|
||||
|
||||
Reference in New Issue
Block a user