Skip to content
Back to skills

Walnut Web Frontend

ASecurity

Implementation details for web/src/ (React SPA) — session chat single-timeline model (streaming blocks, render-filter absorption, optimistic dedup, turn watermark), UX patterns (task refs, images, plan cards, slash commands), frontend file structure. MUST read before touching session chat, turn boundaries, or streaming block rendering.

  • 32 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added October 1, 2026
developmentrustgoshellreactnodeapifrontendbackend

Works with

  • claude code
  • cursor
  • terminal
  • cli
  • api

Security analysis

A100/100

Scanned October 5, 2026

npx -y skills add EvanZhang008/open-walnut --skill walnut-web-frontend --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Walnut Web Frontend?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Walnut Web Frontend
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/evanzhang008-walnut-web-frontend/badge)](https://www.skillsdirectory.com/skills/evanzhang008-walnut-web-frontend)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: walnut-web-frontend
description: Implementation details for web/src/ (React SPA) — session chat single-timeline model (streaming blocks, render-filter absorption, optimistic dedup, turn watermark), UX patterns (task refs, images, plan cards, slash commands), frontend file structure. MUST read before touching session chat, turn boundaries, or streaming block rendering.
---

# Web GUI — Implementation Details

For architecture overview and routes, see project `CLAUDE.md`.

**Rendering model text (markdown, rich HTML, marked extensions, sanitize policy) lives in its own skill: `walnut-markdown-rendering`.** Load that one before changing `web/src/utils/markdown.ts` or `rich-blocks.ts`; it holds the hard rules (never patch marked, register a retune on BOTH `Marked` instances, collapse blank lines at render time only).

## UX Implementation Details

- **Source-aware message isolation**: Streaming agent responses from different sources (heartbeat, cron, triage, user-chat) render as separate message blocks. `useChat` tracks `currentSourceRef` during streaming and passes it to `upsertLastAssistant` which only merges into the last assistant message if the source matches. The turn-merger in `chatEntriesToMessages` checks `entry.source` boundaries to prevent cross-source merging on history reload.
- **Rich task references**: Session result/error messages use `[taskId|Project / Title]` format rendered as clickable pills via a custom `marked` inline extension (`ChatMessage.tsx`). Backend enriches events with task metadata (`resolveTaskRef()` in `server.ts`); frontend builds the format via `buildTaskRef()` in `useChat.ts`. Entity refs (`<task-ref>`, `<session-ref>`) also render as clickable pills. On MainPage, clicking a task ref focuses it in TodoPanel (auto-switches tab, expands groups, scrolls into view) and clicking a session ref opens the inline SessionPanel + focuses the linked task — no page navigation.
- **Inline image rendering**: Image paths in markdown text are rendered as `<img>` tags via three mechanisms in `ChatMessage.tsx`: (1) `imagePath` inline extension for bare paths in text, (2) `link` renderer override for `[text](/api/images/...)` markdown links, (3) `codespan` renderer for backtick-wrapped paths. The `link` renderer only matches local paths (`href.startsWith('/')`) to avoid hijacking external URLs. `/api/images/` URLs render directly; absolute file paths proxy through `GET /api/local-image?path=...` (`src/web/routes/local-image.ts`) with extension whitelist, traversal protection, and 50MB size limit. Clicking any image opens a lightbox modal (`Lightbox.tsx`). Works in chat, session history, and session streaming views via the `marked` singleton.
- **Tool result image rendering**: `GenericToolCall` in `SessionMessage.tsx` auto-detects images in tool results via three mechanisms: (1) Anthropic content-block JSON with `type:"image"` + base64 data → renders as `<img src="data:...">`, (2) file paths ending in `.png/.jpg/.gif/.webp` in result text → renders via `/api/local-image` proxy, (3) input `file_path`/`path`/`filename` pointing to image files → shows preview when result has no images. **Relative paths** (e.g., `screenshot.png` from Playwright) are resolved using the session's `cwd` via `resolveImagePath()` — the `sessionCwd` prop threads through `SessionChatHistory` → `SessionMessage` → `GenericToolCall`. Detection utilities in `web/src/utils/markdown.ts` (`extractContentBlockImages`, `findImagePaths`, `isImageFilePath`, `resolveImagePath`). Result prop merges streaming (`result` prop) and persisted history (`tool.result` field) paths. Images constrained to panel width via `max-width: 100%`; lightbox click-to-zoom via existing `data-lightbox-src` delegation.
- **Session streaming (single-timeline model)**: Backend-buffered per-session streaming. `SessionStreamBuffer` (`src/web/session-stream-buffer.ts`) accumulates blocks in memory; `session:stream-subscribe` RPC returns a snapshot. All stream events broadcast to all WebSocket clients — frontend filters by `sessionId` (supports multi-panel streaming). `useSessionStream` hook handles subscribe-on-switch with catch-up. **Frontend accumulation semantics live in ONE place**: `web/src/stream/stream-reducer.ts` (pure functions: lane isolation by `parentToolUseId`, msgId-boundary = new block, tool-result backfill newest-calling-first) — shared by `useSessionStream` AND `session-cache`. The server buffer is the only remaining twin (language boundary — keep it semantically aligned when touching either). Streaming tool calls reuse `GenericToolCall` from `SessionMessage.tsx` (same expand/collapse as history) — `status` prop drives icon/class (`calling`→`done`→`error`), `result` prop renders when available.
- **Session message queue**: Persistent disk-backed queue (`src/core/session-message-queue.ts`) for session chat. Messages enqueued as `pending`, drained and combined by `SessionRunner.processNext()`, cleared after `session:result`. Supports edit/delete of pending messages. Survives server restart (at-least-once delivery).
- **Session image support**: Session chat inputs reuse the main `ChatInput` component (with `showCommands={false}` and custom `placeholder`). Images are pasted/dropped/attached client-side, sent as base64 via WS RPC, saved to `~/.open-walnut/images/` server-side, and their file paths are embedded in the message text.
- **Session columns on home page**: Up to 3 session panels displayed side by side between chat and ToDo (`MainPage.tsx`). `sessionColumns: string[]` queue with FIFO eviction. Triage takes the first column slot, reducing session max to 2 when open. Panel area width graduates by column count (35%→45%→55%) via `useResizablePanel.setPct()`. Chat panel toggleable via Focus Dock button. Session pills gain `isActive` prop to highlight open columns. Legacy single-session `sessionStorage` key auto-migrates to array format. Each `SessionPanel` instance streams independently (WS broadcasts all events, frontend filters by sessionId). Session pills use a three-layer badge format: `Session · Plan/Bypass · WorkStatus / ProcessStatus` — the outer label is always "Session" (unified naming), inner mode shows Plan or Bypass. Clicking the pill directly opens the session panel (one-click via `stopPropagation`). The initial prompt (first user message) renders at the top of the session chat timeline. Mode indicators use muted labels (`📋 Plan` / `⚡ Bypass`) instead of blue accent badges.
- **Focus Dock pin state**: Shared via `FocusBarContext` (`web/src/contexts/FocusBarContext.tsx`), provided in `AppShell` inside `TasksProvider`. Both `AppShell` (renders `FocusDock`) and `MainPage` (provides pin/unpin to `TodoPanel`) consume the same context — ensures optimistic pin/unpin updates are instant (~6ms) rather than waiting for API round-trips. The `useFocusBar` hook only re-fetches on `config:changed` events with `key === 'focus_bar'` to avoid stale-config races from other config writers. **No pin count limit** — users can pin any number of tasks; Focus Dock renders only the first 3 (`FOCUS_DOCK_MAX_VISIBLE` in `FocusDock.tsx`), while TodoPanel's pinned section shows all.
- **Search child task expansion**: Search results include `parentTaskId` and `isAutoExpanded` from the backend (`src/core/search.ts`). When a parent task matches, its children are auto-inserted after it with `isAutoExpanded: true`. Frontend `SearchResults.tsx` renders children with `↳ Child` badge, left-indent (`search-result-child` CSS), and an `auto` badge for auto-expanded items.
- **Segmented filter bars**: TodoPanel uses joined segmented controls (not dropdowns) for Priority, Phase, Session, and Source filters. Icons use a unified minimalist set (○ ◐ ✓ [person-svg] ⋈ ▷ ✓✓) consistent across `PHASE_ICON`, `StatusBadge`, and filter chips. AWAIT_HUMAN_ACTION uses an SVG `PersonIcon` component (`PersonIcon.tsx`) instead of a Unicode character. Icon maps are `Record<string, ReactNode>` to support mixed string/JSX icons.
- **Slash commands**: Dual-layer system — hardcoded frontend commands (`/compact`, `/plan`, `/tasks`) + markdown-based commands (built-in `.md` in `src/data/slash-commands/` + user-created in `~/.open-walnut/commands/`). User commands override built-in by name. Backend: `src/core/command-store.ts`, `src/web/routes/commands.ts`. Frontend: `web/src/commands/` (registry + markdown-bridge), `/commands` page.
- **Session slash command autocomplete**: Session inputs (`SessionPanel`) show a command palette when typing `/`. Aggregates 4 sources: skills, Walnut commands, `~/.claude/commands/` (root), `{cwd}/.claude/commands/` (project). Backend: `GET /api/slash-commands?cwd=...` (`src/web/routes/slash-commands.ts`). Frontend: `useSlashCommands` hook with cwd-based caching and ranked search (name prefix > name contains > description contains). `ChatInput` supports `sessionCommands` prop — selecting inserts text instead of executing. Source badges show origin (Skill, Walnut, Claude, Project).
- **Plan content rendering**: In session chat, `ExitPlanMode` renders as a `PlanCard` — an accent-bordered, collapsible card with formatted plan markdown. Plan content resolved from `ExitPlanMode.input.plan` (primary) or from a preceding `Write` to `~/.claude/plans/` (fallback). Components: `PlanCard` and `CollapsedPlanWrite` in `SessionMessage.tsx`. **Live updates**: `PlanCard` consumes `PlanContentContext` (provided by `SessionPanel`) to show live plan content from `useSessionPlan` polling — this bypasses `memo()` on `SessionMessage` so both the top `PlanPreviewSection` and bottom chat `PlanCard` auto-refresh when the plan file changes. Context: `web/src/contexts/PlanContentContext.ts`. **Expand features**: `PlanCard` header has a hover-reveal expand button that opens `PlanPopup` (60vw centered popup via portal). `SessionPanel` header has an expand button that promotes the panel to 95vw×95vh fullscreen via `useFullscreen` (CSS class toggle — no new component instance). `PlanPopup` shares `useModalOverlay` for ref-counted scroll lock + Escape support; `useFullscreen` participates in the same scroll-lock ref count via the exported `lockScroll`/`unlockScroll` from `useModalOverlay.ts`. z-index: session=9000, plan=9100, lightbox=9999. **Fullscreen exits live in the hook, never per consumer**: on a pathname change, and on the `fullscreen:yield` window event that MainPage's two column-opening entry points (`openSessionOrToast`, `openDraftColumn`) fire via `yieldFullscreen(reason)`; without it a toast's "Open session ↗" or the panel's own Fork chip opened the new column BEHIND the sheet. `openSessionOnHome` dispatches `main:open-session` (the yield) BEFORE `armSessionInboxLink` (re-enters fullscreen on the Inbox tab) so an already-fullscreen panel lands on the letter. Ratchet: `tests/e2e/browser/fullscreen-yields-to-column-open.spec.ts`.
- **Session header actions**: Only four chips stay visible outside the ⋮ kebab — **Fork / Changed / Files / Terminal**. Everything else (copy CWD/ID/Resume, Restart, Investigate, Notes, Msgs, Plan & Execute) lives in the kebab's `Session` section. The kebab is the shared `TaskQuickActions` (`slot="kebab"`); it takes an `extraSection={(close) => …}` render-prop so one dropdown holds Task actions (top) + Session actions (bottom, `SessionKebabSection.tsx`). The kebab now renders even for a task-less session (when `extraSection` is supplied). `SessionForkButton.tsx` is the standalone Fork action (calls `POST /api/sessions/:id/fork` with `create_child_task: true`; backend auto-creates a child task then forks; navigates to `/tasks/{newChildId}`). Wired into `SessionPanel`. Exit-fullscreen uses the shared `ICON_COLLAPSE` (lucide minimize-2, arrows inward).
- **Service links open in the Web view**: a click on an http(s) link in a session's chat column whose host is loopback (`localhost`, `127.x`, `0.0.0.0`, `::1`), the session's host, or any configured host (with a port) opens `SessionWebView` in the split instead of a browser tab. The decision is made at CLICK time in `SessionPanel` (`handleServiceLinkClick` on `.session-panel-chat-col`, classifier `web/src/utils/service-link.ts`), so rendered HTML stays a plain `<a target=_blank>` everywhere; ⌘/Ctrl/Shift/middle clicks, anchors marked `data-native-link`, and a console not served from loopback keep native behavior. The server (`POST /api/sessions/:id/service-preview`, `src/core/session-service-preview.ts`) picks the serving host, opens an SSH forward through `DaemonConnection.ensurePortForward(port, target)` for a remote one, probes the page (refused vs slow, X-Frame-Options / frame-ancestors), and answers the 127.0.0.1 URL the iframe loads. Loopback/host matching rules live in ONE zero-import module shared by both sides: `src/core/service-url.ts` (`@open-walnut/service-url`). The kebab's "Web preview" opens the view empty for a typed address (`8080`, `:8080/x`, `host:port`). Ratchets: `tests/web/routes/sessions-service-preview.test.ts`, `tests/e2e/browser/session-service-preview.spec.ts`.
- **Quick Add behavior**: The "Quick add task..." form in `TodoPanel.tsx` always creates tasks under `Inbox / Quick Start` (hardcoded `category` + `project`). After creation: if on the All tab, stays (task visible); any other tab (Star, category tabs) auto-switches to the Inbox tab. The new task is auto-focused and scrolled into view via `onFocusTask`. A `focusHandledRef` prevents race conditions when the task arrives via WebSocket after `focusedTaskId` was set.
- **Multi-level child task nesting**: `TodoPanel.tsx` computes `depthMap` (task ID → nesting depth) via recursive parent-chain walk in the child-task `useMemo`. `isChildHidden` walks the full ancestor chain — collapsing any ancestor hides all descendants. Depth-based `paddingLeft` (20px per level, max depth 10) replaces the old fixed `.todo-panel-item-child` CSS class for unlimited nesting support.
- **Memory Browser** (`/memory`): Split-view page for browsing and editing all memory files. Left panel (`MemoryTreePanel`) shows collapsible sections (Global, Daily Logs, Projects, Sessions, Knowledge) with filter input. Right panel (`MemoryContentPanel`) renders selected file as markdown with an Edit toggle — clicking Edit switches to a monospace textarea for raw markdown editing with Save/Cancel buttons and keyboard shortcuts (Cmd+S, Escape). Edit state resets on file switch. Backend: `GET/PUT /api/memory/browse|global` + `GET/PUT /api/memory/*` (path-based with traversal protection). URL state via `?path=` query param. Resizable left panel with localStorage persistence.

- **Browser console log persistence**: `web/src/utils/browser-logger.ts` monkey-patches `console.log/info/warn/error` to persist browser logs to disk. Initialized in `main.tsx` before React mount. Logs sent via WS RPC `browser:logs` (batched every 2s or 50 entries) with `sendBeacon` fallback on page unload. Backend writes to the same log file (`subsystem: 'browser'`). View with `open-walnut logs -s browser`. See `src/logging/AGENTS.md` for full details on investigating frontend issues.

## Session Chat — Single-Timeline Model (streaming blocks + persisted history + optimistic bubbles)

Session chat renders three data sources: **persisted JSONL history** (server-parsed from Claude Code output files), **streaming blocks** (WS deltas accumulated in `useSessionStream`), and **optimistic messages** (client-side state in `useSessionSend`). The core challenge is showing content immediately and gracefully converging to persisted data, without duplicates or disappearances.

### The single-timeline invariant (2026-07 refactor — read before touching turn boundaries)

The old model DELETED streaming blocks at turn boundaries via evidence reconciliation (`clearCompleted`, `completedLen`, `awaitingRefresh`, deferred clears, 5s fallback timers). Every new CLI stream format broke one of the heuristics → 7 same-shape "message vanished / pinned at bottom / looks unfinished" incidents in 2026-06~07. The refactor inverts the failure mode:

- **Streaming blocks are APPEND-ONLY.** No event handler deletes blocks. `session:result` only stamps `completedLen` (a MERGE boundary for the text accumulator, nothing else).
- **Absorption is a render-time filter**, not a mutation: `web/src/stream/render-filter.ts` (`computeRenderFilter`) hides a block iff current history PROVES it was absorbed — msgId twin (watermark-immune), toolUseId twin (incl. `childMessages` recursion), content twin within the turn window, or parent-Agent `bgTaskFinished` for subagent-lane blocks. Matching rules live in `web/src/cache/promote-blocks.ts` (`computeAbsorbedIndices`) — the exact semantics that survived the incident chain.
- **Failure inverts**: a missed match means a block briefly renders TWICE (next to its history twin) and collapses when evidence lands; it can never vanish. Unmatched finished blocks log a `render-filter: … no delta twin` warning — a persistent one means archive & stream genuinely disagree (real bug, surface it).
- **Turn watermark** (`SessionChatHistory`): history length when the current turn started streaming (`isStreaming` false→true edge; seeded at first history load). Content matching for id-less blocks only trusts `messages[watermark..]`; id matching is scope-safe at any range. On /compact shrink the slice clamps empty → content matching pauses, ids unaffected.
- **Live-tail guard**: while streaming, the last MAIN-lane block (`lastMainLaneIndex`) is never matched: its partial content / early-known msgId must not hide it mid-accumulation. Exception: an Agent/Task tool_call tail IS matched (exact toolUseId; its persisted row is the anchor we want, see the subagent bullet below).
- **Subagents follow the Claude Code model: the lane never renders in the main conversation** (`web/src/stream/group-blocks.ts`, 2026-09-08). One compact Agent card sits at the spawn position (streaming `StreamingTaskGroup` while the tool_call is in the buffer, collapsed by default; the persisted `TaskGroup` the moment history absorbs it), the Background ledger (`WorkflowProgress`, pinned above the scroll area, fed by `session:background-tasks`) carries the run with per-agent elapsed/tokens/tool-uses/activity and "View transcript", and every lane block (any `parentToolUseId`) is CONSUMED: inside the card when opened, nowhere when its anchor is hidden or gone. There is no "orphan group" any more, and a groupable parent absorbs on its bare toolUseId twin like any tool_call (`promote-blocks.ts`). Two designs preceded this and both put the same agent on screen twice: an anonymous "Subagent (continued)" box at the tail (inc-1785965937858), then a labeled live duplicate kept "as the safe direction" while the ✓ history card already rendered (the user could not tell which was current). Lane blocks of a running agent stay HELD silently (the ledger reads them; `allBlocksAbsorbed` waits for `bgTaskFinished`), just not rendered. Pinned by `tests/web/chat-lab/scenarios.test.ts`, `tests/web/stream/group-blocks-nested-lane.test.ts`, `tests/e2e/browser/subagent-interleave.spec.ts`.
- **Memory reclamation is all-or-nothing**: `resetIfAbsorbed` in `useSessionStream` drops the whole array only when every block is hidden and no turn is live (`allBlocksAbsorbed`). Because reset is atomic and blocks are append-only, **array indices are stable render identities** — no blockSeq needed; `blockIndexMap` bubble anchors are set-once and clamp to 0 on full reset.
- `session-cache`'s streaming state is a background accumulator; `gcAbsorbedBlocks` is memory-bound GC only (correctness lives in the render filter, never in the cache).
- Fault-injection e2e pins the invariants: `tests/e2e/browser/single-timeline-fault-injection.spec.ts` (batch-before-result race, held refetch, /compact shrink).
- **History refetch triggers** (`historyVersion` bumps in `SessionChatHistory`): `session:batch-completed`, `session:error` (not `delivery_failed`), `_ws:reconnected`, `isStreaming` true→false, and the **turn-START edge** (`turn-prompt-refetch.ts`): the live turn's first MODEL-output block (text/thinking/tool_call/permission) while no user row ≥ watermark and no optimistic bubble exists. That edge exists because the CLI writes the launch prompt to the transcript only after it boots (3-7 s after spawn), AFTER the panel's opening fetches — without it the first message stayed hidden until turn end (2026-09-03). System blocks never qualify: hook/init notifications land ~20 ms BEFORE the user line.
- **Launch-prompt bubble** (2026-09-28, the edge above still left 13 s of "working…" over an empty timeline): the server keeps each native launch's prompt in memory until the first turn ends (`src/core/sessions/launch-prompts.ts`, fed by `session:start`, skipping forks, side-thread lanes and ACP engines) and `session:get-queue` returns it as `launchPrompt` BESIDE the queue. `useSessionSend` prepends it as a `delivered` bubble flagged `launch` (seeded synchronously from `launch-prompt-seed.ts` in the tab that pressed Start, so the pending column's echo never blinks out at promotion); `SessionChatHistory` renders it at the HEAD of the conversation, above persisted rows and outside the live timeline, so a transcript that never yields its row can't leave it pinned under the answer; `optimistic-dedup.ts` absorbs it by POSITION (the first typed user row, `launchRowIndex`), never by text or watermark, because the row can carry Walnut's wrappers and the bubble can arrive after the watermark passed it (a tail-windowed history, `historyFromStart: false`, counts it absorbed: its row is above the window). Pinned by `tests/web/optimistic-dedup-launch.test.ts`, `tests/core/launch-prompts.test.ts`, `tests/e2e/browser/launch-prompt-first-message.spec.ts`.

### How Claude Code JSONL records user messages

Claude Code CLI writes JSONL output (one JSON object per line). User messages appear via 3 patterns:

| Pattern | When | JSONL entries | Reliable? |
|---------|------|---------------|-----------|
| **A** (FIFO mid-stream) | User sends while CLI is running a turn | `queue-operation:enqueue` → `queue-operation:dequeue` → normal `human_turn_start` | Usually, but CLI may finish before reading FIFO |
| **B** (FIFO unprocessed) | FIFO msg not consumed before turn ends | `queue-operation:enqueue` only (no dequeue) — parser synthesizes user msg | Yes (enqueue always logged) |
| **C** (--resume) | No running process → server spawns `claude --resume -p "msg"` | Normal `human_turn_start` | Always |

Server-side parser (`src/core/session-history.ts`): matches enqueue→dequeue pairs (Pattern A skips enqueue, uses the user message that follows). Unmatched enqueues (Pattern B) are synthesized as user messages at their chronological position.

**`remove` has two meanings (CLI 2.1.280).** A send that arrives while a tool runs is handed to the model at the next tool boundary as an `attachment` line of type `queued_command` (its `prompt` is the text, or a content array for an image send), then the CLI logs `queue-operation:remove`. A Stop with `cancel_queued` also logs `remove`, with no attachment. Only the second hides the enqueue; the first stays a Pattern B row at its enqueue. Treating every remove as a drop hid nearly every mid-turn send and left its "Delivered" bubble below the later turns (inc-1790637095180, `tests/core/session-history-queued-command.test.ts`).

### Optimistic message lifecycle

```
pending → received → delivered → committed → (deduped by persisted history)
```

| Status | Trigger | Meaning | Visual |
|--------|---------|---------|--------|
| `pending` | `send()` | Exists only in React state | Grey |
| `received` | WS RPC response | Server acknowledged, queueId = real messageId | "Queued" badge |
| `delivered` | `session:messages-delivered` event | Written to FIFO or --resume spawned | "Delivered ✓" badge |
| `committed` | `session:batch-completed` → `handleBatchCompleted` | CLI consumed the message | Normal text |
| (hidden) | Render dedup (`consumedQueueIds` sticky set) | Persisted history now contains this message | — |
| (removed) | GC notification effect → `onBatchCompleted(0, ids)` | Post-render cleanup of hidden bubbles | — |

### Dedup logic (critical — was source of a disappearing-message bug)

**Problem:** Text-based dedup against ALL persisted history caused false matches. If the user previously sent "hi" in turn N, and sent "hi" again mid-stream in turn N+1, the new optimistic "hi" was matched against the OLD persisted "hi" and removed.

**Solution (two-tier dedup, `optimistic-dedup.ts`):**

- **Non-committed** (pending/received/delivered): Only dedup against messages that appeared since the **turn watermark** (`messages[watermark..length]`). Old persisted messages cannot absorb new optimistic messages. (The watermark replaced the old `prevMsgLen` freeze dance — it advances only on the `isStreaming` false→true edge, so no per-render bookkeeping.)
- **Committed**: Dedup against ALL persisted messages. Safe because committed = CLI consumed it, so the persisted copy exists somewhere.

Both tiers use multiset (count-based) matching for correctness with duplicate texts, with a `walnutMessageId` id-first pass (echo-claim) before any text matching. Deduped bubbles enter the sticky `consumedQueueIds` set (render tombstone); a post-render effect notifies `useSessionSend` (`onBatchCompleted(0, ids)` — count 0 so the count-fallback path can't misfire) to physically remove them.

**The index window alone is not enough (inc-1790922678361, 2026-10-02).** A bubble carries its enqueue time (`timestamp`: the server's `enqueuedAt`, returned by `session:send` and carried on `session:message-queued`; the client clock only until the RPC answers), and every row carries the CLI's timestamp. Two more ways a row proves a bubble, both in `optimistic-dedup.ts`: (a) a user row BELOW the watermark still counts when it is not older than the bubble's enqueue minus `BUBBLE_CLOCK_SLACK_MS` (10 s; NTP drift is sub-second and every second of slack lets an identical earlier send absorb the new bubble early), so a row that landed under a watermark that moved on, or under a re-seeded one, is still found; (b) when the history is a tail window (`historyFromStart: false`), a `delivered` bubble enqueued before the window's first row by more than the slack is absorbed by position, like the launch bubble, because its row sits above what is loaded. The incident: a redeploy mid-turn left the server's history cache cold, the panel's refetch came back as a byte-bounded tail window starting at the compaction, and six mid-turn sends whose rows sat above it stayed "Delivered ✓" under every later turn until a reload (deltas only append; `session:batch-completed` is not a removal signal and its `ids` under-report). Only `delivered` qualifies for (b): pending/received never reached the CLI, and inside the window a missing row is a real mismatch that must stay visible. Pinned by `tests/web/optimistic-dedup-window.test.ts` and `tests/e2e/browser/bubble-window-swap.spec.ts`.

### Key files

| File | Role |
|------|------|
| `web/src/stream/stream-reducer.ts` | THE single copy of frontend accumulation semantics (lane isolation, msgId boundary, tool-result backfill). Pure functions. |
| `web/src/stream/render-filter.ts` | Non-destructive absorption: `computeRenderFilter` (hidden set + unmatched diagnostics), `allBlocksAbsorbed` reset gate. |
| `web/src/cache/promote-blocks.ts` | `computeAbsorbedIndices` = the evidence-matching core (id-first, content multiset, join-run, childMessages, bgTaskFinished). `promoteCompletedBlocks` = destructive wrapper used only for cache GC. |
| `web/src/components/sessions/SessionChatHistory.tsx` | Renders persisted + streaming (filtered) + optimistic. Turn watermark. Bubble dedup + GC notification. Timeline interleaving. |
| `web/src/components/sessions/optimistic-dedup.ts` | Bubble dedup (id pass + watermark-windowed multiset). |
| `web/src/hooks/useSessionSend.ts` | Optimistic state machine (pending→committed). |
| `web/src/hooks/useSessionStream.ts` | Streaming blocks from WS (append-only; `resetIfAbsorbed` for memory reclamation). |
| `web/src/cache/session-cache.ts` | Background accumulator for offscreen sessions + history LRU. `gcAbsorbedBlocks` = memory GC only. |
| `web/src/hooks/useSessionHistory.ts` | Fetches persisted history via REST (cursor/delta transport, stale self-heal). Re-fetches on `historyVersion` change. |
| `src/web/session-stream-buffer.ts` | Server-side accumulator twin (language boundary — keep semantics aligned with stream-reducer). |
| `src/core/session-history.ts` | Server-side JSONL parser. Pattern A/B/C handling. |
| `src/providers/claude-code-session.ts` | FIFO write (`writeMessage`), `processNext` (FIFO→--resume fallback). |
| `src/core/session-message-queue.ts` | Disk-backed message queue. Survives server restart. |

## Frontend File Structure

```
web/src/
├── App.tsx              # Routes (see CLAUDE.md routes table)
├── pages/               # MainPage, DashboardPage, SearchPage, SettingsPage,
│                        # TaskDetailPage, ChatPage, CronPage, UsagePage
├── components/
│   ├── chat/            # ChatPanel, ChatInput, ChatMessage
│   ├── tasks/           # TodoPanel, TaskList, TaskCard, TaskForm, SubtaskList
│   ├── context/         # ContextInspectorPanel, ContextSection, ToolCard, ApiMessageBlock
│   ├── sessions/        # SessionPanel (the ONLY session surface), PlanPopup, SessionChatHistory
│   ├── settings/        # SettingsNav, inputs/ (SectionCard, SecretInput, ToggleSwitch, NumberInput,
│   │                    #   ListEditor, KeyValueEditor, StatusIndicator), sections/ (9 section components)
│   ├── memory/          # MemoryTreePanel, MemoryContentPanel
│   ├── usage/           # UsageSummaryCards, UsageDailyChart, UsageBreakdownTable, UsageRecentTable
│   ├── cron/            # Cron job management components
│   ├── layout/          # Sidebar, AppShell
│   ├── search/          # SearchBar, SearchResults (child task display with indentation)
│   └── common/          # EmptyState, LoadingSpinner, StatusBadge, PriorityBadge
├── hooks/               # useTasks, useChat, useWebSocket, useSessionStream, useResizablePanel,
│                        # useSessionHistory, useContextInspector, useCronJobs, useFavorites, useOrdering,
│                        # useUsage, useSettingsConfig, useModalOverlay
├── api/                 # REST client: tasks, sessions, search, config, ws
├── utils/               # Shared utilities (markdown rendering, session-status, time)
└── styles/globals.css   # Theme
```

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.