@chanlerdev/scorel 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +110 -0
- package/dist/index.js +6675 -0
- package/dist/index.js.map +7 -0
- package/docs/CHANGELOG.md +12 -0
- package/docs/README.md +116 -0
- package/docs/ROADMAP.md +669 -0
- package/docs/SHIP.md +242 -0
- package/docs/spec/channels.md +156 -0
- package/docs/spec/client.md +326 -0
- package/docs/spec/daemon.md +408 -0
- package/docs/spec/events.md +423 -0
- package/docs/spec/extensions.md +255 -0
- package/docs/spec/relay.md +391 -0
- package/docs/spec/runtime.md +251 -0
- package/docs/spec/session.md +380 -0
- package/docs/spec/ship/S0001-docs-baseline.md +41 -0
- package/docs/spec/ship/S0002-package-skeleton.md +56 -0
- package/docs/spec/ship/S0003-protocol-contracts.md +49 -0
- package/docs/spec/ship/S0004-session-core.md +50 -0
- package/docs/spec/ship/S0005-runtime-loop.md +48 -0
- package/docs/spec/ship/S0006-embedded-daemon-client.md +51 -0
- package/docs/spec/ship/S0007-cli-alpha.md +49 -0
- package/docs/spec/ship/S0008-coding-tools.md +107 -0
- package/docs/spec/ship/S0009-code-discovery-tools.md +82 -0
- package/docs/spec/ship/S0010-todo-tool-and-cli.md +81 -0
- package/docs/spec/ship/S0011-coding-agent-alpha-smoke.md +110 -0
- package/docs/spec/ship/S0012-coding-tools-maturity.md +143 -0
- package/docs/spec/ship/S0013-local-daemon-protocol.md +57 -0
- package/docs/spec/ship/S0014-local-daemon-lifecycle.md +64 -0
- package/docs/spec/ship/S0015-local-attach-and-broadcast.md +58 -0
- package/docs/spec/ship/S0016-local-daemon-resync-smoke.md +60 -0
- package/docs/spec/ship/S0017-grep-files-output-mode.md +49 -0
- package/docs/spec/ship/S0018-daemon-entrypoint-smoke.md +48 -0
- package/docs/spec/ship/S0019-remote-transport-contract.md +59 -0
- package/docs/spec/ship/S0020-remote-websocket-server.md +56 -0
- package/docs/spec/ship/S0021-remote-websocket-client-transport.md +55 -0
- package/docs/spec/ship/S0022-remote-daemon-cli-lifecycle.md +60 -0
- package/docs/spec/ship/S0023-remote-control-e2e-validation.md +66 -0
- package/docs/spec/ship/S0024-remote-attach-interactive-stream.md +49 -0
- package/docs/spec/ship/S0025-remote-attach-session-event-view.md +57 -0
- package/docs/spec/ship/S0026-attach-project-cache-and-dual-seq-reconnect.md +87 -0
- package/docs/spec/ship/S0027-session-diagnostics-log.md +77 -0
- package/docs/spec/ship/S0028-client-attach-diagnostics-log.md +70 -0
- package/docs/spec/ship/S0029-project-index-for-session-lookup.md +119 -0
- package/docs/spec/ship/S0030-webui-product-intent.md +73 -0
- package/docs/spec/ship/S0031-daemon-projectslug-rule.md +72 -0
- package/docs/spec/ship/S0032-daemon-protocol-completion.md +123 -0
- package/docs/spec/ship/S0033-webui-skeleton-routing.md +92 -0
- package/docs/spec/ship/S0034-webui-device-settings.md +121 -0
- package/docs/spec/ship/S0035-webui-device-handshake.md +83 -0
- package/docs/spec/ship/S0036-webui-project-session-sync.md +70 -0
- package/docs/spec/ship/S0037-webui-chatbox-v1.md +97 -0
- package/docs/spec/ship/S0038-webui-cancel-multiclient.md +65 -0
- package/docs/spec/ship/S0039-webui-e2e-newchat.md +74 -0
- package/docs/spec/ship/S0040-webui-codex-visual-tokens.md +227 -0
- package/docs/spec/ship/S0041-webui-markdown-and-tool-block.md +248 -0
- package/docs/spec/ship/S0042-webui-streaming-ux-autoscroll.md +130 -0
- package/docs/spec/ship/S0043-startup-ergonomics.md +278 -0
- package/docs/spec/ship/S0044-webui-chatbox-rebuild.md +556 -0
- package/docs/spec/ship/S0045-webui-card-sidebar-and-session-fixes.md +469 -0
- package/docs/spec/ship/S0046-webui-empty-composer-and-lazy-session.md +428 -0
- package/docs/spec/ship/S0047-webui-project-hover-newchat-and-dynamic-greeting.md +176 -0
- package/docs/spec/ship/S0048-device-level-host-project-registry.md +253 -0
- package/docs/spec/ship/S0049-webui-add-project-directory-browser.md +217 -0
- package/docs/spec/ship/S0050-instruction-snapshot-and-agents-assembly.md +338 -0
- package/docs/spec/ship/S0051-harness-item-and-system-reminder.md +190 -0
- package/docs/spec/ship/S0052-follow-up-queue-and-dual-loop.md +195 -0
- package/docs/spec/ship/S0053-skill-index-and-skill-tool.md +252 -0
- package/docs/spec/ship/S0054-webui-running-message-behavior.md +72 -0
- package/docs/spec/ship/S0055-webui-composer-acceptance-and-queue-strip.md +68 -0
- package/docs/spec/ship/S0056-relay-and-hosted-webui-contract.md +106 -0
- package/docs/spec/ship/S0057-relay-service-protocol-skeleton.md +161 -0
- package/docs/spec/ship/S0058-host-outbound-relay-and-pair-command.md +138 -0
- package/docs/spec/ship/S0059-relay-transport-and-hosted-webui-connector.md +140 -0
- package/docs/spec/ship/S0060-relay-hosted-webui-e2e-validation.md +132 -0
- package/docs/spec/ship/S0060-relay-hosted-webui-e2e-validation.verification.md +90 -0
- package/docs/spec/ship/S0061-hosted-defaults-and-cli-command-surface.md +208 -0
- package/docs/spec/ship/S0062-npm-package-and-release-workflow.md +166 -0
- package/docs/spec/tools.md +173 -0
- package/package.json +51 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# S0032: Daemon Protocol Completion (Cancel, ListSessions, ListProjects)
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
Restore and extend the daemon control protocol so M5 WebUI and CLI can:
|
|
6
|
+
|
|
7
|
+
- cancel an in-progress turn from any client
|
|
8
|
+
- list sessions scoped by `projectSlug`
|
|
9
|
+
- list the projects this daemon has served
|
|
10
|
+
|
|
11
|
+
The previous M5 attempt added `cancel` and partial `list_sessions`; both were rolled back in `4ebfabe`. This spec re-introduces them on top of S0031's daemon-owned `projectSlug`, plus a new `list_projects` request that exposes the daemon's project view to clients.
|
|
12
|
+
|
|
13
|
+
## Scope
|
|
14
|
+
|
|
15
|
+
- `@scorel/protocol`:
|
|
16
|
+
- Re-add `cancel` request:
|
|
17
|
+
```ts
|
|
18
|
+
cancel: {
|
|
19
|
+
request: { sessionId: SessionId };
|
|
20
|
+
response: { sessionId: SessionId; cancelled: boolean };
|
|
21
|
+
};
|
|
22
|
+
```
|
|
23
|
+
- Restore real `list_sessions` shape and add `projectSlug` filter:
|
|
24
|
+
```ts
|
|
25
|
+
list_sessions: {
|
|
26
|
+
request: { projectSlug?: string; limit?: number };
|
|
27
|
+
response: { sessions: SessionSummary[] };
|
|
28
|
+
};
|
|
29
|
+
```
|
|
30
|
+
- Extend `SessionSummary` with `projectSlug: string` so callers can group across daemons.
|
|
31
|
+
- New `list_projects` request:
|
|
32
|
+
```ts
|
|
33
|
+
list_projects: {
|
|
34
|
+
request: Record<never, never>;
|
|
35
|
+
response: { projects: DaemonProjectSummary[] };
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
type DaemonProjectSummary = {
|
|
39
|
+
projectSlug: string;
|
|
40
|
+
displayName: string; // basename(workDir)
|
|
41
|
+
workDirHint?: string; // absolute path daemon last saw
|
|
42
|
+
sessionCount: number;
|
|
43
|
+
lastSeenAt: number; // max(updatedAt) over the project's sessions
|
|
44
|
+
};
|
|
45
|
+
```
|
|
46
|
+
- `@scorel/daemon`:
|
|
47
|
+
- Re-implement `#handleCancel` (logic identical to the rolled-back version).
|
|
48
|
+
- Implement `list_sessions`:
|
|
49
|
+
- source: in-memory session lanes plus on-disk `<sessionsDir>/*.jsonl` headers (lazy scan, same dir layout as today)
|
|
50
|
+
- apply `projectSlug` filter when present
|
|
51
|
+
- respect `limit` (default 200, max 1000)
|
|
52
|
+
- sort by `updatedAt` desc, then `sessionId` asc for stability
|
|
53
|
+
- return `currentSeq` from in-memory lane when loaded, else from JSONL header tail
|
|
54
|
+
- Implement `list_projects`:
|
|
55
|
+
- aggregate sessions in `<sessionsDir>/` by reading their headers (cwd in `meta` if present; else fall back to `toProjectSlug(daemon.workDir)`)
|
|
56
|
+
- cache the scan in-memory; invalidate when a new session is created or appended
|
|
57
|
+
- return all projects daemon has touched, even if every session is stale
|
|
58
|
+
- Persist a session header field that pins the slug at session creation time so list_projects/list_sessions stay deterministic across daemon restarts (extend `SessionMeta` with `projectSlug?: string`; daemon writes it; readers prefer the header field).
|
|
59
|
+
- `@scorel/client`:
|
|
60
|
+
- Re-add `DaemonClient.cancel()`.
|
|
61
|
+
- Add `DaemonClient.listSessions(filter?: { projectSlug?: string; limit?: number })`.
|
|
62
|
+
- Add `DaemonClient.listProjects()`.
|
|
63
|
+
- All three thin wrappers around the request/response wire; no business logic.
|
|
64
|
+
- `apps/cli`:
|
|
65
|
+
- CLI `chat` and `attach` keep working unchanged. CLI does not need to call `list_projects` in this spec; `cancel` and `list_sessions` are wired only when CLI features already use them (cancel-on-Ctrl-C exists today and was rolled back too — restore call site).
|
|
66
|
+
- `scorel attach --remote` continues to populate the project-index file via `list_sessions` results (reuses the new shape).
|
|
67
|
+
|
|
68
|
+
## Not In Scope
|
|
69
|
+
|
|
70
|
+
- WebUI consumption (M5.4+).
|
|
71
|
+
- Project-index v2 / cross-device aggregation.
|
|
72
|
+
- Daemon-side session creation API (`create_session` already exists; not changed here).
|
|
73
|
+
- Permission gates / auth scopes around `cancel` (single-user model only).
|
|
74
|
+
- Pagination / cursors for `list_sessions` beyond `limit`.
|
|
75
|
+
|
|
76
|
+
## Acceptance Criteria
|
|
77
|
+
|
|
78
|
+
- Wire schema: `cancel`, `list_sessions`, `list_projects` are present in `packages/protocol/src/wire.ts` exactly as above; round-trip tests in `packages/protocol/src/index.test.ts` cover each.
|
|
79
|
+
- `EmbeddedDaemon`:
|
|
80
|
+
- `cancel` returns `{ sessionId, cancelled: bool }` and writes a `cancel_requested` diagnostic; cancellation actually interrupts a running runtime turn (re-establish the rolled-back behavior, no regressions).
|
|
81
|
+
- `list_sessions({ projectSlug })` returns only sessions matching the slug; `list_sessions()` returns all known sessions; `limit` clamps result size.
|
|
82
|
+
- `list_projects()` returns one entry per distinct slug seen across sessions, with correct `sessionCount`, `lastSeenAt`, and `displayName`.
|
|
83
|
+
- Session JSONL header includes `projectSlug` for new sessions; reading older sessions without that field falls back to `toProjectSlug(daemon.workDir)`.
|
|
84
|
+
- `DaemonClient` exposes `cancel()`, `listSessions()`, `listProjects()`. Cancel and listSessions throw if not connected to a session / daemon respectively; listProjects only requires daemon connection (no session).
|
|
85
|
+
- CLI Ctrl-C path triggers `cancel()` on the active session and prints a cancellation marker (restore prior behavior).
|
|
86
|
+
- `pnpm typecheck && pnpm test` passes.
|
|
87
|
+
- Manual real-daemon validation:
|
|
88
|
+
- Run `scorel chat`, send a long-running tool prompt, Ctrl-C → daemon emits cancel, CLI shows cancelled.
|
|
89
|
+
- Run two `scorel chat` sessions in different `--cwd` directories; a third client calling `listProjects()` returns both slugs with correct counts.
|
|
90
|
+
|
|
91
|
+
## Tests
|
|
92
|
+
|
|
93
|
+
- Protocol round-trip: every new message shape encodes/decodes via existing schema validators.
|
|
94
|
+
- Daemon unit tests for `list_sessions` filtering, sorting, limit clamping; `list_projects` aggregation across mixed in-memory and on-disk sessions; `cancel` happy path and "no running turn" path.
|
|
95
|
+
- DaemonClient unit tests for the three new methods (request id correlation, session-id requirement for cancel, error response handling).
|
|
96
|
+
- CLI test re-asserts Ctrl-C → cancel diagnostic.
|
|
97
|
+
- Manual: see Acceptance Criteria.
|
|
98
|
+
- Run targeted tests then `pnpm typecheck && pnpm test`.
|
|
99
|
+
|
|
100
|
+
## Affected Paths
|
|
101
|
+
|
|
102
|
+
- `packages/protocol/src/wire.ts`
|
|
103
|
+
- `packages/protocol/src/events.ts` (extend `SessionSummary` with `projectSlug`; add `DaemonProjectSummary` if it lives here, else add new `projects.ts` module — choose `events.ts` to avoid file proliferation)
|
|
104
|
+
- `packages/protocol/src/index.test.ts`
|
|
105
|
+
- `packages/daemon/src/index.ts`
|
|
106
|
+
- `packages/daemon/src/index.test.ts` (or new `protocol.test.ts` reintroduced)
|
|
107
|
+
- `packages/daemon/src/projects/aggregator.ts` (new — reads JSONL headers and aggregates by slug)
|
|
108
|
+
- `packages/daemon/src/projects/aggregator.test.ts` (new)
|
|
109
|
+
- `packages/client/src/index.ts`
|
|
110
|
+
- `packages/client/src/daemon-client.test.ts` (reintroduce, scoped to new methods)
|
|
111
|
+
- `apps/cli/src/index.ts`
|
|
112
|
+
- `apps/cli/src/index.test.ts`
|
|
113
|
+
- `docs/spec/daemon.md`
|
|
114
|
+
- `docs/spec/client.md`
|
|
115
|
+
- `docs/ROADMAP.md` (M5 step entry for S0032)
|
|
116
|
+
|
|
117
|
+
## Risks And Boundaries
|
|
118
|
+
|
|
119
|
+
- Reading every JSONL header on `list_projects` / `list_sessions` is O(N) over disk; acceptable for v1 (single-user, dozens to hundreds of sessions). Add an in-memory cache invalidated on session create/append so steady-state cost is constant.
|
|
120
|
+
- `projectSlug` collision (S0031 documented `-` ambiguity) means `list_projects` cannot reverse a slug back to a unique `workDir`; clients display `displayName` and `workDirHint` from the daemon and never reverse-engineer.
|
|
121
|
+
- Cancellation already had a rolled-back implementation in git history. Re-introduce that exact behavior; do not invent new semantics.
|
|
122
|
+
- `SessionMeta.projectSlug?` is additive; existing sessions without it remain readable (fallback path covered above).
|
|
123
|
+
- `list_projects` exposes daemon's full session map. Acceptable in single-user model; if multi-tenant ever lands, gating is a future spec.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# S0033: WebUI Application Skeleton And Routing
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
Stand up `apps/webui` as a Next.js 14 App Router project with Tailwind 4, wire it into the pnpm monorepo, and lock the URL routing surface for the rest of M5. No real data, no daemon connection — just a deployable empty shell with the right shape.
|
|
6
|
+
|
|
7
|
+
This spec exists separately from M5.4+ because the previous M5 attempt lumped framework integration with feature work, which hid build/ESM regressions inside feature commits. This time the skeleton lands first, alone.
|
|
8
|
+
|
|
9
|
+
## Scope
|
|
10
|
+
|
|
11
|
+
- New app `apps/webui`:
|
|
12
|
+
- `package.json` with `next@14`, `react@18`, `react-dom@18`, `tailwindcss@4`, `@scorel/protocol` (workspace), `@scorel/client` (workspace).
|
|
13
|
+
- `tsconfig.json` extending the repo TS base config.
|
|
14
|
+
- `next.config.mjs` with `transpilePackages: ["@scorel/protocol", "@scorel/client"]` so monorepo TS sources work without prebuild.
|
|
15
|
+
- Tailwind v4 wired through PostCSS (`postcss.config.mjs`) and global stylesheet.
|
|
16
|
+
- App Router shell:
|
|
17
|
+
- `app/layout.tsx` — global HTML / body / Tailwind reset; renders sidebar slot + main slot.
|
|
18
|
+
- `app/page.tsx` — root empty state ("Add a Device in Settings to start").
|
|
19
|
+
- `app/devices/[deviceId]/page.tsx` — per-device empty state.
|
|
20
|
+
- `app/devices/[deviceId]/projects/[projectSlug]/page.tsx` — per-project empty state.
|
|
21
|
+
- `app/devices/[deviceId]/projects/[projectSlug]/sessions/[sessionId]/page.tsx` — chatbox empty state.
|
|
22
|
+
- `app/settings/page.tsx` — settings root empty.
|
|
23
|
+
- `app/settings/devices/[deviceId]/page.tsx` — device edit empty.
|
|
24
|
+
- Sidebar / topbar component scaffolds:
|
|
25
|
+
- `components/shell/sidebar.tsx` — hardcoded layout with `New Chat` action, empty Projects tree, bottom Settings link. No real data wiring.
|
|
26
|
+
- `components/shell/topbar.tsx` — selected device label + connection placeholder.
|
|
27
|
+
- All components are server-rendered or `"use client"` only when strictly needed.
|
|
28
|
+
- Build/dev scripts:
|
|
29
|
+
- `pnpm --filter @scorel/webui dev` runs `next dev`.
|
|
30
|
+
- `pnpm --filter @scorel/webui build` runs `next build`.
|
|
31
|
+
- `pnpm --filter @scorel/webui typecheck` runs `tsc --noEmit -p tsconfig.json`.
|
|
32
|
+
- `pnpm --filter @scorel/webui test` runs `vitest --run`. With zero tests v1, the script must still exit 0.
|
|
33
|
+
- Lint/format: lean on existing repo config; do not introduce ESLint per-app unless required by Next 14 to build.
|
|
34
|
+
- Repo-level `pnpm typecheck && pnpm test` continues to pass with the new app included.
|
|
35
|
+
|
|
36
|
+
## Not In Scope
|
|
37
|
+
|
|
38
|
+
- Device CRUD, BrowserStore, DaemonClient instantiation, real sidebar tree, chatbox, settings forms (M5.4–M5.9).
|
|
39
|
+
- UI library beyond Tailwind primitives (no Radix / Base UI / shadcn integration in this spec).
|
|
40
|
+
- Auth, theming, dark mode, i18n.
|
|
41
|
+
- Service worker / PWA / SSR optimization.
|
|
42
|
+
- Static export (`next export`); spec assumes `next dev` / `next build` against Node 22.
|
|
43
|
+
|
|
44
|
+
## Acceptance Criteria
|
|
45
|
+
|
|
46
|
+
- `pnpm install` succeeds with no warnings about peer deps Scorel can fix.
|
|
47
|
+
- `pnpm --filter @scorel/webui build` succeeds; output has the seven routes above.
|
|
48
|
+
- `pnpm --filter @scorel/webui dev` boots and serves all seven routes returning 200 with a recognizable empty-state string.
|
|
49
|
+
- `pnpm --filter @scorel/webui typecheck` succeeds.
|
|
50
|
+
- Top-level `pnpm typecheck && pnpm test` succeeds.
|
|
51
|
+
- `apps/webui/src` only imports from `@scorel/protocol` and `@scorel/client` (and Next/React/Tailwind). No imports from `@scorel/core` or Node-only paths. Enforced by a focused vitest spec or eslint rule (vitest is fine for v1).
|
|
52
|
+
- Routing matches §Scope exactly; URL params are accepted as plain strings (no decoding logic yet).
|
|
53
|
+
- Tailwind classes render (smoke: visit any route, body has Tailwind preflight applied).
|
|
54
|
+
|
|
55
|
+
## Tests
|
|
56
|
+
|
|
57
|
+
- Add `apps/webui/src/package-boundaries.test.ts` (Vitest) verifying the imports rule above by parsing TS files via `node:fs` and a simple regex over `from "..."` strings.
|
|
58
|
+
- Add `apps/webui/src/routes.test.ts` enumerating the seven route segments from `app/` directory listing and asserting they match the expected set.
|
|
59
|
+
- Run `pnpm --filter @scorel/webui typecheck && pnpm --filter @scorel/webui test`.
|
|
60
|
+
- Run `pnpm typecheck && pnpm test` at repo root.
|
|
61
|
+
- Manual: `pnpm --filter @scorel/webui dev`; visit each route; confirm empty state text.
|
|
62
|
+
|
|
63
|
+
## Affected Paths
|
|
64
|
+
|
|
65
|
+
- `apps/webui/package.json` (new)
|
|
66
|
+
- `apps/webui/tsconfig.json` (new)
|
|
67
|
+
- `apps/webui/next.config.mjs` (new)
|
|
68
|
+
- `apps/webui/postcss.config.mjs` (new)
|
|
69
|
+
- `apps/webui/tailwind.config.ts` (new — even though Tailwind 4 supports zero-config, pin content paths explicitly)
|
|
70
|
+
- `apps/webui/app/layout.tsx`
|
|
71
|
+
- `apps/webui/app/globals.css`
|
|
72
|
+
- `apps/webui/app/page.tsx`
|
|
73
|
+
- `apps/webui/app/devices/[deviceId]/page.tsx`
|
|
74
|
+
- `apps/webui/app/devices/[deviceId]/projects/[projectSlug]/page.tsx`
|
|
75
|
+
- `apps/webui/app/devices/[deviceId]/projects/[projectSlug]/sessions/[sessionId]/page.tsx`
|
|
76
|
+
- `apps/webui/app/settings/page.tsx`
|
|
77
|
+
- `apps/webui/app/settings/devices/[deviceId]/page.tsx`
|
|
78
|
+
- `apps/webui/components/shell/sidebar.tsx`
|
|
79
|
+
- `apps/webui/components/shell/topbar.tsx`
|
|
80
|
+
- `apps/webui/src/package-boundaries.test.ts`
|
|
81
|
+
- `apps/webui/src/routes.test.ts`
|
|
82
|
+
- `pnpm-lock.yaml`
|
|
83
|
+
- `docs/architecture.md` (note WebUI app exists)
|
|
84
|
+
- `docs/ROADMAP.md` (M5 step entry for S0033)
|
|
85
|
+
|
|
86
|
+
## Risks And Boundaries
|
|
87
|
+
|
|
88
|
+
- **ESM in monorepo with Next**: Next 14 handles workspace TS via `transpilePackages`. If `@scorel/client` ships dual ESM/CJS later, this list must include both. Spec keeps `transpilePackages` and avoids introducing build steps for protocol/client.
|
|
89
|
+
- **Tailwind 4** is current at the time of writing; if API drift forces Tailwind 3, swap before merge — do not block M5 on Tailwind 4 specifics. *Implementation note (2026-05-31)*: Tailwind 4.3.0 stable + `@tailwindcss/postcss` 4.3.0 installed cleanly with Next 14.2.35; no fallback to Tailwind 3 was needed.
|
|
90
|
+
- **App Router strictness**: server components are default; any state hook needs `"use client"`. Skeleton stays mostly server-rendered.
|
|
91
|
+
- **Bundle size** is irrelevant v1; we run `next dev` locally and `next build` for verification, not production deploy.
|
|
92
|
+
- Do not pull in extra UI libs until S0034 actually needs them; this spec stays minimal so feature commits don't fight framework decisions.
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# S0034: WebUI Device Model And Settings CRUD
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
Lock the WebUI domain model (`Device`, `DeviceProject`, `DeviceSessionSummary`), introduce the single browser-storage abstraction (`BrowserStore`, `localStorage` v1), and ship the Settings page so users can add / edit / delete a Device. No daemon connection yet — Settings is purely local persistence.
|
|
6
|
+
|
|
7
|
+
## Scope
|
|
8
|
+
|
|
9
|
+
- Domain types in `apps/webui/lib/domain/devices.ts`:
|
|
10
|
+
```ts
|
|
11
|
+
type Device = {
|
|
12
|
+
id: string; // ulid generated client-side
|
|
13
|
+
name: string;
|
|
14
|
+
link: string; // normalized wss://host[:port][/path] | ws://...
|
|
15
|
+
token: string; // v1 cleartext
|
|
16
|
+
createdAt: number;
|
|
17
|
+
lastConnectedAt?: number;
|
|
18
|
+
remoteIdentity?: { deviceId: string; deviceDisplayName?: string };
|
|
19
|
+
projects?: DeviceProject[];
|
|
20
|
+
projectsFetchedAt?: number;
|
|
21
|
+
};
|
|
22
|
+
type DeviceProject = {
|
|
23
|
+
projectSlug: string;
|
|
24
|
+
displayName?: string;
|
|
25
|
+
workDirHint?: string;
|
|
26
|
+
sessionCount?: number;
|
|
27
|
+
lastSeenAt?: number;
|
|
28
|
+
sessions?: Record<string, DeviceSessionSummary>;
|
|
29
|
+
sessionsFetchedAt?: number;
|
|
30
|
+
};
|
|
31
|
+
type DeviceSessionSummary = {
|
|
32
|
+
sessionId: string;
|
|
33
|
+
title?: string;
|
|
34
|
+
model?: string;
|
|
35
|
+
updatedAt?: number;
|
|
36
|
+
currentSeq?: number;
|
|
37
|
+
};
|
|
38
|
+
```
|
|
39
|
+
- BrowserStore abstraction in `apps/webui/lib/store/browser-store.ts`:
|
|
40
|
+
- Single namespace prefix: `scorel:webui:v1:`.
|
|
41
|
+
- API: `get<T>(key): T | undefined`, `set<T>(key, value): void`, `remove(key): void`, `subscribe(key, listener): unsubscribe` (uses `storage` events for cross-tab + an in-process pub/sub).
|
|
42
|
+
- Quota handling: catch `QuotaExceededError`; expose `onQuotaExceeded` hook (used by future attach-cache evictor; v1 just logs and rethrows).
|
|
43
|
+
- SSR safety: `BrowserStore` constructor takes `storage: Storage | null`. On the server (Next.js render), pass `null` and all reads return `undefined`, all writes are no-ops. Hooks integrate with `useSyncExternalStore`.
|
|
44
|
+
- Devices store in `apps/webui/lib/store/devices.ts`:
|
|
45
|
+
- Reads/writes `scorel:webui:v1:devices` (a JSON-encoded `Device[]`).
|
|
46
|
+
- Methods: `list()`, `get(id)`, `create(input)`, `update(id, patch)`, `remove(id)`. All synchronous on top of BrowserStore.
|
|
47
|
+
- `create` generates ulid via `crypto.randomUUID` (acceptable v1; ulid lib only if string format matters).
|
|
48
|
+
- Validates and normalizes Link before persist (see below).
|
|
49
|
+
- Link normalization in `apps/webui/lib/domain/link.ts`:
|
|
50
|
+
- Accept input: `wss://host[:port][/path]` or `ws://host[:port][/path]`. Reject anything else with a clear error string.
|
|
51
|
+
- Trim whitespace.
|
|
52
|
+
- Lowercase scheme and host.
|
|
53
|
+
- Strip trailing `/`.
|
|
54
|
+
- Reject empty token (separate validator on the form).
|
|
55
|
+
- Settings UI in `apps/webui/app/settings/page.tsx` and `apps/webui/app/settings/devices/[deviceId]/page.tsx`:
|
|
56
|
+
- List page: table/list of devices, "Add Device" button, edit + delete actions.
|
|
57
|
+
- Add/edit form fields: Name (required, 1–64 chars), Link (required, validated by `link.ts`), Token (required, 1–4096 chars; rendered as password input).
|
|
58
|
+
- Form errors shown inline; submit disabled while invalid.
|
|
59
|
+
- Delete confirms via standard browser `confirm()`; v1 acceptable.
|
|
60
|
+
- Sidebar wiring in `apps/webui/components/shell/sidebar.tsx`:
|
|
61
|
+
- Read devices via the devices store hook.
|
|
62
|
+
- Render Device nodes (just name + faint "not connected" badge — connection happens in S0035).
|
|
63
|
+
- Each Device node links to `/devices/:deviceId`.
|
|
64
|
+
- Empty-state copy: when no devices exist, root page shows "Add a device in Settings to get started" with a button linking to `/settings`.
|
|
65
|
+
|
|
66
|
+
## Not In Scope
|
|
67
|
+
|
|
68
|
+
- DaemonClient instantiation, handshake, project/session listing (S0035, S0036).
|
|
69
|
+
- Connection state indicators beyond "configured but not connected" placeholder.
|
|
70
|
+
- Token encryption / Web Crypto / IndexedDB.
|
|
71
|
+
- Multi-tab realtime sync of device list (basic `storage` event subscription is included; no conflict resolution beyond last-write-wins).
|
|
72
|
+
- Import / export devices.
|
|
73
|
+
- Dark mode / theming.
|
|
74
|
+
|
|
75
|
+
## Acceptance Criteria
|
|
76
|
+
|
|
77
|
+
- Domain types match §Scope exactly; exported from `lib/domain/devices.ts`.
|
|
78
|
+
- BrowserStore is the only module that touches `localStorage` directly. Enforced by the package-boundaries test from S0033 extended to forbid `localStorage` references outside `lib/store/`.
|
|
79
|
+
- Devices store round-trips: create → list returns the device; update mutates fields; remove deletes. All reflected in `localStorage` under `scorel:webui:v1:devices`.
|
|
80
|
+
- Link validator rejects: `http://...`, `https://...`, `wss:`/`wss:///`, empty, plain hostname. Accepts `wss://host`, `wss://host:9876`, `ws://localhost:8765`, `wss://host/path/`.
|
|
81
|
+
- Settings list page renders all devices; add form creates a new device; edit form updates; delete removes.
|
|
82
|
+
- Sidebar shows configured devices with "not connected" badge; clicking navigates to `/devices/:deviceId`.
|
|
83
|
+
- Root empty state appears when zero devices configured.
|
|
84
|
+
- `pnpm --filter @scorel/webui typecheck && pnpm --filter @scorel/webui test` passes.
|
|
85
|
+
- Manual: open `/settings`, add a device with link `wss://localhost:9876` and token `abc`, verify list shows it; refresh page, device persists; edit name; delete; confirm sidebar reflects changes live.
|
|
86
|
+
|
|
87
|
+
## Tests
|
|
88
|
+
|
|
89
|
+
- Unit tests for `lib/domain/link.ts` covering accept/reject cases.
|
|
90
|
+
- Unit tests for `lib/store/devices.ts` using `vitest` with a fake `Storage` implementation.
|
|
91
|
+
- Unit test extending `package-boundaries.test.ts` to assert `localStorage` only appears under `lib/store/`.
|
|
92
|
+
- Component test (Vitest + jsdom) for settings list and add form: rendering, form validation error states, submit dispatch.
|
|
93
|
+
- Run `pnpm --filter @scorel/webui typecheck && pnpm --filter @scorel/webui test`.
|
|
94
|
+
- Run repo-level `pnpm typecheck && pnpm test`.
|
|
95
|
+
|
|
96
|
+
## Affected Paths
|
|
97
|
+
|
|
98
|
+
- `apps/webui/lib/domain/devices.ts` (new)
|
|
99
|
+
- `apps/webui/lib/domain/link.ts` (new)
|
|
100
|
+
- `apps/webui/lib/domain/link.test.ts` (new)
|
|
101
|
+
- `apps/webui/lib/store/browser-store.ts` (new)
|
|
102
|
+
- `apps/webui/lib/store/browser-store.test.ts` (new)
|
|
103
|
+
- `apps/webui/lib/store/devices.ts` (new)
|
|
104
|
+
- `apps/webui/lib/store/devices.test.ts` (new)
|
|
105
|
+
- `apps/webui/app/settings/page.tsx`
|
|
106
|
+
- `apps/webui/app/settings/devices/[deviceId]/page.tsx`
|
|
107
|
+
- `apps/webui/components/settings/device-list.tsx` (new)
|
|
108
|
+
- `apps/webui/components/settings/device-form.tsx` (new)
|
|
109
|
+
- `apps/webui/components/shell/sidebar.tsx`
|
|
110
|
+
- `apps/webui/app/page.tsx` (empty-state with link to settings)
|
|
111
|
+
- `apps/webui/src/package-boundaries.test.ts` (extend)
|
|
112
|
+
- `docs/ROADMAP.md` (M5 step entry for S0034)
|
|
113
|
+
|
|
114
|
+
## Risks And Boundaries
|
|
115
|
+
|
|
116
|
+
- **Token cleartext**: documented v1 trade-off; spec must not promise encryption later in this milestone. README/UX should warn users not to share screenshots of Settings.
|
|
117
|
+
- **`crypto.randomUUID`** is available in modern browsers; pin to it (no polyfill).
|
|
118
|
+
- **SSR**: Settings forms are client components (`"use client"`); the rest of the shell stays server-rendered where possible.
|
|
119
|
+
- **Cross-tab races**: last-write-wins via `storage` event is acceptable v1; document the gap.
|
|
120
|
+
- **Form library**: keep it native (`<form>` + `useState`). No react-hook-form / zod runtime in this spec; if S0035+ need stronger validation, reassess then.
|
|
121
|
+
- **Quota**: with only the Devices array v1 we are far below 5MB; explicit quota handling lives in attach-cache work (S0037+).
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# S0035: WebUI Device Connection Handshake
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
Wire the configured `Device` list to real `DaemonClient + WsTransport` connections. After this spec, opening a Device in the sidebar establishes a WebSocket connection, the daemon's identity (`deviceId`, `deviceDisplayName`) is captured into `Device.remoteIdentity`, and connection status is visible. No project/session listing yet.
|
|
6
|
+
|
|
7
|
+
## Scope
|
|
8
|
+
|
|
9
|
+
- Connection pool in `apps/webui/lib/connection/pool.ts`:
|
|
10
|
+
- Owns one `DaemonClient` instance per `Device.id`.
|
|
11
|
+
- Lazily creates a client when the user navigates to `/devices/:deviceId` (or descendants).
|
|
12
|
+
- Methods: `acquire(deviceId): ManagedConnection | undefined`, `release(deviceId)`, `subscribe(deviceId, listener)`.
|
|
13
|
+
- Uses `WsTransport` from `@scorel/client` with `link` and `token` from the device record.
|
|
14
|
+
- Reuses an existing client if present and not in `error`/`disconnected` terminal state.
|
|
15
|
+
- Connection state machine in `apps/webui/lib/connection/state.ts`:
|
|
16
|
+
- States: `idle` → `connecting` → `connected` → (`reconnecting` | `disconnected` | `error`).
|
|
17
|
+
- Transitions driven by `DaemonClient` callbacks (`onConnected`, `onDisconnect`, `onError` if present; otherwise wrap `connect()` promise + `state` getter from the existing client API).
|
|
18
|
+
- `error` carries a categorized reason: `auth | network | version_mismatch | unknown`. Categorization rules:
|
|
19
|
+
- `auth` ↔ daemon error code `auth_failed` / HTTP 401-equivalent close codes.
|
|
20
|
+
- `network` ↔ ws close code 1006 / DNS / TCP errors.
|
|
21
|
+
- `version_mismatch` ↔ daemon `protocolVersion` field disagreement.
|
|
22
|
+
- else `unknown`.
|
|
23
|
+
- Identity capture:
|
|
24
|
+
- On successful handshake (`connected` daemon message), update `Device.remoteIdentity = { deviceId, deviceDisplayName }` via the devices store; also update `Device.lastConnectedAt`.
|
|
25
|
+
- The handshake-supplied `defaultProjectSlug` (if any from S0032) is ignored here — projects are populated in S0036.
|
|
26
|
+
- UI:
|
|
27
|
+
- `components/shell/sidebar.tsx` Device node shows live status: dot color + tooltip text matching the state machine state.
|
|
28
|
+
- `app/devices/[deviceId]/page.tsx` shows a "Connecting…" / "Connected as <displayName>" / "Error: <reason>" banner.
|
|
29
|
+
- "Reconnect" button on error/disconnected; "Disconnect" on connected (manual disconnect transitions to `idle`).
|
|
30
|
+
- Switching device in the sidebar does not eagerly connect every other device; only the current route's device is acquired. Other devices stay `idle` unless previously connected (then keep their already-open ws). Lifetime: when the user navigates away from a device for more than 60 seconds, release its connection. (Make this delay a constant for now; adjust later.)
|
|
31
|
+
- Errors:
|
|
32
|
+
- Auth failure: show "Token rejected; update token in Settings". Link to `/settings/devices/:deviceId`.
|
|
33
|
+
- Network failure: show "Cannot reach <host>; will retry". Pool retries with exponential backoff: 1s, 2s, 4s, 8s, capped at 30s; retries stop after 5 consecutive network failures and stays `disconnected` until user clicks Reconnect.
|
|
34
|
+
- Version mismatch: show "Daemon protocol version unsupported; upgrade required". No retry.
|
|
35
|
+
|
|
36
|
+
## Not In Scope
|
|
37
|
+
|
|
38
|
+
- `list_projects` / `list_sessions` calls (S0036).
|
|
39
|
+
- Session attach / event stream (S0037).
|
|
40
|
+
- Background reconnect when WebUI tab is hidden (browser may freeze ws; rely on user interaction to retrigger).
|
|
41
|
+
- Multi-tab connection coordination (each tab is independent v1).
|
|
42
|
+
- Token rotation, refresh, OAuth.
|
|
43
|
+
|
|
44
|
+
## Acceptance Criteria
|
|
45
|
+
|
|
46
|
+
- Connection pool creates exactly one client per device id; concurrent route navigations don't spawn duplicates.
|
|
47
|
+
- State machine transitions match the table in §Scope; verified by unit tests with a fake `DaemonClient`.
|
|
48
|
+
- After handshake, `Device.remoteIdentity` and `lastConnectedAt` are persisted and visible in `/settings/devices/:deviceId`.
|
|
49
|
+
- Sidebar status dot reflects live state (use `useSyncExternalStore` against the pool's per-device subscription).
|
|
50
|
+
- Error categorization unit tests cover each reason path.
|
|
51
|
+
- Manual: start a real daemon (`scorel daemon serve --remote`), add the device in WebUI, verify sidebar dot turns green; stop daemon → red with retry; restart daemon → re-clicking Reconnect transitions to green.
|
|
52
|
+
- `pnpm --filter @scorel/webui typecheck && pnpm --filter @scorel/webui test` passes.
|
|
53
|
+
- Repo `pnpm typecheck && pnpm test` passes.
|
|
54
|
+
|
|
55
|
+
## Tests
|
|
56
|
+
|
|
57
|
+
- Unit tests for connection state machine: every legal transition, illegal transitions are rejected.
|
|
58
|
+
- Pool tests: lazy create, reuse, release after timeout, no duplicate clients across rapid navigations.
|
|
59
|
+
- Error categorization tests for the four reason buckets.
|
|
60
|
+
- Component test for sidebar Device node states.
|
|
61
|
+
- Manual real-daemon validation per Acceptance Criteria.
|
|
62
|
+
|
|
63
|
+
## Affected Paths
|
|
64
|
+
|
|
65
|
+
- `apps/webui/lib/connection/pool.ts` (new)
|
|
66
|
+
- `apps/webui/lib/connection/pool.test.ts` (new)
|
|
67
|
+
- `apps/webui/lib/connection/state.ts` (new)
|
|
68
|
+
- `apps/webui/lib/connection/state.test.ts` (new)
|
|
69
|
+
- `apps/webui/lib/connection/error.ts` (new — categorization)
|
|
70
|
+
- `apps/webui/lib/connection/error.test.ts` (new)
|
|
71
|
+
- `apps/webui/components/shell/sidebar.tsx` (live status)
|
|
72
|
+
- `apps/webui/components/shell/device-status.tsx` (new)
|
|
73
|
+
- `apps/webui/app/devices/[deviceId]/page.tsx` (banner + reconnect/disconnect)
|
|
74
|
+
- `apps/webui/lib/store/devices.ts` (add `markIdentity`, `markConnectedAt` helpers if needed)
|
|
75
|
+
- `docs/ROADMAP.md` (M5 step entry for S0035)
|
|
76
|
+
|
|
77
|
+
## Risks And Boundaries
|
|
78
|
+
|
|
79
|
+
- `DaemonClient` API surface for connection events may not currently include all hooks needed; if so, extend `@scorel/client` minimally — keep that change in this spec, and document it in `docs/spec/client.md`.
|
|
80
|
+
- Browser WebSocket has limited error introspection. Categorization is best-effort; document the unreliability.
|
|
81
|
+
- Backoff constants (1s/2s/4s/8s/30s, 5 attempts) are tuned by feel; expose them as constants for easy adjustment but do not make them user-configurable v1.
|
|
82
|
+
- Holding open ws for inactive devices wastes daemon connections. The 60-second release rule is a v1 compromise; revisit if user feedback says it churns too aggressively.
|
|
83
|
+
- Multi-tab same-device duplicate connections are accepted v1; daemon already supports multiple clients per session.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# S0036: WebUI Project And Session Sync
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
After a device is connected, populate its project list via `list_projects` and (lazily) its session list per project via `list_sessions({ projectSlug })`. Render the sidebar tree (Device → Project → Session) from the persisted `Device.projects` snapshot, with offline-cache fallback.
|
|
6
|
+
|
|
7
|
+
## Scope
|
|
8
|
+
|
|
9
|
+
- After handshake (state machine reaches `connected`), `lib/connection/sync.ts` calls `client.listProjects()` and writes the result into `Device.projects` via the devices store. Stale `projects` from a prior connection are replaced wholesale (overwrite snapshot, not merge).
|
|
10
|
+
- Sidebar Device node renders `Device.projects` if present, else "(no projects yet)" placeholder. Connected and `projects` empty → render same placeholder; offline → render last persisted `projects` with a faint "offline" tint.
|
|
11
|
+
- Selecting a project (clicking Project node, or navigating to `/devices/:deviceId/projects/:projectSlug`) triggers `client.listSessions({ projectSlug, limit: 200 })`. Result writes to `DeviceProject.sessions` (keyed by `sessionId`) and updates `sessionsFetchedAt`.
|
|
12
|
+
- Sessions list renders newest first by `updatedAt`. If `sessions` already cached, render immediately and refresh in background; replace on refresh result.
|
|
13
|
+
- New project navigation uses Next.js `Link` and updates URL state via App Router. Sidebar reflects the active route.
|
|
14
|
+
- Sync helpers in `apps/webui/lib/sync/projects.ts` and `apps/webui/lib/sync/sessions.ts`:
|
|
15
|
+
- `syncProjects(deviceId)`: client.listProjects → store.update.
|
|
16
|
+
- `syncSessions(deviceId, projectSlug)`: client.listSessions → store.update.
|
|
17
|
+
- Both deduplicate concurrent calls per (deviceId, projectSlug).
|
|
18
|
+
- Empty / error paths:
|
|
19
|
+
- listProjects fails: keep existing `Device.projects` cache; show toast/banner "Failed to load projects" with retry button.
|
|
20
|
+
- listSessions fails: same pattern at the project level.
|
|
21
|
+
- URL params: `projectSlug` may contain unreserved characters per S0031 (no `/`, no encoding tricks needed). Use `decodeURIComponent` defensively when reading from `params` — but daemon-emitted slugs already pass through Tailwind/URL path safely.
|
|
22
|
+
|
|
23
|
+
## Not In Scope
|
|
24
|
+
|
|
25
|
+
- Chatbox (S0037) — selecting a session shows an empty chatbox shell with header info only here.
|
|
26
|
+
- attach-cache reads (those land in S0037 alongside dual-seq resync).
|
|
27
|
+
- Cross-device project aggregation / search.
|
|
28
|
+
- Session creation (`New Chat`) — lives in S0039.
|
|
29
|
+
- Real-time push of new sessions / projects (no server-side push API yet; v1 polls on user navigation).
|
|
30
|
+
|
|
31
|
+
## Acceptance Criteria
|
|
32
|
+
|
|
33
|
+
- After connecting a device with three projects, sidebar shows three Project nodes under that Device with daemon-supplied `displayName`.
|
|
34
|
+
- Clicking a Project node lists its sessions; sessions persisted to `DeviceProject.sessions` so re-navigating after reload shows the cached list before the refresh fetch resolves.
|
|
35
|
+
- Disconnecting the device transitions sidebar to faint "offline" tint but keeps the last project/session structure visible until the user removes the device.
|
|
36
|
+
- Project list refresh on every successful (re)connect (overwrite); session list refresh on every project navigation; both deduplicated under concurrent triggers.
|
|
37
|
+
- listProjects / listSessions errors do not erase cache; they show a non-blocking error banner.
|
|
38
|
+
- `pnpm --filter @scorel/webui typecheck && pnpm --filter @scorel/webui test` passes.
|
|
39
|
+
- Repo `pnpm typecheck && pnpm test` passes.
|
|
40
|
+
- Manual: with two real daemons (different `--cwd`), each registered as a Device, sidebar shows both Devices with their respective projects.
|
|
41
|
+
|
|
42
|
+
## Tests
|
|
43
|
+
|
|
44
|
+
- Unit tests for `syncProjects` / `syncSessions` happy path, error preservation, concurrent dedupe.
|
|
45
|
+
- Component tests for sidebar Project / Session nodes (rendered from a fake store).
|
|
46
|
+
- Integration test using a stub `DaemonClient` returning canned `listProjects` / `listSessions` results, asserting store mutation and URL navigation behavior.
|
|
47
|
+
- Manual: as above.
|
|
48
|
+
|
|
49
|
+
## Affected Paths
|
|
50
|
+
|
|
51
|
+
- `apps/webui/lib/sync/projects.ts` (new)
|
|
52
|
+
- `apps/webui/lib/sync/projects.test.ts` (new)
|
|
53
|
+
- `apps/webui/lib/sync/sessions.ts` (new)
|
|
54
|
+
- `apps/webui/lib/sync/sessions.test.ts` (new)
|
|
55
|
+
- `apps/webui/lib/store/devices.ts` (add `setProjects`, `setProjectSessions` helpers)
|
|
56
|
+
- `apps/webui/components/shell/sidebar.tsx` (project + session nodes)
|
|
57
|
+
- `apps/webui/components/shell/project-node.tsx` (new)
|
|
58
|
+
- `apps/webui/components/shell/session-node.tsx` (new)
|
|
59
|
+
- `apps/webui/app/devices/[deviceId]/page.tsx` (kick off `syncProjects` on mount via `"use client"` effect)
|
|
60
|
+
- `apps/webui/app/devices/[deviceId]/projects/[projectSlug]/page.tsx` (kick off `syncSessions`)
|
|
61
|
+
- `apps/webui/app/devices/[deviceId]/projects/[projectSlug]/sessions/[sessionId]/page.tsx` (placeholder chatbox header; full chatbox in S0037)
|
|
62
|
+
- `docs/ROADMAP.md` (M5 step entry for S0036)
|
|
63
|
+
|
|
64
|
+
## Risks And Boundaries
|
|
65
|
+
|
|
66
|
+
- `list_sessions` returns up to 200 entries v1 (per S0032 default). For projects with thousands of sessions, the sidebar truncates silently. Acceptable v1; flag as a known limit.
|
|
67
|
+
- Cache eviction: device removal must clean up its `projects` entries (handled in S0034 store helpers; verify here).
|
|
68
|
+
- Navigating to a session under a project not yet synced is allowed (URL deep-linking); the page still triggers `syncSessions` even if the session isn't in cache yet — chatbox itself can attach by `sessionId` regardless.
|
|
69
|
+
- Cross-tab freshness: when one tab fetches, other tabs receive the update via `storage` event (BrowserStore subscription). Acceptable v1; may flicker if both tabs fetch simultaneously.
|
|
70
|
+
- No optimistic creation here. All sidebar items reflect daemon truth after handshake.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# S0037: WebUI Chatbox v1
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
Open a session and see a working chatbox: history rendered from attach-cache (instant), reconciled with daemon via dual-seq resync, live event stream (user / assistant / streaming delta / tool call / tool result) projected to UI, and a composer that sends prompts. No `cancel` (S0038) and no `New Chat` (S0039) yet.
|
|
6
|
+
|
|
7
|
+
## Scope
|
|
8
|
+
|
|
9
|
+
- Session attach in `apps/webui/lib/connection/session.ts`:
|
|
10
|
+
- On chatbox mount, take the device's `DaemonClient`, call `connect({ sessionId, persistentLastSeq, streamLastSeq })` using anchors from attach-cache.
|
|
11
|
+
- Subscribe to `event` messages and feed them to the projector.
|
|
12
|
+
- On unmount or session change, unsubscribe but keep the underlying client connection (pool-managed).
|
|
13
|
+
- Attach-cache in `apps/webui/lib/store/attach-cache.ts`:
|
|
14
|
+
- Key: `scorel:webui:v1:attach-cache:<scopeKey>:<sessionId>`. `scopeKey = sha256(kind\0locator).hex.slice(0,24)`; for WebUI always `kind="remote"`, `locator="device:<remoteDeviceId>/project:<projectSlug>"`.
|
|
15
|
+
- `scopeKey` computed via Web Crypto SubtleCrypto in browser (async); cache the result per (deviceId, projectSlug, sessionId) tuple in memory.
|
|
16
|
+
- File shape: `{ version: 1, scope, sessionId, events: PersistentEvent[], transients?: { eventId, seq, text }[] }` (mirrors CLI `AttachCacheFile`).
|
|
17
|
+
- Append on every received persistent event; truncate transients on `turn_end`.
|
|
18
|
+
- Quota fallback: if `setItem` throws QuotaExceeded, drop oldest transients first, then evict the least-recently-used non-current session's cache.
|
|
19
|
+
- Event projector in `apps/webui/lib/events/projector.ts`:
|
|
20
|
+
- Reduce `(state, event)` into a list of UI turns: `[{ id, kind: "user" | "assistant" | "tool", parts: TurnPart[] }]`.
|
|
21
|
+
- Streaming delta merges into the in-flight assistant turn keyed by `assistantEventId` from `message_start`.
|
|
22
|
+
- Tool calls and tool results group under their assistant turn.
|
|
23
|
+
- Final `assistant_message` event replaces the streamed assistant turn (deduplicate via id).
|
|
24
|
+
- Skip events whose `seq` is already incorporated.
|
|
25
|
+
- Chatbox UI in `app/devices/[deviceId]/projects/[projectSlug]/sessions/[sessionId]/page.tsx` (client component):
|
|
26
|
+
- Header: session title, model, last updated.
|
|
27
|
+
- Transcript: scrollable, autoscroll-on-new-event when scrolled to bottom; preserve position otherwise.
|
|
28
|
+
- Turn renderers: user (markdown-light, plain text initially fine), assistant (streaming-aware), tool call (collapsible JSON), tool result (collapsible).
|
|
29
|
+
- Composer: textarea + Send button; `Enter` submits, `Shift+Enter` newline. Empty submit ignored. `Send` calls `client.sendMessage(content)`; optimistic local user turn shown before server echoes.
|
|
30
|
+
- Loading state: while initial resync is pending, show "Loading session…" skeleton.
|
|
31
|
+
- Resync handling:
|
|
32
|
+
- `connect({ sessionId, persistentLastSeq, streamLastSeq })`. Persist anchors after every applied event:
|
|
33
|
+
- `persistentLastSeq = max(seq of last applied PersistentEvent)`.
|
|
34
|
+
- `streamLastSeq = max(seq of last applied event of any kind)`.
|
|
35
|
+
- If daemon returns `mode === "full_reload"`, drop projector state and re-render from scratch using returned events.
|
|
36
|
+
- If `mode === "persistent_fallback"`, append returned persistent events; transients before the boundary are gone (acceptable; CLI behaves the same).
|
|
37
|
+
- If `mode === "stream_resume"`, just append.
|
|
38
|
+
- Markdown / formatting v1: render text as preformatted with line wrapping. Code blocks not specially handled v1; skip syntax highlighting to keep this spec focused.
|
|
39
|
+
|
|
40
|
+
## Not In Scope
|
|
41
|
+
|
|
42
|
+
- `cancel` (S0038).
|
|
43
|
+
- `New Chat` (S0039).
|
|
44
|
+
- Multi-client conflict UX beyond what daemon already broadcasts.
|
|
45
|
+
- Rewind / fork / compact UI.
|
|
46
|
+
- File attachments, images, audio.
|
|
47
|
+
- Markdown rendering library, syntax highlighting, math, mermaid.
|
|
48
|
+
- Tool result rendering specialization (e.g., diff viewer for Edit results) — generic JSON dump is fine v1.
|
|
49
|
+
|
|
50
|
+
## Acceptance Criteria
|
|
51
|
+
|
|
52
|
+
- Opening a session in WebUI:
|
|
53
|
+
- Renders cached events instantly when attach-cache is present.
|
|
54
|
+
- Issues a resync `connect` with persistent + stream anchors.
|
|
55
|
+
- Shows the same transcript as a parallel `scorel attach` against the same daemon (within event ordering tolerance).
|
|
56
|
+
- Sending a prompt: composer clears, optimistic user turn appears, daemon echoes the persisted user message, assistant streaming text accumulates, final assistant message and any tool calls appear.
|
|
57
|
+
- Reloading the WebUI tab restores the chatbox from cache, then resyncs and continues live.
|
|
58
|
+
- Daemon-side persistent fallback (force by clearing daemon ring buffer) still produces a continuous transcript.
|
|
59
|
+
- attach-cache size stays under ~1.5MB per session for a typical 100-turn run; quota eviction never silently drops the active session.
|
|
60
|
+
- `pnpm --filter @scorel/webui typecheck && pnpm --filter @scorel/webui test` passes.
|
|
61
|
+
- Repo `pnpm typecheck && pnpm test` passes.
|
|
62
|
+
- Manual: real daemon + real LLM provider; send "list files in cwd" prompt that invokes a tool; verify event stream renders correctly; reload tab; transcript preserved.
|
|
63
|
+
|
|
64
|
+
## Tests
|
|
65
|
+
|
|
66
|
+
- Projector unit tests covering: streaming delta merge, dedup, tool call/result grouping, full_reload reset.
|
|
67
|
+
- attach-cache tests for: append, truncate transients on turn_end, quota fallback ordering (transients → LRU).
|
|
68
|
+
- session attach tests with a fake `DaemonClient`: mode handling for stream_resume, persistent_fallback, full_reload.
|
|
69
|
+
- Component test for chatbox renderer (render fixture transcript; assert turn order and content).
|
|
70
|
+
- Manual: as above.
|
|
71
|
+
|
|
72
|
+
## Affected Paths
|
|
73
|
+
|
|
74
|
+
- `apps/webui/lib/connection/session.ts` (new)
|
|
75
|
+
- `apps/webui/lib/connection/session.test.ts` (new)
|
|
76
|
+
- `apps/webui/lib/store/attach-cache.ts` (new)
|
|
77
|
+
- `apps/webui/lib/store/attach-cache.test.ts` (new)
|
|
78
|
+
- `apps/webui/lib/events/projector.ts` (new)
|
|
79
|
+
- `apps/webui/lib/events/projector.test.ts` (new)
|
|
80
|
+
- `apps/webui/lib/identity/scope-key.ts` (new — SubtleCrypto-based scope key)
|
|
81
|
+
- `apps/webui/lib/identity/scope-key.test.ts` (new)
|
|
82
|
+
- `apps/webui/components/chatbox/transcript.tsx` (new)
|
|
83
|
+
- `apps/webui/components/chatbox/composer.tsx` (new)
|
|
84
|
+
- `apps/webui/components/chatbox/turn-user.tsx` (new)
|
|
85
|
+
- `apps/webui/components/chatbox/turn-assistant.tsx` (new)
|
|
86
|
+
- `apps/webui/components/chatbox/turn-tool.tsx` (new)
|
|
87
|
+
- `apps/webui/app/devices/[deviceId]/projects/[projectSlug]/sessions/[sessionId]/page.tsx`
|
|
88
|
+
- `docs/ROADMAP.md` (M5 step entry for S0037)
|
|
89
|
+
|
|
90
|
+
## Risks And Boundaries
|
|
91
|
+
|
|
92
|
+
- The CLI projector logic in `apps/cli/src/index.ts` is the reference. Lift the algorithm; don't reinvent. Future spec can move it into `@scorel/client/reducer.ts`; this spec keeps it inside `apps/webui` to avoid scope creep.
|
|
93
|
+
- `localStorage` write throttling: every event causes a write today. If profiling shows hot path, batch writes via `requestIdleCallback` in a follow-up.
|
|
94
|
+
- Streaming text smoothness depends on event flush cadence; rely on daemon's existing transient delta cadence; do not add WebUI-side animation v1.
|
|
95
|
+
- Multi-tab same-session: each tab subscribes independently; both apply the same events. Acceptable but doubles localStorage writes; document.
|
|
96
|
+
- Markdown / code rendering deferred. Plain text v1 is honest; users who want pretty output add it later.
|
|
97
|
+
- attach-cache scope key uses SubtleCrypto, which is async. The first event in a session waits for the key promise; cache that promise per (deviceId, projectSlug, sessionId).
|