72 Commits

Author SHA1 Message Date
ad45b28250 v1.13.19-html-artifact-panes: pane-based artifact viewer with on-request HTML
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.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 12:43:13 +00:00
b52c5df705 v1.13.17-cross-repo-reads: on-demand read access to paths outside the project root
When the agent needed context from another repo, pathGuard rejected every read
with no recovery path. This batch adds a reactive request_read_access flow:
pathGuard's error now hints at the tool, the model emits a structured request,
the inference loop pauses (same mechanism as ask_user_input), the user picks
Allow/Deny via inline chips, and subsequent reads under the granted root succeed
for the rest of the session.

Schema: sessions.allowed_read_paths TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[]
(idempotent ADD COLUMN IF NOT EXISTS).

Grant unit (design D1): nearest registered projects.path ancestor →
nearest repo-shaped ancestor (.git/ / package.json / go.mod / Cargo.toml)
under PROJECT_ROOT_WHITELIST → else refuse. grant_resolver.ts walks
ancestors with a per-iteration whitelist invariant check so symlinked
input can't escape the whitelist mid-walk (Sam's checkpoint-1 ask).

Path-guard: optional extraRoots arg threaded from session.allowed_read_paths
through executeToolCall to view_file / list_dir / grep / find_files. The
ToolDef.execute signature gets an optional third param; non-FS tools
ignore it. view_file re-anchors the secret-guard check on basename(real)
whenever a relative path starts with "../" so .env / id_rsa* etc. still
deny across grant roots.

Endpoint: POST /api/chats/:id/grant_read_access mirrors /answer_user_input.
On 'allow' it re-resolves the grant root (state may have changed since
prompt — auto-falls to denial reason text on failure, not 500), array_appends
to sessions.allowed_read_paths with in-memory dedup, then publishes
tool_result + session_updated frames and enqueues the next assistant turn.

PATCH /api/sessions/:id allowed_read_paths supports revocation only. Zod
refines absolute + no traversal markers; runtime findUnauthorizedAdditions
guard rejects any entry not already present in the row, so a malicious
curl -X PATCH -d '{"allowed_read_paths":["/etc"]}' returns 400 instead of
bypassing the grant flow (Sam's compliance-review action item).

Frontend: RequestReadAccessCard renders pending (path + reason + Allow/Deny)
and answered (granted/denied summary with the resolved root) variants;
MessageList.flatten/group special-cases the tool name; SettingsPane adds a
per-session grants list with per-row revoke that PATCHes the shortened
array.

Tests: 11 grant_resolver, 8 path_guard, 8 sessions PATCH subset, including
explicit cases for symlink escape mid-walk, walk-bound termination at
whitelist root, /etc bypass attempt via PATCH, and nearest-project
disambiguation. 292 total server tests green.

Pairs with v1.13.16-xml-parser — the model now self-recovers from both
a wrong tool name AND from a refused path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 21:45:52 +00:00
0fa46cd06c v1.13.12: skills audit + token-tracking fix + codecontext + cap50 + UI cleanups
Multi-topic batch. The big-ticket item is the skills audit; the rest are
smaller patches that compounded during the audit work.

## Skills audit (rules→recipes split)

Vendored all 26 skills from /home/samkintop/opt/skills/ into data/skills/
(the boocode-repo-local skill library — see docker-compose change below).
Audited via 5 parallel Claude Code agent-teams running the
mgechev/skills-best-practices 4-step protocol (Discovery → Logic → Edge
Case → self-Architecture-Refinement) per skill, ~2 min wall-clock vs the
~3.7-hour serial estimate.

Result: 14 skills surviving (renamed to gerund form, frontmatter matched),
11 deleted (duplicates, BooCode-irrelevant patterns, Claude-already-does-
natively), 1 migrated to BOOCHAT.md/BOOCODER.md as an always-true rule
(verification-before-completion). Each surviving skill had its description
refined to fix specific trigger gaps surfaced by the protocol — 4
real-bug findings landed (dead refs, stale tags, broken sub-file
references in the original vendored content).

Audit decisions documented in openspec/changes/v1.13.12-skills-audit/
audit-notes.md. Convention codified in BOOCHAT.md/BOOCODER.md "rules vs
recipes" sections — future workflow rules go to those files (100%
present), recipes stay in data/skills/ (~6% invoke rate in multi-turn
per the Codeminer42 measurement).

## Token tracking + stale-stream banner fix (same root cause)

ws-frames.ts IsoTimestamp was z.string().min(1) but postgres returns
timestamp columns as JS Date objects. Every message_complete /
session_updated / chat_updated frame was failing the v1.13.11 Zod gate
and being silently dropped. Symptoms: token tracking blank in the UI
(no usage frames landed); the 60s no-token-activity timer tripped the
stale-stream banner because the frontend's local message state never
saw status='streaming' flip to 'complete'.

Fix: z.preprocess(v => v instanceof Date ? v.toISOString() : v,
z.string().min(1)) applied to the IsoTimestamp primitive. Centralized,
no publisher changes, works identically server + web (the parity test
still passes).

## Codecontext .codecontextignore auto-install

services/codecontext_client.ts now copies the
codecontext/.codecontextignore.template into any project's root on the
first call to that project if no .codecontextignore exists. One file
written per project, idempotent (in-memory Set guard + access-check),
silent fallback on read-only project. Stops the upstream empty-source-
file parser crash on foreign projects' node_modules — previously
required manually copying the template per project.

## Tool-call budget cap 30 → 50

services/inference/budget.ts: BUDGET_READ_ONLY and BUDGET_NO_AGENT
bumped to 50 (from 30). BUDGET_NON_READ_ONLY stays at 10 (no write
tools landed yet). Real recon sessions were hitting 30 with ~3 turns
wasted on codecontext parse failures; legitimate need was ~27, and
Architect-class system overviews want deeper recon. Headroom of 20
absorbs failure-retry turns without changing the safety floor — the
doom-loop guard (3 identical calls → abort) catches the actual
failure mode this cap was guarding against.

v1.14 (Phase C outer agent loop) will supersede this via per-agent
agent.steps. Throwaway-ish patch but unblocks deeper recon today.

## UI cleanups

- ChatPane queued-message dropdown removed. Each queued message now
  has three buttons: edit (pop back into ChatInput via sendToChat
  event), force-send (was the dropdown's only useful action), and
  cancel. Default behavior (send when streaming completes) needs no
  UI — it's the implicit do-nothing path.
- ChatThroughput removed from desktop tab strip (ChatTabBar.tsx).
  Mobile tab switcher still shows it.

## Plumbing

- .gitignore: data/* + !data/AGENTS.md + !data/skills/ negation
  patterns so the vendored skill library + agent registry become
  git-tracked while session DB state stays out.
- docker-compose.yml: removed /opt/skills:/data/skills override
  mount. Skills now live in the boocode repo at data/skills/,
  auditable per-batch. The host-level /opt/skills/ is preserved
  untouched for any other tools that read from it.
- .codecontextignore at repo root: auto-installed when codecontext
  was first called against /opt/boocode itself; matches the template.
- CLAUDE.md: updated to document the v1.13.11 publishFrame wrapper +
  message_parts table + tool_cost_stats view + DB-integration test
  pattern + host-side smoke endpoint quirk. (Pre-existing in working
  tree before this batch; shipped here for completeness.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:58:30 +00:00
bc376c878d v1.13.11-b: convert raw broker.publish call sites to typed publishFrame
Second half of the WebSocket-frame-typing batch. Phase A (8b568b3)
landed the schemas + frontend receive validation + publishFrame /
publishUserFrame wrappers. This commit converts the existing publish
call sites so every server-emitted WS frame now goes through Zod
validation at the broker boundary.

Conversion strategy: change once in the inference / skills adapters in
index.ts (so ctx.publish / ctx.publishUser propagate to publishFrame /
publishUserFrame for ALL ~50 inference + auto_name call sites in one
move), then bulk-replace the ~30 direct broker.publish* call sites in
the routes + compaction.

Files touched:
- index.ts: inference + skills route adapters now call publishFrame /
  publishUserFrame internally; raw broker.publishUser('default', ...)
  call in the stale-row sweeper also converted.
- routes/projects.ts (7 sites), routes/chats.ts (9 sites),
  routes/sessions.ts (8 sites): all broker.publishUser(...) → broker.
  publishUserFrame(...).
- services/compaction.ts (3 sites): 2 publishUser, 1 publish.

Real protocol drift surfaced by Zod, fixed in the same commit:

  services/compaction.ts:442 was publishing chat_status with status:
  'working' — the v1.12.1 chat_status widening (CLAUDE.md:55) dropped
  this enum value in favor of streaming|tool_running|waiting_for_input|
  idle|error. The compaction.ts site was missed during v1.12.1; the
  frame had been published with an unknown enum value ever since (the
  frontend useChatStatus quietly ignored it). Corrected to 'streaming'
  — compaction's LLM call has the same dot-state semantic as an
  inference turn. This is exactly the class of bug v1.13.11 exists to
  catch.

Schema relaxation: OpaqueObject (the bag type for nested entities like
Project / Chat / Session / WorkspacePane embedded in WS frames) was
z.object({}).passthrough(), which Zod outputs as {} & {[k:string]:
unknown}. The strict-typed entities don't have index signatures so
TypeScript rejected them at publishFrame call sites. Relaxed to
z.unknown() — runtime validation still accepts the value, dev-time
narrowing happens via the existing hand-maintained types. Trade-off:
frame-level drift detection stays sharp; nested-payload validation
goes to follow-up work as the brief intended.

Schema audit:
  grep -rn "broker\.publish(\|broker\.publishUser(" apps/server/src \
    --include="*.ts" | grep -v "broker.ts\|__tests__\|.bak"
  → 0 results. Every server publish goes through publishFrame /
  publishUserFrame. The remaining ctx.publish / ctx.publishUser sites
  in services/inference/* + services/auto_name.ts route through the
  index.ts adapter, which calls publishFrame internally.

Tests: 219/219 pass (unchanged from v1.13.11-a; the Phase B conversion
is mechanical and doesn't add test cases).

Smoke: clean container boot, no ws-frame-validation-failed entries
under normal traffic. Sidebar list refresh + agent picker open both
pass through useUserEvents without drops.

~70 LoC across 7 files. v1.13.11 closed.
2026-05-22 15:54:00 +00:00
8b568b36d3 v1.13.11-a: WS frame schemas + frontend receive validation
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).
2026-05-22 15:48:32 +00:00
9ce638c916 v1.13.10: per-tool token cost accounting (rolling 100-call view)
Surfaces per-tool prompt/completion-token rolling averages in
AgentPicker for at-a-glance agent-cost hints. Implementation is a
SQL view on top of messages_with_parts plus a read endpoint and
AgentPicker tooltip extension. No new write site; all source data
already lands via the existing tool-phase.ts:94-95 / error-handler.ts:
109-110 / sentinel-summaries.ts UPDATEs that v1.13.7's includeUsage:
true fix made non-NULL.

(1) schema.sql — new tool_cost_stats view. Window-functions over
messages_with_parts.tool_calls with LATERAL jsonb_array_elements.
Attribution: equal split — multi-tool turn divides tokens N-ways;
the 100-call rolling mean absorbs split noise. Filters: status=
'complete' + metadata.kind NOT IN ('cap_hit','doom_loop') exclude
failed turns and sentinels respectively; tool_calls IS NOT NULL is
defense-in-depth since sentinels are role='system' rows. CREATE OR
REPLACE means schema apply is idempotent.

(2) routes/tools.ts NEW + index.ts wire-in. GET /api/tools/cost_stats
returns { stats: ToolCostStat[] } with mean_prompt_tokens / mean_
completion_tokens computed at read time (sum / n_calls). Sorted by
tool_name ASC. No pagination — ≤30 tools.

(3) __tests__/tool_cost_stats.test.ts NEW — 7 integration tests
keyed off DATABASE_URL env var. Tests skip gracefully when unset
(no-DB default). beforeAll applies the schema via sql.unsafe(read
FileSync(schema.sql)) for self-contained runs. Helper insertAssistant
Turn shared across cases. Covers: empty state, single-tool attribution,
multi-tool equal split, 100-call FIFO window, NULL-tokens exclusion,
parts-authoritative read via messages_with_parts, failed/sentinel
exclusion.

(4) web/api/types.ts + client.ts — ToolCostStat interface + api.tools.
costStats() method binding.

(5) AgentPicker.tsx — fetch costStats on mount, compute per-agent
sum-of-means across whitelisted tools, render muted cost line below
description: "~5.2k prompt / 280 completion · 6/8 tools · last call
3h ago". Skips line entirely when no tool history; preserves existing
native title= for layout backward-compat. formatK/formatAgo colocated.

Tests: 202/202 pass (195 prior + 7 new view-integration). Server +
web tsc clean.

Smoke: schema applied cleanly; GET /api/tools/cost_stats returns
canonical JSON; view + endpoint agree. Single-row result expected
given the v1.13.1-A → v1.13.7 NULL latent regression window; new
traffic populates organically.

Roadmap row at boocode_roadmap.md:114 plus schema row at :474 both
match. View vs table decision documented in handoff_v1.13.10_per_
tool_cost.md (rollback-safe, microsecond-fast at BooCode scale).

~270 LoC across 8 files (5 modified + 3 new).
2026-05-22 14:42:09 +00:00
ff29b48e3a v1.13.7: stability bundle — usage capture + payload/UI sanitization
Five fixes for latent regressions surfaced during the v1.13.x.cosmetic
revert investigation. None alter schema or compaction; all cleanup
against the v1.13.1-A AI SDK migration's hidden surface.

(1) provider.ts — includeUsage: true on createOpenAICompatible.
@ai-sdk/openai-compatible defaults this false, omitting
stream_options.include_usage from the request body; llama-swap never
emitted the usage block, so result.usage.inputTokens/outputTokens
resolved undefined and tokens_used / ctx_used landed NULL in every
assistant row since v1.13.1-A. No historical backfill.

(2) MessageList.tsx — hasText = m.content.trim().length > 0.
AI SDK v6 streaming occasionally emits a leading "\n" text-delta on
tool-call-only turns; the literal newline passed length > 0 and
rendered an empty bubble + ActionRow between every tool call. Trim
catches it without changing semantics for genuine content.

(3) MessageBubble.tsx — same trim on hasContent for the no-tool-calls
path. Defensive symmetry with MessageList.flatten.

(4) payload.ts — buildMessagesPayload skips assistant rows with
status='failed' AND assistant rows with status='complete' + empty
content + no tool_calls. Without this, a trailing empty/failed
assistant + the next attempt's placeholder produced "Cannot have 2
or more assistant messages at the end of the list" rejections from
the OpenAI-compatible upstream after cap-hit + Continue.

(5) budget.ts — BUDGET_NO_AGENT 15 → 30. Every tool in ALL_TOOLS is
read-only today; the 15-cap was forward-looking for write tools that
haven't landed. No-agent mode now matches BUDGET_READ_ONLY.

47 LoC across 5 files. 190/190 server tests pass.

Verified live: new assistant turns populate StatsLine token data;
single-tool-call turns no longer render the stray empty-bubble +
ActionRow between tool calls; Continue after cap-hit no longer hits
the trailing-assistant API rejection.
2026-05-22 13:24:19 +00:00
ac1a71f583 v1.13.1-C: port ask_user_input correlation to parts + wire reasoning_parts end-to-end
Pass 1 — ask_user_input correlation port (messages.ts:478, :549):

- The two correlation queries that backed the elicitation flow used to scan
  messages.tool_calls and messages.tool_results JSON columns directly. They
  now JOIN message_parts on payload->>'id' (for the caller assistant) and
  payload->>'tool_call_id' (for the pending tool row). Semantics preserved:
  ORDER BY m.created_at DESC LIMIT 1 still picks the latest issuance, the
  already-answered 409 guard now reads payload.output, and the UPDATE +
  parts replace inside sql.begin is unchanged from v1.13.0.
- Pre-v1.13.0 history has no parts rows and is unreachable to this lookup
  path (404). Acceptable per dispatch decision — no pending elicitation
  from before v1.13.0 will still be open. JSON-column fallback can land as
  a hotfix if it ever surfaces.

Pass 2 — reasoning_parts wired end-to-end:

- types.ts/StreamResult gains `reasoning: string`. stream-phase.ts accumulates
  reasoning-delta text per stream (replacing the v1.13.1-A counter-only
  diagnostic) and returns it on the result.
- parts.ts/partsFromAssistantMessage gains an optional `reasoning` param.
  When present it emits a kind='reasoning' part at sequence 0, ahead of
  the text and tool_call parts.
- error-handler.ts/finalizeCompletion and tool-phase.ts/executeToolPhase
  both thread result.reasoning into the dual-write call so reasoning-channel
  models (qwen3.6) get persistent reasoning rows.
- payload.ts: loadContext SELECT pulls reasoning_parts from the v1.13.1-B
  view; OpenAiMessage gains an optional `reasoning` field; buildMessagesPayload
  collapses reasoning_parts into a single string per assistant message.
- stream-phase.ts/toModelMessages converts assistant messages with reasoning
  into an AI SDK ModelMessage content array starting with a ReasoningPart,
  matching the @ai-sdk/provider-utils AssistantContent union. Reasoning
  models can now replay prior reasoning context across tool-call boundaries.
- types/api.ts and apps/web/src/api/types.ts Message interface gain
  reasoning_parts (optional, nullable). Frontend doesn't render this yet —
  field reserved for a v1.14 UI surface.

Tests: 2 new in parts.test.ts cover reasoning-at-sequence-0 with and
without text content. 172 tests pass (170 prior + 2 new).

Smoke verified against the live container:
- A reasoning-prompt ("walk through 17 × 23 step by step") produced one
  message with kind='reasoning' (361 chars) at sequence 0 and kind='text'
  (429 chars) at sequence 1. Adapter log confirmed reasoning capture.
- The new correlation SQL was validated against existing tool_call /
  tool_result parts: returns the expected message_id + payload shape with
  pending state correctly identified via payload.output IS NULL.
- ask_user_input end-to-end through the UI is Sam's smoke — the Prompt
  Builder agent does not always trigger ask_user_input for these prompts,
  so synthetic verification via SQL substituted for traffic-driven cover.

Annotation: the v1.13.1-A abort-throw site in stream-phase.ts got a
one-liner comment ("AI SDK v6 fullStream returns normally on abort; check
signal explicitly.") to prevent a future refactor removing it.

v1.13.2 drops the dual-write + the JSON columns + collapses the view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 06:34:10 +00:00
eef4782383 v1.12.3: stale-stream banner with Retry/Discard
When an assistant message sits status='streaming' with no token activity
for 60+ seconds, the chat shows a banner above the input offering Retry
or Discard. Both clear the stale row via a new backend endpoint
POST /api/chats/:id/discard_stale that updates status='failed' and
publishes chat_status='idle'.

Closes the UX gap that caused the 2026-05-21 debugging spiral —
slow streams and dead streams now look different to the user.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:48:22 +00:00
a7104691aa v1.12.2: live tok/s + ctx display next to status indicator
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.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:45:53 +00:00
48ee63a286 v1.12.1: rich status indicator + server-side workspace pane sync
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.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:32:02 +00:00
16c69a38a1 Merge v1.12 track B: codecontext sidecar
# Conflicts:
#	apps/web/src/components/ToolCallLine.tsx
#	docker-compose.yml
2026-05-21 15:12:30 +00:00
be3c38ff2f Merge v1.12 track A: container guidance + skills 2026-05-21 15:11:12 +00:00
a2e2481ef9 v1.12 track A: container guidance + skills 2026-05-21 15:11:04 +00:00
136e9538aa v1.12 track B.2: codecontext tool wrappers + tests 2026-05-21 13:35:44 +00:00
5cd3f63df5 mobile: add explicit close button to nav drawer 2026-05-21 04:06:35 +00:00
2fdbb05477 v1.11.8: web_search + web_fetch tools via SearXNG
Adds two new tools registered through the existing ALL_TOOLS registry:
  - web_search hits SearXNG's JSON API (Fathom, internal Tailscale URL,
    no auth) and returns top results
  - web_fetch retrieves a URL's text content, gated by isPublicUrl
    (url_guard.ts) which blocks loopback / RFC1918 / Tailscale CGNAT /
    link-local / .local / .internal / non-http schemes

Both tools are opt-in via the existing session.web_search_enabled flag
(plumbed in v1.9, activated here). Default off. UI labels updated to
"Enable web search and fetch" / "Web search and fetch" since fetch joins
the same store. Counts against the v1.8.2 per-turn budget; covered by
the v1.11.6 doom-loop guard.

Native Node 20 fetch — no new prod dep. HTML stripping via regex (script
and style content elided wholesale). 5MB body cap, 15s fetch timeout,
8000-char default output, 32000-char cap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 21:38:02 +00:00
f92b0810c3 v1.11.6: doom-loop guard (3 identical tool calls aborts recursion) 2026-05-20 20:28:45 +00:00
1ffcf67c47 v1.11.5: ContextBar inline next to agent picker; remove ChatContextPopover
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.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 20:11:49 +00:00
8cd270a5da ContextBar: persistent context-usage indicator above MessageList
Walks chat messages newest-first for the latest ctx_used/ctx_max pair.
Color tiers fire against (max - 20k compaction reserve) so the bar warns
amber/orange/red at the same boundaries auto-compaction triggers.
"Context" → "Ctx" at <640px, (NN%) drops at <380px.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 19:18:27 +00:00
dc43dd44f9 v1.11: opencode-style compaction port
- compaction.ts: usable/isOverflow/estimate/turns/select/buildPrompt/process
- compaction-prompt.ts: SUMMARY_TEMPLATE verbatim from opencode
- schema: messages.{compacted_at,summary,tail_start_id} + chats.needs_compaction
- inference: auto-trigger on overflow, pre-fetch compaction before next turn
- /compact slash command rewired to new path
- WS: chat_status working/idle around compaction + compacted frame
- frontend: SummaryCard + sonner toast on compacted
- 24 unit tests for pure functions
2026-05-20 19:05:35 +00:00
6aab4f7d2a ChatTabBar: + button dropdown to add chat / terminal / agent pane
Replaces single onNewChat handler with onAddPane(kind). Terminal pane
header gets matching + dropdown. Context menu "New chat" stays.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 18:13:55 +00:00
2d841ee0b4 handoff 2026-05-20 14:56:02 +00:00
3fceea064a booterm: fitFull() bypasses FitAddon scrollbar subtraction; push initial PTY size
FitAddon's proposeDimensions() always subtracts a phantom scrollbar width even
when CSS hides the scrollbar — losing one column of usable width. fitFull()
divides host clientWidth/clientHeight by the renderer's reported cell size
directly. Also POSTs the resized cols/rows back to /api/term/.../resize on
initial mount and after fonts.ready so bash/opencode get the correct PTY
size before the user types.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 17:32:42 +00:00
ea9d261f0f v1.10.4: booterm mobile UX — copy/paste, swipe-close, send-to-chat, search
- Long-press selection + floating menu (mobile + desktop right-click): Copy,
  Paste, Select All, Search, Send to chat. Tap-outside / Esc dismiss.
- Pane-header Paste button (📋) for iOS user-gesture clipboard read.
- Swipe-left-to-close on mobile pane pill with red "Close" overlay and
  translateX visual hint; spring-back below 80px threshold.
- Send-to-chat reverse path: chatInputsRegistry + sendToChat event mirror
  the existing terminalsRegistry pattern. ChatInput appends with newline
  separator on receive and focuses (no auto-send).
- Scrollback search via xterm-addon-search@^0.13.0: SearchBar overlay with
  N-of-M match counter (onDidChangeResults), Enter/Shift-Enter cycling.
- Cmd/Ctrl+F intercept in Session.tsx when active pane is terminal; xterm
  also intercepts when focused. Browser native find passes through elsewhere.
- terminalsRegistry signature extended with openSearch + paste callbacks.

Includes deferred CLAUDE.md updates documenting v1.10/v1.10.1/v1.10.2/v1.10.3
learnings (uid 1000 collision, libc match, two event buses, vite proxy order,
mobile pane URL sync, xterm canvas selection).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 17:16:47 +00:00
875db86e31 v1.10.3: booterm mobile/UX fixes + global keyboard shortcuts
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.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:52:44 +00:00
7486e7d3e0 v1.10: booterm container — xterm.js + tmux + node-pty 2026-05-18 14:06:46 +00:00
d85b17081e v1.9.7: ask_user_input elicitation tool 2026-05-18 02:15:18 +00:00
80fd3d9fa9 feat(web): /skill slash command with autocomplete
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.
2026-05-18 01:10:51 +00:00
eaacd432e8 feat(web): skills API types + client methods 2026-05-18 01:10:51 +00:00
98b432ebce refactor: drop type-to-confirm gate on chat delete
The chat-delete dialog required typing the chat name to confirm
deletion. Single-user app — typing friction is annoying, not safety.
Match the archive dialog pattern in SettingsPane.tsx: title +
description naming the chat in mono font, plain Cancel + destructive
Delete button.

Removes the deleteInput state, deleteExpected / deleteEnabled
deriveds, the <Input> field, and its lone <Input> import.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:50:30 +00:00
1ecccc112f fix: settings pane close affordance + sidebar toggle
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.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:50:25 +00:00
09aecc4ee9 v1.9: settings pane + per-project defaults + bulk archive + themes lift
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).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:37:29 +00:00
9b174cdb5e themes-v1: 18 preset palettes + Settings picker
Adds 18 preset themes (16 dual-mode + 2 light-only) selectable from
a new /settings route. Persists per-user via the existing key-value
settings table — no schema refactor. Default on first load is
obsidian dark.

Storage: two new seeded keys (theme_id, theme_mode) inserted
idempotently from schema.sql. PATCH /api/settings tightens validation
with a discriminated branch — theme_id must be one of the 18
whitelisted ids, theme_mode ∈ {dark,light,system}, anything else
rejects 400. Other keys pass through the loose record schema.

CSS layer: 18 files in apps/web/src/styles/themes/, each declaring
.theme-<id> (light) and .theme-<id>.dark (dark) — except ivory and
chalk which are light-only. Anchor-to-token mapping per spec §3.
--destructive stays red across all themes. --radius unchanged at
0.625rem (spec parenthetical was about "not per-theme", not a
specific value swap).

Frontend: lib/theme.ts owns THEMES, applyTheme(), setTheme(), and
useTheme() — module-singleton with optimistic PATCH + revert on
failure (mirrors useChatStatus / useSidebar pattern). Settings.tsx
renders a 3-col (md) / 2-col (mobile) grid of shadcn Card swatches
with a Dark/Light/System radio group on top. App.tsx mounts
useTheme() at AppShell top and wires the /settings route.
index.html ships a pre-React FOUC script that reads localStorage
'boocode.theme' and stamps the className on <html> before any
paint. Stripped two pre-existing dark-mode lock-ins (AppShell's
hardcoded 'dark' className and body's neutral-950/100 tailwind
utilities) that would have fought theme tokens.

Light-only + dark request → falls back to obsidian dark in three
places: lib/theme.ts effectiveThemeId(), the FOUC script, and the
picker's "Light only" badge. No inline message; matches spec §8
decision 1.

shadcn primitives card and radio-group installed via shadcn CLI
(no hand-rolling). card.tsx and radio-group.tsx are the only ui/
additions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 16:25:15 +00:00
5c61cc7281 v1.8.2: tool loop cap-hit summary + tool call UI compaction
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).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 10:31:32 +00:00
12d91c9a12 v1.8.1: global agents + parser robustness + WS reconnect toast
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.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:16:02 +00:00
2bce4d85fa feat(mobile): v1.8 tab switcher + branch indicator + git_status tool
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.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 20:07:53 +00:00
92bd3b1cdf feat(agents): Tier 2 — AGENTS.md + per-session picker
Six builtin defaults (Code Reviewer, Debugger, Refactorer, Architect,
Security Auditor, Prompt Builder) with no model field so session.model
wins. Project root AGENTS.md parsed on demand with mtime cache; when
present, only its agents are shown. sessions.agent_id resolves per turn
into effective system prompt, temperature, and a tool whitelist applied
in inference. AgentPicker mounts in the ChatInput toolbar; SettingsDrawer
agent surface deferred to Batch 7.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 20:06:51 +00:00
934f739ca1 Merge branch 'v1.7-drag-drop' 2026-05-16 15:35:07 +00:00
50a756aca1 feat(input): drag-drop + paste-as-attachment for long text 2026-05-16 15:23:41 +00:00
3cb1ead5e2 feat(mobile): add hamburger + file explorer button to root empty state 2026-05-16 15:23:33 +00:00
5932682193 feat(mobile): right-rail as drawer on mobile, header toggle button
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.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 06:37:13 +00:00
9d0d41bcb3 feat(mobile): add "New chat" to tab long-press context menu
With the Split button hidden on mobile (G1), users need another path
to create additional chat panes. Add a "New chat" ContextMenuItem at
the top of each tab's context menu, separated from Rename / Close /
etc. by a ContextMenuSeparator. Wired to the existing onNewChat prop
— no plumbing change. Available on both long-press (mobile) and
right-click (desktop).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 06:37:03 +00:00
e167f851fd feat(mobile): rework Session and Project headers for narrow viewports
Session header: breadcrumb (Projects > project) wrapped in
hidden sm:flex; active file path hidden on mobile; session name cap
max-w-[140px] sm:max-w-[280px]; padding px-3 sm:px-4. Mobile gets
just hamburger | session name | model pill.

Project header: px-3 sm:px-6, py-2 sm:py-3, heading text-base
sm:text-lg, project path hidden sm:block, "New session" button is
icon-only on mobile via <span className="hidden sm:inline">. Both
headers retain the safe-area-inset-top padding from v1.6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 06:36:36 +00:00
f6c7e12dbf fix(mobile): hide Split button + single-pane navigator chrome
v1.6 left the Workspace's Split-button row visible on mobile even
when only one pane was open — ~36px of dead chrome above the chat.
Wrap the entire Split-row in !isMobile so mobile gets header → chat
with no intermediate strip. The existing mobile pane-navigator strip
(gated to panes.length > 1) is unchanged and still appears once a
second pane is created via the long-press "New chat" menu item (G3).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 06:36:03 +00:00
6a9fe187bd fix(mobile): hide RightRail below md breakpoint
v1.6 left the right-rail file browser visible on phones (~32px column
when collapsed). Wrap the RightRail render in <div class="max-md:hidden
contents"> inside RightRailForSession so it's hidden entirely below
the md (768px) breakpoint. The `contents` class keeps the wrapper
layout-transparent on desktop. No behavior change on desktop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 06:00:25 +00:00
4b5b9b2cb3 feat(mobile): pull-to-refresh sidebar list
- 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.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 05:55:47 +00:00
273eeac68c feat(mobile): chat input keybinds + safe-area + tap targets + overflow safety
- ChatInput: e.nativeEvent.isComposing early-return added (CJK IME
  safety — first Enter of a composition no longer submits). Bare-Enter
  send path gated by !isMobile so mobile inserts a newline; send is
  button-only. Cmd/Ctrl+Enter and Shift+Cmd/Ctrl+Enter retained as
  desktop secondary bindings. Placeholder is now viewport-aware. Outer
  wrapper gets paddingBottom: env(safe-area-inset-bottom) so iOS home
  indicator doesn't overlap.
- MessageBubble: ActionRow buttons (Copy / Regenerate / Fork / Trash)
  bumped to max-md min-h/min-w 44px; opacity-100 on mobile so actions
  don't hide behind a hover-to-reveal pattern. User bubble and
  assistant content wrapper gain break-words + min-w-0 so long
  unbreakable strings (URLs / paths) wrap rather than blowing out
  the column on narrow viewports.
- ChatPane: queued-message dropdown + close X + Stop-generating button
  hit max-md 44px sizing.
- ChatTabBar: per-tab X, +/History/Close-pane action buttons hit
  max-md 44px. Tab close X is force-visible on mobile (no
  hover-to-reveal).
- M8: CodeBlock / Markdown tables / ToolCallCard already wrap
  overflow-x-auto pre-existing — no source change needed there; the
  break-words + min-w-0 additions above are the new defensive layer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 05:55:34 +00:00
cd897d6893 feat(mobile): single-pane stack + long-press tab menu + swipe-to-close
- 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).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 05:55:05 +00:00
a643b5f67f feat(mobile): viewport hook + sidebar drawer + hamburger headers
- 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).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 05:54:33 +00:00