Arena is a new pane kind for competitive AI evaluation. A Battle runs
the same prompt against 2-6 Contestants across two concurrent lanes:
local lane (llama-swap models, serial) and cloud lane (parallel).
Added to all three registries: @boocode/contracts WsFrameSchema,
server InferenceFrame, and web WsFrame.
Backend (apps/coder):
- arena-runner: battle scheduler, lane classifier, benchmark, results
writer, resume, user winner override
- arena-analyzer: two-stage digest→judge analysis on DEFAULT_MODEL
- arena-decisions: status transitions and resume logic (unit-tested)
- arena-analyzer-helpers: pure helper functions (unit-tested)
- arena-model-call: model call utility for analysis
- arena routes: create/get/list/stop/analyze/cross-examine/winner/diff
- schema: battles, contestants, cross_examinations tables (idempotent)
- remove old /api/arena* routes and tasks.arena_id column
Frontend (apps/web):
- ArenaLauncherDialog: battle type, prompt, contestant selection
- ArenaPane: live roster, streaming output, analysis, cross-exam
- DiffView: unified diff with line-by-line color for coding contests
- Winner override per-row dropdown (Trophy icon)
- battle_updated WS handler for live winner/analysis updates
- arena pane kind in Workspace, ChatTabBar, useSidebar
Cross-app:
- ArenaState and ArenaContestantShape/WsFrame types (contracts)
- battle_* frames in WsFrameSchema, InferenceFrame, and web WsFrame
- manifest.json written per battle results folder
- /Arena added to .gitignore
Brings the deterministic Han-flow conductor into BooCode: launch any read-only
flow from BooChat or BooCoder, watch each agent stream live in a Paseo-style
run pane, get an evidence-disciplined report — on local Qwen, persisted and
resumable. Read-only enforced hard via qwen --approval-mode plan (orchestrator
tasks fail closed if qwen is unavailable; never fall to write-capable native).
Backend (apps/coder): re-homed conductor defs, flow_runs/flow_steps schema,
flow-runner + dispatcher onTaskTerminal hook, restart-resume, runs routes
(launch/list/get/cancel), user-channel WS. Contracts: two flow_run_* frames.
Web: orchestrator pane kind + OrchestratorPane, Workflow button + slash flows
(BooChat/BooCoder parity), FlowLauncherDialog, "New Orchestrator" in the + and
split menus, runs history + export. Plan: openspec/changes/orchestrator.
Second checkpoint of in-flight work (sessions route, api types, ChatTabBar,
PaneHeaderActions, Workspace, useWorkspacePanes) so the Orchestrator branch
can rebase onto current main before merge.
Checkpoint of in-flight work so the orchestrator branch can rebase onto a
clean main: ContextBar → ContextMeter, model-label helper, model/agent picker
+ provider-snapshot/registry changes, inference payload + message-columns.
Adds a Git tab to the right-side file panel that shows the project
repository's diff and lets the user stage, unstage, commit, and discard
whole files in-session. Two comparison modes (Uncommitted vs HEAD, and the
branch vs its base — upstream tracking branch else default branch), auto-
selected by repo state on first open and pinned after explicit choice;
per-file expand/collapse with lazy syntax-highlighted diffs, +/- stats, and
binary/large-file placeholders. All git read and write logic lives in
apps/server via a new git_diff service: argv-safe execFile only (never a
shell), per-file paths validated repo-relative through pathGuard with a
realpath symlink-escape check, server-derived commit identity (the request
carries no author fields), and the write endpoints are deliberately absent
from the assistant tool registry. Reads are bounded (30s deadline, 10MB);
an index lock or an in-progress merge/rebase/cherry-pick/bisect surfaces as
"repository busy" and disables writes. The panel stays current via a client
git_diff_refresh session event (no new wire contract) coalesced across tab
open, mutations, turn completion, and pending-change apply. Discard is an
irrecoverable hard-delete behind a plain confirm that distinguishes
reverting a tracked file from deleting an untracked one.
Move all hand-synced cross-app wire contracts into one built workspace
package, @boocode/contracts, consumed by server/web/coder/coder-web via
workspace:* + a per-subpath exports map. The ws-frames and provider-config
Zod schemas are schema-first (z.infer); MessageMetadata, ErrorReason,
AgentSessionConfig, the provider snapshot types, and WorktreeRiskReport are
each single-sourced. Deletes the byte-identical copies and their parity
tests, fixes a live AgentSessionConfig drift (coder dead copy removed,
unified to the web required/nullable shape), removes the dead pending_change
WS arms in the fallback SPA, and inverts the build order (contracts builds
first) across root build, Dockerfile, and the coder deploy docs. Reverses
the shared-package decision declined in v2.5.12.
In-flight workspace UX work.
- Extract a shared PaneHeaderActions cluster (+/Split/Reopen/History/Close)
used by ChatTabBar + the Workspace coder/terminal pane headers, replacing the
divergent per-header copies; SessionLandingPage history + useWorkspacePanes
tweaks.
- Fix coder-side correctness bug: resolveChatId read sessions.workspace_panes as
a bare WorkspacePane[] but v2.6.5 widened it to a WorkspaceState envelope, so
it mis-read panes and clobbered tabNumbers/nextTabNumber/closedPaneStack on
every pane-chat write. New normalizeWorkspaceState handles either shape and
preserves the envelope (+ regression test).
- CLAUDE.md doc-sync (coder vitest suite, deploy-by-surface, dual-remote push,
in-flight-web-WIP staging, release-branch naming).
Web tsc + coder build + coder tests green. Builds on v2.7.6.
Scoped half of boocode_code_review_v2 §1 #10 — publish the agent status
BooCoder already observes (the config-injection notify-hook is the documented
follow-on, clean-room from superset ELv2).
- agent_status_updated WS frame (working|blocked|idle|error), server+web parity.
- Published from the dispatcher's turn boundaries (warm-acp/opencode/sdk/pty:
working at start, idle/error at end) + the permission flow (blocked/working).
Best-effort, never breaks a turn.
- Clean-room normalizeAgentEvent helper (superset's vendor-event -> Start/blocked
/Stop collapse, event names as facts) + 25 tests — reused by the follow-on.
- AgentComposerBar status dot (distinct from the WS-liveness dot), tracked per
(chat,agent) by a useAgentStatus map in CoderPane.
Built by 2 parallel agents vs a pinned frame contract. Server 545 + coder 294
tests passing (25 new); web tsc + builds clean; ws-frames parity green. Clears
the actionable review backlog (#1/#3/#4/#6-#12). Builds on v2.7.5.
DiffPanel renders a per-row agent badge (icon+label; null -> 'manual') + a 'Changes from X, Y' note when the pending set spans >1 agent. AgentComposerBar gains an optional sessionId prop -> resumed/history/new-session chip beside the Provider picker (gated, so BooChat callers are unchanged), driven by a new useAgentSessions hook (refetch on message-complete). providerIcon extracted to shared components/coder/providerIcons.tsx; api.coder gains agentSessions(sessionId); PendingChange type gains agent. web tsc clean.
A cohesive batch of pane/tab UX + the persisted workspace-state model (grouped
because the changes interleave across useWorkspacePanes, ChatTabBar, Workspace,
sessionEvents and the api types/client):
- Open a whole chat in a fresh pane via a new open_chat_in_new_pane event:
ChatTabBar tab context menu "Open in new pane", and MessageBubble.fork() now
lands the fork beside the original instead of replacing the active pane.
openChatInNewPane detaches the chat from any pane already holding it
(one-chat-per-pane).
- The tab-bar "+" becomes a New BooChat/BooTerm/BooCode menu (chat as a tab,
term/coder as split panes); the split button is unchanged.
- Drop the per-message "Open in pane" button (it opened a single message's
artifact) and its dead code; the artifact-pane machinery is left orphaned for
a later teardown.
- Session history: the empty/landing pane lists the session's open chats plus
archived chats (fetched separately), click to open / restore-and-open.
- Relocate-on-close: closing a chat pane moves its tabs (in order) into the
oldest chat/empty pane instead of discarding them; terminal/coder panes close
as before. Reopen strips the restored chatIds from all live panes first, so a
relocated-then-reopened pane never duplicates a tab — no stack-shape change.
- Stable global tab numbering: tabNumbers/nextTabNumber assigned on chat-pane
open, retired on close (never reused), rendered map-keyed (not positional).
- workspace_panes is now a WorkspaceState envelope { panes, tabNumbers,
nextTabNumber, closedPaneStack }; the reopen stack moved from a module-level
array into the persisted envelope so it survives reload. Hydrate/persist
normalize the legacy bare-array shape. appendClosed dedupes a value-identical
top entry to neutralize the StrictMode double-invoke of the setPanes updater.
Two independent fixes:
- opencode-server.ts: stripDcpTags() removes <dcp-message-id>…</dcp-message-id>
tags from text deltas before they reach the frame/DB. Applied to all three
text paths (session.next.text.delta, message.part.delta text field,
handleUpdatedPart text type). Reasoning/tool paths untouched.
- useWorkspacePanes.ts: module-level closedPaneStack (capped at 10) captures
pane kind + chatIds on removePane and removeTab auto-remove. reopenPane()
pops the stack and re-attaches a new pane to the existing chat ids (chats
survive pane close server-side). hasClosedPanes drives conditional render.
- ChatTabBar.tsx: [+] is now instant new-tab (no dropdown); split-pane
dropdown (Columns2 icon) opens Chat/Term/Code in a new pane; reopen button
(RotateCcw icon) appears when closed panes exist.
- Workspace.tsx: pass reopenPane + hasClosedPanes through to ChatTabBar.
Two independent UI/UX fixes:
- auto_name.ts: pass the session's own model as fallbackModel to
taskModelCompletion, so chat rename uses whatever model is already
loaded on llama-swap instead of forcing a swap to DEFAULT_MODEL
(which times out at 10s when a different model is active).
- useWorkspacePanes.ts: when the last tab in a pane is closed and
other panes exist, remove the pane entirely instead of leaving an
orphaned empty panel.
Opening the settings pane on mobile set activePaneIdx, but the ?pane= URL-sync
effect snapped it back to the chat pane on the panes change, so the pane never
showed. toggleSettingsPane now returns the new pane id (id generated outside the
updater, strict-mode safe); Session's toggleSettingsAndSync pushes ?pane=<id> on
mobile when opening (and drops it on close) so the sync effect keeps it active —
mirrors the existing addPaneAndSwitch pattern. Desktop unaffected.
Coder / menu now shows two groups: the active agent's commands first (manifest + live ACP available_commands), BooCoder skills second. SlashCommandPicker gains an opt-in groups prop (flat items path unchanged -> BooChat byte-identical, parity verified); ChatInput takes slashGroups; CoderPane builds the groups. Skills run under the selected agent: coder skill_invoke accepts a provider and, when external, injects the server-side skill body into a dispatched task instead of native inference. Also folds in the initial-chat skill fix (handleLandingSkill: create chat -> assign to pane -> invoke, same transition as a text send) that resolves the landing-page blank screen. BooChat slash menu + skill invocation unchanged.
Bundles in-progress working-tree UI work not authored this session (CoderPane ChatInput migration, AgentComposerBar/CoderMessageList/tab-bar/sidebar/pane refinements, provider icons) with this session's changes to the same files: MessageBubble renders a collapsible 'Thinking' block from reasoning_text/reasoning_parts (surfacing ACP agent_thought_chunk + native reasoning), and the DiffPanel approve/reject calls are repointed to the real /api/coder/pending/:id/apply and /reject routes (the old /sessions/:id/pending/:id/approve|reject paths did not exist).
useViewport re-syncs the snapshot on pageshow/visibilitychange/resize/orientationchange — iOS reported a stale width on backgrounded-tab restore, leaving isMobile=false so the sidebar rendered as a permanent column with no close affordance. flattenToMessage now inserts pasted-text chips verbatim instead of wrapping them in a triple-backtick fence.
Integrates BooCoder as a 'coder' workspace pane within the existing
BooChat SPA at code.indifferentketchup.com. Renamed the placeholder
'agent' pane kind to 'coder' across all types, menus, hooks, and
mobile switcher (Icon: Code instead of Bot).
CoderPane.tsx: split layout with chat area (messages via WS to
boocoder:9502, input bar posting to /api/coder/sessions/:id/messages)
and diff panel (pending changes with Approve/Reject per change plus
Approve All/Reject All). Reuses MarkdownRenderer for message content.
Proxy: Vite dev config adds /api/coder → boocoder:9502 (ordered above
/api per CLAUDE.md proxy-ordering rule). Production: Fastify route in
apps/server/src/index.ts proxies /api/coder/* to http://boocoder:3000
via fetch() pass-through. WS connects directly to :9502 (same
Tailscale network, no proxy needed for WebSocket upgrade).
WorkspacePaneKind mirror updated in both apps/web and apps/server
types. useWorkspacePanes gains coderPane() factory (replaces the old
agent toast stub). Workspace.tsx switch renders CoderPane for
pane.kind === 'coder'.
Every assistant message gets an "Open in pane" affordance that opens the
message in the workspace splitter — Markdown pane (Copy + Download .md) by
default; HTML pane (Download .html only) when the model emits a self-contained
<!DOCTYPE html> or fenced ```html artifact. BOOCHAT.md rule keeps Markdown
default at every length; HTML opt-in on explicit user request.
Backend: services/artifacts.ts (slug derivation + write helpers with
symlink-escape guard via realpath-after-mkdir), routes/artifacts.ts (POST
download + GET stream with nosniff + CSP sandbox defense-in-depth), HTML
detection in finalizeCompletion writing a new message_parts.kind='html_artifact'
row (schema CHECK extended via v1.13.13 pattern), graceful 1MB cap via the
pure decideHtmlArtifactWrite helper. PartKind union extended.
Frontend: MarkdownRenderer.tsx extracted from MessageBubble's inline
MarkdownBody for reuse; MarkdownArtifactPane.tsx + HtmlArtifactPane.tsx with
loading/error states; pane state is reference-only ({chat_id, message_id,
title}) — content fetched on mount to keep workspace_panes jsonb small and
avoid 1MB blobs riding session_workspace_updated frames. iframe sandbox
locked to allow-scripts allow-clipboard-write allow-downloads with no
allow-same-origin, srcDoc not src. openInPane discriminates 404 (expected
fallback) from real errors (toast + bail). PanelRightOpen icon button with
mobile 44px tap-target.
31 new server unit tests including a real-symlink filesystem case; 332/332
server tests passing, tsc clean both sides, pnpm -C apps/web build green.
Smoke deferred to first deploy.
First half of the WebSocket-frame-typing batch (split per recon — total
scope was ~535 LoC, larger than the roadmap's ~300 estimate, so the
server-side publish-site conversion lands separately in v1.13.11-b).
Phase A scope:
(1) apps/server/src/types/ws-frames.ts (NEW) — Zod schemas for all 27
wire-format WS frame types. Discriminated union (WsFrameSchema) plus
KNOWN_FRAME_TYPES const for diagnostic lookup. UUIDs are z.string().
uuid(); model-emitted tool_call_id stays z.string().min(1) since OpenAI-
compatible APIs emit "call_<random>" not UUID. Per-kind payload narrowing
(tool args, message_parts payloads) intentionally stays z.unknown() —
frame-level drift detection is the goal; deep payload validation is
follow-up work.
(2) apps/web/src/api/ws-frames.ts (NEW) — byte-identical mirror of the
authoritative server file. No path alias from web→server in the existing
tsconfig setup; sync-by-hand was chosen over a new packages/shared/ dir.
A ws-frames.test.ts test asserts the two files match.
(3) apps/server/src/services/broker.ts — adds publishFrame() and
publishUserFrame() methods to the Broker interface. Both validate via
WsFrameSchema and fail-closed: log + drop on invalid. createBroker now
accepts an optional FastifyBaseLogger so validation failures land in
the pino stream (with console.error fallback for unit tests). The
existing publish() / publishUser() raw methods stay legal — they get
converted to the typed variants in v1.13.11-b.
(4) apps/web/src/hooks/useSessionStream.ts + useUserEvents.ts — wrap
ws.onmessage with WsFrameSchema.safeParse. Fail-closed: invalid frames
log + return without dispatching. Hand-maintained WsFrame and
SessionEvent types stay in place; one cast bridges Zod-typed → narrowed
shape (Zod uses OpaqueObject for nested Message[] / WorkspacePane[] etc.,
which are dev-time-narrowed via the existing hand-maintained types).
(5) apps/web/package.json — adds zod ^3.23.8 as a direct dep. Was a
transitive dep via ai-sdk / postgres; promotion makes the import legal.
(6) Tests: 15 new in ws-frames.test.ts covering happy-path per major
frame type, drift-catchers (unknown type, invalid enum, non-UUID, negative
tokens), parts-authoritative read variants, the mirror-file diff check,
and four broker fail-closed scenarios. 219/219 server tests pass (was
204; +15 new).
Two recon corrections to the dispatch brief, both flagged before
implementation:
- No 'parts_appended' frame exists. The brief assumed one; the codebase
reads parts via the messages_with_parts view after message_complete
triggers a refetch. MessagePartSchema is therefore unused this batch.
- No 'tool_running' frame exists. The brief listed it as standalone; it
is in fact a 'chat_status' variant ({ status: 'tool_running' }), already
covered by ChatStatusFrame.
Smoke: clean container boot, no validation errors in the server log. Real
production frames pass validation (the schemas were derived from the
existing hand-maintained types in api/types.ts and sessionEvents.ts).
v1.13.11-b will follow immediately: convert all ~85 raw broker.publish /
ctx.publish call sites across 11 server files to publishFrame /
publishUserFrame. Mechanical edit; the wiring done here means the diff
in -b is just the call-site swaps.
~310 LoC across 9 files (4 new + 5 modified).
ChatThroughput renders inline beside StatusDot while streaming or
tool_running. Subscribes to existing usage frames via sessionEvents.
Hides when status drops to idle/error or data is older than 10s.
Addresses the 2026-05-21 spike's UX gap where slow streams looked
identical to dead streams — now there's a live token velocity readout
that immediately distinguishes the two.
Status indicator (StatusDot): drops the flat amber pulse for a richer set
of states — orbiting amber for streaming, spinning sky ring for tool_running,
static violet for waiting_for_input, plus the existing idle/error. Backend
chat_status frame widens from 'working|idle|error' to discriminate streaming
vs tool execution vs paused for user input.
Workspace pane sync: pane layout moves from per-device localStorage to
server-side sessions.workspace_panes jsonb. PATCH /api/sessions/:id/workspace
broadcasts session_workspace_updated on the user channel for cross-device live
sync. Echo dedup via JSON comparison so the round-trip frame doesn't loop.
Legacy localStorage seeds the server on first hydrate, then is deleted.
Deprecated session_panes table dropped.
Resilience: startup sweep marks any stale 'streaming' message older than
5 minutes as 'failed' so v1.12.0-style hung rows clear on container restart.
useWorkspacePanes gains validatePanes() to prune dead chatId references from
saved pane state when the chat list lands.
ContextBar relocated from a dedicated row above MessageList to inline with
the agent-picker row, filling the space to the right of the picker + plus
button. Always-visible (zero-state when no assistant message has run yet)
via chat.model_context_limit, which GET /api/sessions/:id/chats now
populates from a single getModelContext lookup per session.
ChatContextPopover above the input is removed entirely along with its
useChatContextStats hook (no remaining callers). Color tiers and the
auto-compaction threshold tooltip unchanged.
Five issues + keyboard shortcuts across booterm and the workspace shell.
Auto-switch on create (mobile): addSplitPane now returns the new pane id;
Session.tsx wraps it with addPaneAndSwitch which pushes ?pane=<newId> on
mobile so the URL-sync effect doesn't fight the just-set activePaneIdx.
NewPaneMenu uses the wrapper; desktop Split dropdown is unaffected.
Tab-away reconnect: TerminalPane has a connect()/manualReconnect() state
machine. ws.onclose backs off 500ms/1s/2s × 3 attempts, then surfaces a
[Disconnected] banner with a Reconnect button. visibilitychange listener
calls manualReconnect when the tab returns and the WS isn't OPEN. tmux
session persists server-side so scrollback is intact on resume.
Copy/paste: attachCustomKeyEventHandler binds Cmd/Ctrl-C (copy if
selection, else send ^C), Cmd/Ctrl-Shift-C (always swallow — copy if any,
no-op otherwise — never sends ^C), Cmd/Ctrl-V and Cmd/Ctrl-Shift-V
(navigator.clipboard.readText → ws.send). No custom right-click menu —
browser's native menu is preserved.
Scroll: removed `set -g mouse on` from tmux.conf so xterm.js sees wheel
and touch events natively. scrollback: 10_000, fastScrollModifier: 'shift',
altClickMovesCursor: false. Container has touch-action: pan-y for mobile.
Right-edge gap: inline <style> overrides xterm's defaults to width:100%
height:100% and hides the scrollbar chrome. Host container is
flex-1 min-w-0 self-stretch w-full. Three refit triggers: ResizeObserver
(rAF-wrapped), document.fonts.ready, and useEffect on the new active prop.
Background color matched between outer div, inner div, and xterm theme.
Keyboard shortcuts in Session.tsx (window-level keydown):
Cmd/Ctrl+` focus active terminal, else jump to last
Cmd/Ctrl+Shift+T new terminal pane
Cmd/Ctrl+Shift+C new chat pane (defers to xterm copy if focused)
Cmd/Ctrl+W close active pane
Cmd/Ctrl+Tab/Shift+Tab cycle next / prev pane
Cmd/Ctrl+1..9 jump to pane N
terminalsRegistry gains a focus() callback per registration so Cmd+`
can call term.focus() on the active terminal.
Trigger /<name>, dropdown lists all skills filtered by name prefix,
arg passthrough sends the rest as the user message. Synthetic
skill_use tool_use renders identically to model-invoked skills.
The v1.9 settings pane had no way to dismiss once opened. ChatTabBar
(which owns the per-pane close X for chat panes) is skipped for
settings panes, and the pane header itself only rendered the maximize
toggle (desktop-only). Mobile users had zero controls beyond the
section tabs.
Add three close paths:
- X button in SettingsPane header, visible on mobile + desktop, sits
next to the maximize toggle. Tap-target sized per the v1.6 mobile
convention (max-md:min-h-[44px]).
- Esc when the settings pane is the active pane and no input/textarea/
dialog has focus. Maximize-restore still wins when maximized.
- Sidebar Settings button is now a strict toggle: opens on first click,
closes on second. Renamed openOrFocusSettingsPane →
toggleSettingsPane in the panes hook.
Edge case: removing the settings pane when it's the only pane left
falls back to an empty pane to preserve the "always one pane"
invariant. In normal flow this is unreachable (the toggle only
appends), but defensive against future entry points.
Adds a singleton, ephemeral 'settings' pane kind to the workspace.
Opened via a new bottom-pinned button in ProjectSidebar (emits an
open_settings_pane event when a session is mounted; navigates to
/settings otherwise). Pane has three sections — Session, Project,
Theme — and a maximize toggle that hides sibling pane columns via
display:none on desktop only. Settings panes don't count toward
MAX_PANES and are filtered out of the localStorage persistence layer
so reload always restores a clean workspace.
Schema (additive):
- projects.default_system_prompt TEXT NOT NULL DEFAULT ''
- projects.default_web_search_enabled BOOLEAN NOT NULL DEFAULT false
- sessions.web_search_enabled BOOLEAN (nullable; null = inherit)
Inference resolves user_prompt = session.system_prompt.trim() ||
project.default_system_prompt.trim() — empty/whitespace at either
layer means "no override". Keeps the columns NOT NULL and matches
the existing inherit semantics.
Server routes:
- GET /api/projects/:id (new; settings pane refetches on
project_updated)
- PATCH /api/projects/:id accepts default_system_prompt,
default_web_search_enabled
- PATCH /api/sessions/:id accepts web_search_enabled (tri-state)
- POST /api/projects/:id/sessions/archive-all + GET
/api/projects/:id/sessions/open-count
- POST /api/sessions/:id/chats/archive-all + GET
/api/sessions/:id/chats/open-count
- PATCH /api/sessions/:id now broadcasts session_updated on every
successful PATCH (was rename-only). Lets SettingsPane open in
another tab pick up edits without a refetch.
Bulk-archive publishes one session_archived / chat_archived frame
per affected id so useSidebar's existing reducer cases handle them
incrementally — no new frame type, no payload widening.
ModelPicker refactored: shared ModelList inside a responsive shell.
Desktop = labeled trigger + DropdownMenu, mobile = icon-only Cpu
button + BottomSheet. Header in Session.tsx drops the pill wrap on
mobile since the new trigger is the visual.
ChatInput gains an icon-only '+' DropdownMenu next to AgentPicker
when sessionId + webSearchEnabled props are provided. One item for
now — Web search — with a checkmark reflecting the stored value
(true), not the effective one. Click PATCHes the override; to
restore inherit-from-project the user opens SettingsPane.
ThemePicker lifted out of pages/Settings.tsx into a reusable
component. The standalone /settings route is now a thin wrapper
that mounts <ThemePicker /> with a Back button on top
(navigate(-1) with fallback to '/'); the SettingsPane Theme tab
renders the same picker bare.
Project section delete-flow removed (button + confirm dialog +
handler). Replaced with "Archive all sessions" using the same
two-step count → confirm → fire pattern as "Archive all chats" in
the Session section. api.projects.remove() stays in the client
because useProjects.ts still uses it.
Hand-rolled Switch primitive in SettingsPane (no shadcn switch in
the project; spec said no new deps). Section nav is plain buttons
(no shadcn Tabs).
Old hardcoded MAX_TOOL_LOOP_DEPTH=15 replaced by per-agent
max_tool_calls (1-100, AGENTS.md frontmatter) with defaults: 30 for
read-only-only agents, 10 for agents that include any non-read-only
tool, 15 for raw chat. When the loop hits cap, fire one final summary
call with tools disabled, stream the wrap-up into the in-flight
assistant message, then insert a system sentinel with
metadata.kind='cap_hit'. The sentinel renders an amber bubble with a
Continue button (latest sentinel only) that POSTs to a new
/api/chats/:id/continue route to extend. Hard ceiling: 3 cap-hits per
chat (2 continues max) — third sentinel reports can_continue=false.
Error frames carry a machine-readable reason code alongside human
error text. Failed messages persist the reason via
metadata.kind='error' so the bubble renders specifics on reload (WS
error frame is one-shot).
Tool call UI rewired: ToolCallLine renders inline (↳ name args
spinner/check/✗, expand-on-tap for args+result); ToolCallGroup
collapses 3+ consecutive same-tool runs into a compact card.
MessageList owns a three-pass pre-render (flatten + fold tool
results onto matching runs by id + group same-tool runs + number
sentinels). MessageBubble drops tool rendering and adds the
sentinel / error-reason branches. ToolCallCard deleted.
Roadmap follow-up logged: add explicit max_tool_calls: 30 to the 6
agents in /data/AGENTS.md and /opt/boocode/AGENTS.md post-ship for
discoverability (defaults handle behavior identically).
Builtins move out of code into /data/AGENTS.md (always-on, mounted ro
into the container); per-project AGENTS.md is now an optional override.
agents.ts merges global + project entries with project-wins-by-name and
caches per-source mtimes (60s TTL). Parser switches to per-block
try/catch and returns AgentsResponse { agents, errors[] } so one
malformed block no longer fails the file. AgentPicker shows a
non-blocking amber chip listing skipped blocks and only fires a gray
toast when zero agents loaded.
WS reconnect UX (useUserEvents + useSessionStream) now silent on the
first disconnect; createWsReconnectToast escalates to gray after 3
failures or 15 s, then to red with a Retry Now action after 60 s.
useSessionStream also gained the exponential-backoff reconnect it was
missing.
Mobile header is now two rows. Row 1: hamburger | project · branch
indicator (live via GET /api/projects/:id/git, 30s poll) | ModelPicker |
FolderTree. Row 2: pane-switcher pill (hand-rolled BottomSheet) +
NewPaneMenu. Chat-within-pane navigation hidden on mobile; users switch
panes via the sheet. Cross-tab status sync via chat_status frames
published from inference.ts at working/idle/error transitions; StatusDot
component renders amber-pulse/green/red/gray on each pane row and on
desktop ChatTabBar tabs. Level 1 git awareness exposes a read-only
git_status tool to the model, backed by services/git_meta.ts (execFile
+ 2s timeout + 30s cache). Workspace.tsx now receives panes/chats hooks
as props (hoisted into Session.tsx) so the header pill shares state
with the pane grid.
Reverts v1.6.1's max-md:hidden wrapper around RightRail. On mobile,
RightRail now renders as a fixed right-side drawer (w-[85vw],
max-w-sm) toggled by a new FolderTree button in the Session header.
- New useRightRailDrawer hook mirrors useSidebarDrawer (Context +
auto-close on route change).
- New MobileRightRailBackdrop component in App.tsx mirrors the
existing MobileBackdrop for the left sidebar.
- RightRail computes an isOpen synthesis: on mobile, reads the
drawer Context; on desktop, reads the persistent internal state.
The existing tree-load effect and open_file_in_browser
subscription share this plumbing via openRail / closeRail
helpers.
- The desktop floating chevron handle is hidden on mobile (the
Session header's FolderTree button replaces it).
- Session header gains a mobile-only FolderTree button after the
ModelPicker, calling toggle() on the drawer Context.
- usePullToRefresh: hand-rolled hook. Records startY only when the
scroll container is at scrollTop=0 to avoid hijacking mid-scroll
pulls. Tracks downward delta on touchmove; fires onRefresh on
touchend if delta >= 80px threshold. Holds the refreshing state for
600ms minimum so the action feels intentional.
- ProjectSidebar: wires usePullToRefresh(() => retry()) on the nav
element, mobile-only. A status indicator above the nav grows with
pullDist (max 80px) and cycles 'Pull to refresh' -> 'Release to
refresh' -> 'Refreshing...'. retry() is from useSidebar and refetches
GET /api/sidebar.
- Workspace: on mobile renders only panes[activePaneIdx] (rest skipped).
When panes.length > 1, adds a horizontal pane-navigator strip built
from SwipeablePaneTab above the active pane. URL state ?pane=<paneId>
written by switchActivePane (user-initiated only) and read on URL
change (back-button + deep-link). Bare URL resets activePaneIdx to 0.
- useLongPress: 500ms touchstart timer; on fire, dispatches a synthetic
contextmenu event on target.closest('[data-tab-id]') so the existing
Radix ContextMenuTrigger opens at the touch coordinates. Suppresses
the synthetic click that follows touchend. Cancels on touchmove /
touchend / touchcancel.
- ChatTabBar: each tab gets data-tab-id, touch handlers wired to
useLongPress, and WebkitTouchCallout: 'none' to disable iOS Safari's
text-selection callout.
- SwipeablePaneTab: tracks horizontal drag; bails if vertical delta
exceeds 30px (so vertical scroll still works); past 60px on release
fires onClose (removePane), else snaps back. Opacity fades 1->0.4
approaching the threshold. Hand-rolled per spec.
- Pane drag-and-drop disabled on mobile (HTML5 drag is broken on touch
anyway; mobile uses the navigator + swipe-to-close instead).
- useViewport: matchMedia-based hook (no resize polling). Breakpoints
mobile <768 / tablet 768-1023 / desktop >=1024. SSR-safe.
- useSidebarDrawer: Context provider with open/setOpen/toggle + auto-close
on useLocation().pathname change.
- App.tsx: wraps SidebarDrawerProvider around AppShell, renders a
MobileBackdrop (z-30) when the drawer is open on mobile.
- ProjectSidebar: aside is fixed/translate-x-full off-screen on mobile,
slides in (z-40, 200ms transform) when drawerOpen. Inline column on
desktop, unchanged.
- Session.tsx + Project.tsx: hamburger (Menu icon, >=44x44 min) on mobile
opens the drawer. Headers gain paddingTop: max(0.75rem,
env(safe-area-inset-top)) for notch devices. Home.tsx left alone
(sidebar content duplicates the home page).
Adds a floating popover above the chat input showing current
context-window usage. Modeled on Paseo's tracker.
- New hook useChatContextStats(chatId, messages) finds the latest
assistant message in the chat with both ctx_used and ctx_max set,
computes percent, and returns null when data unavailable.
- New component ChatContextPopover renders a small card with the
"Context window" label, big percent, and "used / max tokens"
subline. Hidden when stats is null.
- Color thresholds: <60% muted, 60-85 amber, >85 destructive.
- Not a portal — absolutely positioned inside a new relative
wrapper around ChatInput in ChatPane.tsx, so it's pane-local
(multi-pane safe).
- Live updates via the existing messages-array dependency.
- No API / schema / WS changes.
Swallowed-error logging (audit Feature 3):
- file_index.ts:36-37 (git mtime probes): comment — best-effort, project
may not be a git repo.
- useUserEvents.ts:44 / 53 (ws.close on error / unmount): comments —
best-effort, socket may already be closing.
- RightRail.tsx:38 (localStorage write): comment — best-effort, quota or
private mode.
- App.tsx:21 (api.sessions.get for RightRail projectId): replaced silent
catch with console.warn.
- Session.tsx:38, 41 (session fetch + project list for breadcrumb):
replaced silent catches with console.warn.
H1: ProjectSidebar.tsx:189 — dropped the local sessionEvents.emit
({type:'session_renamed'}) after PATCH. Server publishes via
broker.publishUser since v1.4; useUserEvents forwards.
H2: useSessionStream.ts session_renamed case removed (dead — no
server code path publishes session_renamed on the per-session WS
channel; only user channel via broker.publishUser). Also dropped the
session_renamed variant from WsFrame (in apps/web/src/api/types.ts)
to keep the discriminated-union switch exhaustive.