feat: codeoid web UI (Solid + Vite + Tailwind 4) — chat cockpit + file viewer - #3
Merged
Conversation
…foundation
Phase 1 of the codeoid web UI replacement:
- Solid 1.9 + Vite 6 + TypeScript strict + Tailwind 4 design tokens
- src/protocol/types.ts mirrors codeoid/src/protocol/types.ts
- src/lib/format.ts: token / cost / duration / relative time helpers
(25 unit tests)
- src/lib/auth.ts: ZeroID API key -> JWT exchange, localStorage memo
- src/lib/ws.ts: typed CodeoidClient with auth handshake, request /
response correlation, exponential-backoff reconnect
- 3-pane layout shell (sessions | transcript+prompt | file viewer)
- docs/ARCHITECTURE.md + docs/DECISIONS.md preserve context for
future contributors
Daemon stays the single source of truth: clients are pure renderers
that consume broadcasts and translate UI events into protocol verbs.
Lets any frontend (TUI / web / Telegram) auto-resume any session.
Bundle: 7.86KB JS + 8.43KB CSS gzipped to ~6KB combined before app
code lands.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 2 — clients are pure renderers, daemon is canonical:
- state/connection.ts owns the singleton CodeoidClient, exposes
connectionStatus / authIdentity signals and a bootstrap flow.
Routes broadcasts (session.message, .delta, .info_update,
.status_change, scrollback.replay) into the stores.
- state/sessions.ts: signal-backed session map, sortedlist memo,
focused id, focusNext/Prev with wrap, status-only updates.
- state/messages.ts: per-session message buffer with idempotent
upsert by messageId, applyDelta patching content/parts/tool state,
monotonic version map for downstream cache invalidation.
- state/prompt-drafts.ts: per-session draft persistence in
localStorage. Only client-side persistence allowed.
- lib/identity.ts: shortSub / identityLabel / truncateWimseUri /
role + identity color classes — surfaces ZeroID provenance
everywhere the TUI does (per-message, session header, status bar).
UI shell (Tailwind 4 + design tokens):
- SignIn: API-key form, ZeroID URL override, error surfacing
- StatusBar: connection pill, identity chip, live session metrics
(turns, in/out tokens, cost, model badge, last-turn cost+latency)
- SessionListPane: sidebar rows showing name, workdir, agent label,
cumulative tokens, cost, turn count, sub-agent count, status dot
- CenterPane (P2 stub): session header with full UsageStrip
(Turns, Input, Output, Cache read, Cache write, Agent time, Cost)
- Shell: 3-pane grid; right pane (file viewer) collapsed at 0fr,
grows on file open in P5
Tests: 62 passing — formatters (25), identity (16), messages (12),
sessions (9). Bundle: ~14KB JS + ~4.5KB CSS gzipped.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…approvals (P3)
End-to-end MVP: send a message, see streaming response, approve tools.
Transcript:
- <Transcript> auto-scrolls to bottom unless the user scrolled up;
sticky 'jump to latest' pill when off-bottom
- <MessageRow> with role-aware borders (user/assistant/thinking/tool/
tool_result/system/info) and identity surfacing on every header
- role-specific bodies: solid-markdown + remark-gfm for assistant,
collapsible reasoning for thinking, ToolBlock with a phase badge
(streaming / waiting_confirmation / executing / completed / cancelled)
+ state-specific body renderer, tool_result preserves spacing
- identity.registered info events get a distinct treatment so
sub-agent registration is visible
Approvals:
- <ApprovalBar> derives the oldest waiting_confirmation tool from the
focused-session message buffer and surfaces inline above the prompt
with approve/deny buttons (Alt+Y/D titles, real binding in P6)
Prompt:
- <PromptBox> multi-line textarea with autosize, Enter submits,
Shift+Enter / Ctrl+Enter newline
- per-session draft persistence (localStorage) — switching sessions
snapshots the current text and reloads the destination's draft
- slash commands: /new, /rename, /destroy, /interrupt, /rotate,
/mode {i|a|x [maxTurns]}, /model <id> [fallback], /help, /clear
parsed in components/prompt/slash.ts (17 tests)
- error surfacing inline beneath the box
Wiring:
- App.tsx attaches to the focused session via session.attach so the
daemon broadcasts scrollback + deltas — daemon only routes to
attached clients
Tests: 79 passing (slash adds 17). Bundle ~68KB JS gzipped.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Daemon side:
- protocol: new ClientMessage variants fs.list / fs.read with strict
relative-path semantics, plus FsListResultMsg / FsReadResultMsg /
FsEntry on the broadcast side
- protocol/scopes.ts: add SCOPES.FS_READ; include in WATCHER + OPERATOR
- daemon/fs.ts: handleFsList / handleFsRead implementations
- resolveSafe canonicalises against session.workdir, blocks
symlink escapes via realpath + prefix check
- hidden defaults: .git, node_modules, target, dist, build, .next,
.turbo, .vscode, .idea, __pycache__, .DS_Store
- read cap: min(maxBytes ?? 1 MiB, 10 MiB hard ceiling); larger
files come back with truncated=true
- binary detection: NUL probe within first 4 KiB → base64 with
encoding="base64"; otherwise UTF-8
- language hint: extension + filename map for shiki-compatible ids
(Dockerfile, Makefile, .gitignore handled by name)
- session-manager: dispatch fs.list / fs.read; gate on SCOPES.FS_READ;
return well-formed error frames with the right ErrorCode
- tests: scopes.test.ts updated for the +1 scope; protocol.test.ts
DaemonMessage exhaustiveness covers the new result types
- package.json: 'bun test ./src/' so daemon tests don't pick up
the web/ vitest suite
Web side:
- protocol/types.ts: mirror new fs verbs and result frames
- lib/auth.ts: explicit `scope` parameter on the api_key exchange.
Without this, ZeroID's /oauth2/token returns a JWT with empty
scopes claim and the daemon's per-message gates reject everything.
DEFAULT_WEB_SCOPES enumerates exactly the verbs the UI sends.
- state/files.ts: per-session, per-path tree cache + open-file signal.
Race-protected against fast directory / file switches.
- components/files/FileTree.tsx: lazy-expanding tree, click directory
to toggle, click file to open. Reset cache on session change.
- components/files/FileViewer.tsx: right-pane viewer with shiki
syntax highlighting (lazy-loaded); binary files surface a stub.
- SessionListPane: file tree mounts below the sessions section
- Shell: right pane animates from 0fr to 36rem on file open via
grid-template-columns transition
Tests: 79 web (vitest) + 410 daemon (bun test) all green.
Bundle: ~231KB initial JS / ~24KB CSS. Shiki language grammars
code-split per-language and lazy-loaded.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… + search modal (P5)
Session header gets an inline control bar (`<SessionControls>`):
- Interrupt button — armed only when session is thinking / tool_running;
sends session.interrupt
- Rotate button — sends session.rotate (refresh skills/settings,
memory preserved)
- Mode picker — interactive / auto-allow / autonomous with one-line
hints; sends session.set_mode on change
- Model picker — Opus / Sonnet / Haiku quick picks plus full IDs and
a "custom model id" input for anything ZeroID propagates; sends
session.set_model
- Destroy — two-step confirm, sends session.destroy and locally
drops the session
Search modal (`<SearchModal>`):
- Ctrl+K (or Cmd+K) toggles; Esc closes; ↑/↓ navigate; Enter focuses
- Debounced 220ms session.search dispatch (workspace-scoped when a
session is focused, else cross-workspace)
- Renders ranked sessions with up to 2 snippet previews each
- Click a hit → focusSession()
Status bar gains a 🔍 Ctrl K affordance for discoverability.
Bundle: search modal adds ~3KB. Initial bundle still ~231KB JS / ~24KB CSS.
Tests: 79 web (unchanged for now — UI components don't have unit tests yet;
they need jsdom which is gated for P7).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-state CTA (P6)
Daily-driver polish so the cockpit feels complete:
- <WorkerIndicator>: live "thinking…" / "running <Tool> — <progress>"
row between transcript and prompt; only renders when SessionInfo
.status is thinking or tool_running. Cycles a few thinking verbs
so the spinner reads as alive.
- <NewSessionModal>: proper form for `session.create` (name + workdir).
Opens on the sidebar "+ new session" button, the empty-state CTA,
or Cmd/Ctrl+N anywhere. After dispatch, refreshes the list and
auto-focuses the new session.
- SessionListPane: sticky "+ new session" button at the top of the
sidebar with a Cmd+N affordance.
- CenterPane empty state: clear "No active session" surface with a
primary CTA + Cmd+N / Cmd+K hints. Replaces the old terse
"Select a session" placeholder.
- <SubagentChip>: in the session header, when SessionInfo.subagents
has active entries, render a "⊕ N sub" pill with hover details
(agentType + WIMSE short sub for each). Identity surfacing parity
with the TUI.
All actions go through the same protocol verbs the slash commands and
control bar use; no new client-state surface.
solid-markdown -> unified -> micromark transitively pulls in `debug`, which ships CJS with default-export semantics that need Vite's CJS-to-ESM bridge applied. Without it, the dev server fails with: SyntaxError: ... 'debug/src/browser.js' does not provide an export named 'default' (at create-tokenizer.js) Adding `debug` and `remark-gfm` to optimizeDeps.include forces Vite to pre-bundle them, which generates the synthetic default exports browsers need. Production (Rollup) builds are unaffected — this is dev-only bundling.
solid-markdown's transitive deps include several CommonJS-only packages (extend, bail, ccount, decode-named-character-reference, is-plain-obj, trough, inline-style-parser, property-information, *-separated-tokens, unified, vfile, vfile-message). Each one Vite hadn't pre-bundled manifests as 'does not provide an export named default' at runtime. Listing them in optimizeDeps.include forces the pre-bundler to wrap them with synthetic default exports. Production Rollup builds were already fine; this is dev-only. Also adding ssr.noExternal: ['solid-markdown'] defensively in case any plugin pipeline triggers SSR-style transforms.
ZeroID's /oauth2/token doesn't return Access-Control-Allow-Origin
headers, so the browser blocks the cross-origin POST from :5173 → :8899
even though curl works. Hitting it through Vite's dev proxy makes the
request same-origin from the browser's POV; Vite forwards to ZeroID
server-side (no preflight involved).
Wire-up:
- vite.config.ts: server.proxy["/oauth2"] and server.proxy["/.well-known"]
forward to VITE_ZEROID_URL (or http://localhost:8899 by default).
- lib/auth.ts: when zeroidUrl is empty, the exchange URL becomes a
relative /oauth2/token. Explicit absolute URLs still work for the
rare cross-origin case (ZeroID with CORS configured).
- state/connection.ts: VITE_ZEROID_URL default is now empty so the
relative path takes over.
Production deploys are expected to do the same routing at the
ingress/nginx layer — or ZeroID would need to grow CORS headers, which
is out of scope here.
The center pane is a flex column inside a 1fr grid row, but flex / grid children default to min-height: auto, so the transcript grew with content instead of overflowing within its row. The whole page scrolled and the prompt landed thousands of pixels below the fold. Adding min-h-0 (and min-w-0 defensively) on the CenterPane and the Transcript scroll container lets each respect its parent's bounds and scroll internally — prompt stays anchored at the bottom.
Daily-use polish: chrome should be flexible so the chat dominates when
you're heads-down working.
- state/layout.ts: persistent layout signals — leftSidebarPx (200..600,
default 280), leftSidebarCollapsed (56px rail mode), rightPanePx
(280..1200, default 576). All three serialise to localStorage on
change so reloads keep your preferred chrome.
- components/ResizeHandle.tsx: reusable 4px pointer-event drag gutter
with hover + drag highlight, ew-resize cursor, double-click to
reset, role="separator" for a11y, touch-friendly via pointer events.
- Shell.tsx: 5-column grid (sidebar | gutter | center | gutter | right
pane). Gutters mount the ResizeHandle when the adjacent pane is
visible. Right gutter + pane both collapse to 0px when no file open.
- SessionListPane.tsx: collapse button (◂) in section header; when
collapsed, renders a CollapsedRail with [▸ expand] [+ new] +
a vertical strip of 2-letter session badges (focused tinted accent).
- StatusBar: col-span-full instead of col-span-3 (now 5-col grid).
- CenterPane: explicit col-start-3 placement so it sits correctly in
the new grid.
All paths go through the same protocol verbs (no new optimistic state).
Tests: 79 passing.
Same minimise pattern as the sidebars. The full header is great for
auditing (workdir, agent WIMSE URI, the 7-tile usage strip) but takes
~5 rows of vertical space. Daily-driver mode collapses it to a single
row: name · mode · model · turns · in/out · cost · subagent chip.
- state/layout.ts: persistent `headerCollapsed` flag
- components/CenterPane.tsx: ▴ button on the full header collapses;
▾ button on the one-line view restores; toggle persists in
localStorage like the other layout flags
Tests: 79 still green. Bundle unchanged.
saucam
added a commit
that referenced
this pull request
Jun 14, 2026
…scaling) (#12) Adversarially-verified high-severity findings from a full-codebase audit. (The scarier "stuck-status" race claims were refuted on verification and are not touched here.) #1 Dropped message after interrupt-then-fast-send (session.ts). The consumer `finally` nulled #inputQueue/#consumerTask without the identity guard it already used for #query/#abortController, so an un-awaited interrupt() + a fast send() let the stale loop clobber the new loop's queue/task and the next push was silently dropped. Capture a loop-local queue/task snapshot and guard the nulls by identity. #2 Token never re-verified after handshake (auth.ts/server.ts/types.ts). An open socket honored an expired/revoked token forever. Carry `exp` into AuthContext, reject missing/expired exp in verifyToken (60s skew), and close 4003 on a per-message expiry check. (Instant revocation of a still -valid token still needs a periodic re-verify — tracked separately.) #3 Web reconnect replayed the dead JWT forever (ws.ts/connection.ts). Add a getToken() supplier called on every (re)connect open that re-exchanges the stored zid_sk_ key for a fresh JWT; fall back to the last token if none. +2 tests. #4 Vector recall had no cache despite the comment (memory/store.ts). Every recall re-read + re-decoded all embeddings and brute-forced cosine. Memoize the decoded matrix per workspace; invalidate on insert-with -embedding / setEmbedding. #5 Memory init was all-or-nothing (engine.ts). An embedder download hiccup nulled the whole engine, also killing FTS recall + usage persistence. Wrap embedder.init() in try/catch and run FTS-only (vector signal off) on failure; recall and the embed pump guard on the ready flag. #6 Unbounded session resume blocked startup (session-manager.ts, issue #6). Sort newest-first, cap to RESUME_MAX_SESSIONS, time-box to RESUME_DEADLINE_MS, and log what was left on disk. Typecheck clean; 554 daemon tests + 95 web tests pass; build OK; live smoke (auth+exp accepted, session.list) verified against the dev daemon. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This was referenced Jul 19, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A full Solid.js web UI for the codeoid daemon — feature-parity with the
TUI for chat, plus a read-only file explorer + viewer. Designed as a
daily-use coding cockpit; intentionally lightweight (no UI library;
~75KB JS gzipped initial bundle).
Stack: Solid 1.9 + Vite 6 + TypeScript strict + Tailwind 4 design
tokens. Surgical small deps for hard primitives (cmdk-style search,
@floating-ui, solid-markdown + remark-gfm, shiki, motion). State via
Solid signals + createStore. Tests: vitest in node env, 79 passing.
The daemon stays canonical: clients are pure renderers that consume
broadcasts and translate UI events into protocol verbs. Any frontend
(TUI / web / Telegram) can resume any session — no client-side
business state.
What's in the box
Chat loop (TUI parity):
identity, sub-agent count)
executing → completed/cancelled)
`/rotate`, `/mode`, `/model`, `/help`, `/clear`
Controls (chrome):
live session metrics, ⌘K search affordance, sign-out
usage strip — Turns / Input / Output / Cache R / Cache W / Agent time
/ Cost) plus a collapsed one-line mode for daily driver
rotate, mode picker (interactive / auto-allow / autonomous), model
picker (aliases + custom id), destroy with two-step confirm
hits + snippets; ↑/↓ navigate, Enter focuses
File explorer + viewer:
`session.workdir` (canonicalised + symlink-escape blocked); hidden
defaults for `.git`, `node_modules`, `target`, etc.
(lazy-loaded grammars), binary detection, truncation indicator
Layout (drag + collapse):
Identity surfacing (matches TUI):
Daemon-side changes
implementations with hard ceilings (5k entries / 10 MiB) and binary
detection.
`WATCHER_SCOPES` and `OPERATOR_SCOPES`.
`FsListResultMsg` / `FsReadResultMsg` / `FsEntry`.
scope gates and structured errors.
exhaustiveness updated for the +1 scope and +2 daemon message types.
the web/ vitest suite.
Test plan
`noUnusedLocals`)
helpers, message reducer, session store, slash parser/dispatcher)
gzipped
message → see streaming response → approve a tool → open a file
→ read it → resize panes → collapse all chrome → expand again →
reload (state persists)
Documentation
(Solid > React, no UI library, daemon as source of truth, Vite 6,
Tier-1 scope)
Out of scope (follow-ups)
sessions feel laggy
(the dev Vite proxy is dev-only)
🤖 Generated with Claude Code