Skip to content

feat: codeoid web UI (Solid + Vite + Tailwind 4) — chat cockpit + file viewer - #3

Merged
saucam merged 12 commits into
mainfrom
feat/web-ui-solid
May 5, 2026
Merged

feat: codeoid web UI (Solid + Vite + Tailwind 4) — chat cockpit + file viewer#3
saucam merged 12 commits into
mainfrom
feat/web-ui-solid

Conversation

@saucam

@saucam saucam commented May 5, 2026

Copy link
Copy Markdown
Collaborator

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):

  • Sign-in via ZeroID API key → JWT exchange (with proper scope set)
  • Live session list with per-row metrics (cost, tokens, turns, agent
    identity, sub-agent count)
  • Auto-attach on focus, scrollback replay, streaming token deltas
  • Send messages; markdown-rendered assistant responses with code blocks
  • Tool-call cards with phase badges (streaming → confirmation →
    executing → completed/cancelled)
  • Inline approval bar above prompt for waiting tool calls
  • Slash commands: `/new`, `/rename`, `/destroy`, `/interrupt`,
    `/rotate`, `/mode`, `/model`, `/help`, `/clear`
  • Per-session draft persistence (only client-side persistence allowed)
  • Worker indicator: `thinking…` / `running — `
  • Sub-agent chip with hover detail (agentType + WIMSE short sub)

Controls (chrome):

  • Status bar: connection pill, identity chip with full WIMSE on hover,
    live session metrics, ⌘K search affordance, sign-out
  • Session header: full audit mode (workdir, full agent WIMSE URI, 7-tile
    usage strip — Turns / Input / Output / Cache R / Cache W / Agent time
    / Cost) plus a collapsed one-line mode for daily driver
  • Inline session controls: interrupt (armed when thinking/tool_running),
    rotate, mode picker (interactive / auto-allow / autonomous), model
    picker (aliases + custom id), destroy with two-step confirm
  • Cmd/Ctrl+K search modal: debounced `session.search` with ranked
    hits + snippets; ↑/↓ navigate, Enter focuses
  • Cmd/Ctrl+N new-session modal

File explorer + viewer:

  • New daemon protocol verbs `fs.list` / `fs.read` scoped to
    `session.workdir` (canonicalised + symlink-escape blocked); hidden
    defaults for `.git`, `node_modules`, `target`, etc.
  • Lazy-expanding tree in the left sidebar
  • Right pane slides in on file click; shiki syntax highlighting
    (lazy-loaded grammars), binary detection, truncation indicator
  • Right pane is independently drag-resizable

Layout (drag + collapse):

  • Drag any divider to resize panes (clamped, double-click to reset)
  • Sidebar collapses to a 56px icon-rail with 2-letter session badges
  • Session header collapses to one line
  • All four states persist in localStorage

Identity surfacing (matches TUI):

  • Every message header shows identity name + ZeroID short sub
  • Hover reveals the full WIMSE URI
  • Color-coded by role (user / assistant / thinking / tool / system)

Daemon-side changes

  • `src/daemon/fs.ts`: new `handleFsList` / `handleFsRead`
    implementations with hard ceilings (5k entries / 10 MiB) and binary
    detection.
  • `src/protocol/scopes.ts`: `SCOPES.FS_READ` added; included in
    `WATCHER_SCOPES` and `OPERATOR_SCOPES`.
  • `src/protocol/types.ts`: `FsListMsg` / `FsReadMsg` /
    `FsListResultMsg` / `FsReadResultMsg` / `FsEntry`.
  • `src/daemon/session-manager.ts`: dispatch the new verbs with proper
    scope gates and structured errors.
  • `src/tests/scopes.test.ts` + `src/tests/protocol.test.ts`:
    exhaustiveness updated for the +1 scope and +2 daemon message types.
  • `package.json`: `bun test ./src/` so daemon tests don't pick up
    the web/ vitest suite.

Test plan

  • `bun run typecheck` (strict, `noUncheckedIndexedAccess`,
    `noUnusedLocals`)
  • `bun run test` — 79 web tests passing (formatters, identity
    helpers, message reducer, session store, slash parser/dispatcher)
  • `bun run build` — production bundle ~75KB JS / ~5KB CSS
    gzipped
  • `bun test ./src/` — 410 daemon tests passing
  • Manual: sign in → list sessions → focus → see scrollback → send
    message → see streaming response → approve a tool → open a file
    → read it → resize panes → collapse all chrome → expand again →
    reload (state persists)

Documentation

  • `web/docs/ARCHITECTURE.md` — orientation for new contributors
  • `web/docs/DECISIONS.md` — short ADRs for the load-bearing choices
    (Solid > React, no UI library, daemon as source of truth, Vite 6,
    Tier-1 scope)

Out of scope (follow-ups)

  • A11y: focus rings on every interactive element, ARIA labels on modals
  • Connection-drop UX: surface "reconnecting in Xs" as a top banner
  • Scrollback virtualization (`@tanstack/solid-virtual`) once large
    sessions feel laggy
  • jsdom-based component tests for ``, ``, etc.
  • Right pane upgrade to CodeMirror 6 (Tier 3)
  • Production deploy: ZeroID with CORS or daemon-side `/oauth2` proxy
    (the dev Vite proxy is dev-only)

🤖 Generated with Claude Code

saucam and others added 12 commits May 5, 2026 19:08
…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
saucam merged commit 0a1928e into main May 5, 2026
@saucam
saucam deleted the feat/web-ui-solid branch May 5, 2026 16:17
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant