Compare commits

...

46 Commits

Author SHA1 Message Date
3a5cf0c81a merge v1.11.3-ctxmax 2026-05-20 19:29:26 +00:00
89dcfb95dc v1.11.3: fix ctx_max capture via /props endpoint
- llama-server does not emit n_ctx in timings (confirmed empirically);
  dead code at inference.ts:479 and compaction.ts:300 never fired
- New model-context.ts: cached fetch of /upstream/<model>/props
  with positive-cache (no TTL) and 60s negative-cache
- Wired into all 4 ctx_max write sites: 3 in inference.ts
  (executeToolPhase, finalizeCompletion, runCapHitSummary) and
  1 in compaction.ts (summary row INSERT)
- AbortController 3s timeout, lenient parsing with sensible defaults
- 12 new vitest cases for the cache module (59 total)
- 7 historical assistant rows backfilled manually (see notes)
2026-05-20 19:29:26 +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
c48de06f42 merge v1.11-compaction 2026-05-20 19:05:35 +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
8cea4a899c v1.10.5: inference XML tool-call fallback parser
Some local models (qwen3-coder via llama-swap) emit tool calls as inline XML
inside delta.content rather than structured delta.tool_calls. streamCompletion
now buffers delta.content, extracts complete <tool_call>...</tool_call> blocks
via parseXmlToolCall, and pushes synthetic entries (id prefix xml_call_) into
the existing toolCallsBuffer. Native JSON path unchanged — both coexist.
Partial openers are held back so a tool tag never leaks to the chat mid-tag.
Unclosed XML at end-of-stream is flushed as plain content (no silent drops).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 17:32:42 +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
fccab20920 merge v1.10.4-booterm-mobile 2026-05-19 17:16:50 +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
4d466c5710 merge v1.10.3-booterm-ux 2026-05-19 13:52:50 +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
8eaf9591dc merge v1.10.2-booterm-glibc 2026-05-19 13:14:25 +00:00
5d52b79a07 v1.10.2: booterm runtime on bookworm-slim (glibc), su-exec → gosu
Switched the booterm runtime + proddeps stages from node:20-alpine (musl)
to node:20-bookworm-slim (glibc) so host-installed glibc binaries (Claude
Code, opencode, nvm node) run inside the container when invoked from the
terminal pane. node-pty's native .node has to be compiled in the same
libc env as the runtime, so both stages flip together; the TypeScript-only
builder stage stays on alpine.

su-exec is alpine-only; Debian replacement is gosu — swapped in both the
runtime apt install and the tmux default-command. uid/gid 1000 collision
with the bookworm `node` user handled via userdel/groupdel before
groupadd/useradd.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:14:21 +00:00
ead7cb9d01 merge v1.10.1-booterm-user 2026-05-19 13:07:59 +00:00
d04b30687f v1.10.1: booterm runs shells as samkintop with login bash 2026-05-19 13:07:59 +00:00
9250632ac3 merge v1.10-booterm 2026-05-18 14:06:46 +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
adb5d7b3bb Merge v1.9-skills: skills + /skill slash command 2026-05-18 01:52:15 +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
529a77c959 feat(server): skills v1 — parser, tools, /api/skills, mount
- /data/skills mount (host: /opt/skills)
- skill_find, skill_use, skill_resource added to default read-only
  tool set; opt-in for agents with explicit tools: whitelist
- AGENTS.md builtin agents drop explicit tools: arrays to inherit
  the new default (now includes skill tools)
- POST /api/chats/:id/skill_invoke for slash-command flow
- 19 SKILL.md files seeded at /opt/skills/ across 6 source groups
2026-05-18 01:10:51 +00:00
9a7b35b677 build: harden .dockerignore (secrets/, data/)
The host-side docker-compose mounts secrets/ and data/ read-only at
runtime, but the build context still slurped them in. Add secrets/,
data/, and general SSH key patterns (*.pem, *.key, id_rsa*,
id_ed25519*, known_hosts, .ssh/) so private material can never be
baked into the image even by accident.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:50:37 +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
b6469055d8 docs: reconcile roadmap with merged state
v1.8.3 (tool-call compaction), themes-v1, v1.9 (settings pane +
per-project defaults + bulk archive), and v1.11 (agents Tier 2) were
all marked Planned/in-flight in the roadmap despite being merged on
main. Reconcile the Batch summary table and reorder the Order of
operations to start at v1.10. Drop the stale "Active work" section —
themes-v1 description belongs in the past tense now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:50:16 +00:00
4bf2cd40c3 Merge v1.9 2026-05-17 17:37:38 +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
32c1a2b5f6 Merge themes-v1 2026-05-17 16:25:19 +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
efbecd074a Merge v1.8.2 2026-05-17 10:33:21 +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
5422c47928 gitignore data/ for global AGENTS.md
The /data dir is host-mounted into the container at /data:ro and holds
the global AGENTS.md seed (v1.8.1). It is part of the deployment
contract — anyone cloning needs to mkdir data/ + cp AGENTS.md into it
themselves — so the directory itself should never be tracked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:50:47 +00:00
b09d0ffde0 Merge v1.8.1 2026-05-16 23:16:38 +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
e9895fd694 Merge branch 'v1.6.3-mobile-root-nav' 2026-05-16 15:34:56 +00:00
83c7d33f3c Merge branch 'v1.6.5-session-rename-publish' 2026-05-16 15:34:47 +00:00
c3415574d6 Merge branch 'v1.6.4-auto-name-sessions' 2026-05-16 15:34:36 +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
5ee266a4d9 feat(auto_name): propagate first chat name to parent session
When a chat is auto-named, also rename the parent session if it is
still on its default 'New session' label. UPDATE is gated by an
atomic WHERE clause so user renames and prior propagations are not
clobbered. Publishes session_renamed via broker.publishUser; useSidebar
already listens.

Closes the gap where sessions auto-created from the sidebar would
stay 'New session' forever.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 15:23:11 +00:00
113 changed files with 13195 additions and 1088 deletions

View File

@@ -10,3 +10,13 @@ dist
.vite
coverage
/tmp
# Secrets and runtime data
secrets/
data/
*.pem
*.key
id_rsa*
id_ed25519*
known_hosts
.ssh/

1
.gitignore vendored
View File

@@ -6,3 +6,4 @@ dist
.vite
coverage
secrets/
data/

191
AGENTS.md Normal file
View File

@@ -0,0 +1,191 @@
# Agents
## Code Reviewer
---
temperature: 0.3
description: Reviews code for bugs, security issues, and maintainability. Read-only.
---
You review code. Find real problems, not style nits.
Process:
1. Read the file(s) in question with view_file. If a diff is provided, read surrounding context too.
2. Use grep/find_files to check how changed symbols are used elsewhere.
3. Cite every finding as file:line.
Prioritize in order:
1. Bugs and logic errors
2. Security issues (injection, auth bypass, secret leakage, unsafe deserialization, SSRF, path traversal)
3. Race conditions, error handling, resource leaks
4. Performance issues with measurable impact
5. Maintainability (only if it blocks future work)
Skip: formatting, naming preferences, "consider extracting", "add a comment here". The user has a linter.
Output format:
- Critical: <file:line> — <issue> — <fix>
- Major: <file:line> — <issue> — <fix>
- Minor: <file:line> — <issue> — <fix>
If nothing critical or major, say so in one line. Do not pad.
## Debugger
---
temperature: 0.2
description: Diagnoses bugs from error messages, logs, or described symptoms.
---
You diagnose bugs. Form a hypothesis, prove it with evidence from the code.
Process:
1. Restate the symptom in one line. Confirm you understand it.
2. Read the error/stacktrace. Identify the exact frame where things go wrong.
3. view_file on that frame. Read 50 lines around it.
4. grep for callers, related state, recent changes that could explain it.
5. State the root cause with file:line evidence.
6. Propose the minimal fix. Note any side effects.
Rules:
- Never guess. If evidence is missing, say what you need (specific log line, specific file, specific repro step).
- Distinguish symptom from cause. A null check fixes the symptom; missing init causes it.
- Off-by-one, race conditions, and silent except blocks are common — check for them.
- If two plausible causes exist, name both and say what would discriminate.
Output:
- Symptom: <one line>
- Root cause: <file:line> — <explanation>
- Fix: <minimal diff or description>
- Risk: <what could break>
## Refactorer
---
temperature: 0.3
description: Proposes refactors for clarity, deduplication, or decoupling. Read-only — outputs plans, not edits.
---
You propose refactors. You do not apply them. The user applies via OpenCode or Claude Code.
Process:
1. Read the target file(s).
2. grep for callers, duplicates, and similar patterns elsewhere in the repo.
3. Identify the smallest refactor that delivers the goal.
Prioritize:
1. Deduplication where 3+ sites have near-identical logic
2. Extracting a function/module when one is doing two unrelated jobs
3. Decoupling when a change in A forces a change in B unnecessarily
4. Renaming when a name actively misleads
Reject:
- Refactors that touch 10+ files for marginal gain
- "Modernization" with no concrete benefit
- Abstraction for future flexibility that may never come
- Style-only changes
Output:
- Goal: <one line>
- Scope: <files affected, count of lines roughly>
- Plan: numbered steps, each one self-contained
- Risk: <what tests must pass, what could regress>
- Skip if: <conditions under which this refactor is not worth doing>
## Architect
---
temperature: 0.5
description: Designs new features, modules, or architectural changes. Outputs a build plan.
---
You design. You produce build plans, not code.
Process:
1. Restate the goal in your own words. Confirm constraints (perf, deploy, deps).
2. list_dir the relevant areas. Read existing patterns — match them unless there's a reason not to.
3. Decide: extend existing code or add new module. Justify.
4. Sketch the data flow: inputs → transforms → outputs → side effects.
5. Identify integration points: DB schema, API surface, env vars, container boundaries.
6. List failure modes and how the design handles them.
Rules:
- Reuse before inventing. If a service/lib in the repo already does this, say so.
- Prefer boring tech. New deps require justification.
- Tailscale IPs for internal routing. No 0.0.0.0 binds.
- Least privilege: separate read/write paths, explicit auth gates.
- State assumptions inline. Do not ask clarifying questions mid-design unless blocked.
Output:
- Goal
- Existing code to reuse: <file paths>
- New code: <file paths, one-line purpose each>
- Data model changes: <SQL or schema diff>
- API surface: <endpoints, request/response shapes>
- Failure modes: <list>
- Build order: numbered, each step 30-90 min
## Security Auditor
---
temperature: 0.2
description: Audits code for security vulnerabilities. Read-only.
---
You audit for security issues. Concrete findings only, no generic warnings.
Process:
1. Identify the trust boundary: where does untrusted input enter? Where does it leave?
2. Trace input flow with grep. Mark every transformation.
3. Check each finding against a real attack scenario.
Look for:
- Injection: SQL (raw queries, string concat into queries), command (subprocess with shell=True, unescaped args), XSS (unescaped output in HTML/JSX), template injection, NoSQL injection
- AuthN/AuthZ: missing checks on routes, IDOR (user-supplied IDs without ownership check), JWT misuse (alg=none, weak secret, no expiry), session fixation
- Secrets: hardcoded keys/passwords, .env in repo, secrets in logs, secrets in error messages
- Crypto: weak hashes (MD5, SHA1 for passwords), missing salt, predictable randomness (Math.random for tokens), ECB mode, custom crypto
- Network: SSRF (user URL → server fetch), open CORS, missing CSRF on state-changing requests, plaintext over public network
- File: path traversal, unrestricted upload type/size, zip slip
- Deserialization: pickle, yaml.load, eval, exec on user input
- Resource: missing rate limits on auth/expensive endpoints, unbounded query results
For each finding:
- Severity: Critical / High / Medium / Low
- Location: file:line
- Attack scenario: one sentence describing how an attacker exploits this
- Fix: minimal change
Skip:
- Generic "use HTTPS" advice
- "Consider adding rate limiting" without a specific endpoint
- CVE-of-the-week scares without proof the code is affected
If the code is clean, say so. Do not invent findings.
## Prompt Builder
---
temperature: 0.4
description: Builds prompts for OpenCode, Claude Code, or BooCode dispatch.
---
You write prompts that another coding agent will execute. Your output is the prompt, not the work.
Process:
1. Ask the user (or read context) for: goal, target repo, target files if known, constraints.
2. list_dir and view_file the target area. Confirm files exist and are roughly the shape you think.
3. Identify imports, exports, and conventions in the repo (component layout, error handling style, test framework).
4. Write the prompt.
Prompt structure:
- One-line goal at the top
- Constraints block: don't commit, don't push, don't pull. Use `#careful` and `#nofluff` style hashtags if the target agent honors them
- Pre-flight: list_dir or grep commands the agent must run before writing (e.g. "run: ls frontend/src/components/ui/ and only import primitives that exist")
- Files to modify: explicit paths
- Files to create: explicit paths with one-line purpose
- Behavior spec: numbered, testable
- Backup rule: `cp file file.bak-$(date +%Y%m%d)` before any destructive edit
- Verification: `py_compile`, `tsc --noEmit`, `docker compose up --build -d` — whichever applies
- Stop conditions: when to halt and report instead of pressing on
Rules:
- Tailored to the target agent: OpenCode honors hashtag snippets and skills; Claude Code honors CLAUDE.md and slash commands; BooCode batches are written as user-facing markdown
- Never include credentials or secrets
- Never instruct the agent to commit or push
- Include the exact model the user wants if dispatch is via Paseo or BooCode batch
- For BooLab frontend prompts, always include the "verify shadcn primitives exist" preflight
Output: the prompt, ready to paste. Nothing else.

View File

@@ -6,6 +6,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
Self-hosted single-user developer chat app. AI assistant with read-only file tools (view_file, list_dir, grep, find_files) running against a local llama-swap inference server. Sessions organized by project, with a multi-pane workspace (chat + file browser side by side).
Plus `apps/booterm` (second container, port 9501, bookworm-slim+glibc): Fastify + node-pty + tmux. Browser terminal panes WS to `/ws/term/sessions/:sid/panes/:pid`; per-session tmux session `bc-<sid>`, per-pane window `term-<pid>`. Shells drop privs to samkintop via `gosu` in `tmux.conf` default-command.
## Commands
```bash
@@ -31,11 +33,11 @@ npx tsc -p apps/web/tsconfig.app.json --noEmit # web app specifically
docker compose build --no-cache boocode && docker compose up -d
```
There are no tests or linters configured.
Tests: `pnpm -C apps/server test` runs 23 vitest tests. No test harness on `apps/web` (adding it requires installing vitest as a new devDep). Vitest pinned to `^3` because Vite 5 / vitest 4 are incompatible. No linters configured.
## Architecture
**Monorepo**: pnpm workspaces with `apps/server` (Fastify + postgres) and `apps/web` (React + Vite).
**Monorepo**: pnpm workspaces with `apps/server` (Fastify + postgres), `apps/web` (React + Vite), and `apps/booterm` (Fastify + node-pty + tmux).
### Server (`apps/server/src/`)
@@ -44,7 +46,7 @@ There are no tests or linters configured.
- **Zod** for request validation and config parsing.
Key services:
- **`services/inference.ts`** — Streams LLM responses, executes tool loops (max 5 depth), flushes to DB every 500ms. Publishes `InferenceFrame` events through the broker.
- **`services/inference.ts`** — Streams LLM responses, executes tool loops (max depth 15, see `MAX_TOOL_LOOP_DEPTH`), flushes to DB every 500ms. Publishes `InferenceFrame` events through the broker.
- **`services/broker.ts`** — In-memory pub/sub with two channel types: per-session (message streaming) and per-user (sidebar updates). No persistence; clients reconnect on restart.
- **`services/tools.ts`** — Four read-only file tools exposed as OpenAI function-calling schemas. All file access goes through `path_guard.ts` which resolves against project root.
- **`services/file_ops.ts`** — Shared file operation implementations used by both inference tools and HTTP routes.
@@ -57,6 +59,7 @@ Route registration: all routes registered in `index.ts` via `register*Routes(app
- **React 18** + React Router v6 + **Tailwind v4** + shadcn/radix-ui primitives.
- **Shiki** for syntax highlighting (async `codeToHtml` in `CodeBlock.tsx` and `FileViewer` in `FileBrowserPane.tsx`).
- Path alias: `@/` maps to `src/`.
- **Mobile interaction primitives** (post-v1.6): `useViewport` (matchMedia, breakpoints mobile <768 / tablet 7681023 / desktop ≥1024), `useSidebarDrawer` / `useRightRailDrawer` (Context + auto-close on `useLocation().pathname` change), `useLongPress` (500ms timer, dispatches synthetic `contextmenu` on `[data-tab-id]`), `usePullToRefresh` (80px threshold, 600ms hold), `SwipeablePaneTab` (60px close, 30px vertical bail). Tap-target convention: `max-md:min-h-[44px] max-md:min-w-[44px]`. Mobile headers: `border-b px-3 sm:px-4 py-2` + `style={{ paddingTop: 'max(0.5rem, env(safe-area-inset-top))' }}`. Hamburger left, FolderTree right.
Key patterns:
- **`hooks/sessionEvents.ts`** — Module-singleton event bus (Set of listeners). Used for cross-component communication: session renames, file-open events, attachment dispatch. 9 event types in the discriminated union. When adding a new event type to the `SessionEvent` union, you must also add a case to the `applyEvent` switch in `useSidebar.ts` (even if it's a no-op `return prev`).
@@ -65,6 +68,13 @@ Key patterns:
- **`hooks/useSidebar.ts`** — Module-singleton with Set<setState> subscriber pattern; one bus subscription guarded by `globalThis.__boocode_sidebar_subscribed` for HMR safety. Every new `SessionEvent` type needs a `case` in the `applyEvent` switch (no-op `return prev` is fine).
- **`api/client.ts`** — Centralized typed fetch wrapper. All endpoints under `api.*` namespace.
Font / CSS pipeline (apps/web):
- Tailwind v4's `@import "tailwindcss"` directive strips font URLs from subsequent CSS `@import`s — `@fontsource*` packages must be imported as JS side-effect modules in `apps/web/src/main.tsx`, not via `@import` in `globals.css`. Otherwise the woff2 files never make it to `dist/`.
- Lightning CSS (inside `@tailwindcss/postcss` v4) collapses contiguous unicode-ranges to wildcard shorthand (`U+0000-FFFF``U+????`), which iOS Safari/Vivaldi mishandles (silently drops the font from those codepoints). Use explicit non-wildcard-collapsible subranges (e.g. `U+2500-259F` not `U+2500-25FF`). The `apps/web` build script greps `dist/assets/*.css` for `U+2500-259F` and fails the build if missing — preserve that guard.
- `@font-face` blocks must live AFTER all `@import` statements (CSS spec). Earlier placement silently breaks every subsequent `@import` (this broke the 18 theme palette imports in globals.css for one session).
- JetBrainsMono Nerd Font self-hosted in `apps/web/src/fonts/` (TTF from ryanoasis/nerd-fonts release) — needed because `@fontsource-variable/jetbrains-mono` ships subsetted woff2s that don't cover `U+2500-259F` (box drawing + block elements, used by opencode's banner). "NL" = No Ligatures (matches `font-feature-settings: "liga" 0`); "Mono" = single-cell icon width so TUI layouts don't desync.
- xterm-addon-webgl rasterizes glyphs via Canvas2D into a GPU texture atlas. Canvas2D does NOT honor `font-display: block` — it uses whatever font is currently registered. Gate xterm initialization on `document.fonts.load(<font-name>)` resolving before calling `term.open()` (see `fontsReady` useState in `TerminalPane.tsx`). iOS Safari/Vivaldi also reclaims WebGL contexts from backgrounded tabs: keep `webgl.onContextLoss(() => webgl.dispose())` + recreate via visibilitychange. Do NOT manually dispose+recreate the addon after font load — iOS silently fails the second GL context creation and the terminal drops to DOM renderer with stale metrics.
### Data flow for chat
1. User sends message → POST `/api/sessions/:id/messages` creates user + assistant (status=streaming) rows
@@ -76,7 +86,7 @@ Key patterns:
### Multi-pane workspace
Sessions hold 15 panes (chat / empty / placeholder terminal+agent). Workspace pane state is **client-side only** (localStorage keyed by sessionId); the legacy `session_panes` table is deprecated. Each chat lives in at most one pane; tab strip is per-pane and tracks `chatIds[]` + `activeChatIdx`. Sessions 1:N chats; chats own messages. Tab reorder via native HTML5 drag events.
Sessions hold 15 panes (chat / empty / placeholder terminal+agent). Workspace pane state is **client-side only** (localStorage key `boocode.workspace.panes.<sessionId>`); the legacy `session_panes` table and its REST endpoints are deprecated — no `/api/panes/*` routes exist. Each chat lives in at most one pane; tab strip is per-pane and tracks `chatIds[]` + `activeChatIdx`. Sessions 1:N chats; chats own messages. Tab reorder via native HTML5 drag events.
## Database
@@ -94,9 +104,16 @@ Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0
- Sam reviews all diffs and commits manually. Do not commit unless explicitly asked.
- Deploy: `cd /opt/boocode && docker compose up --build -d` (or `docker compose build --no-cache boocode && docker compose up -d` if you suspect a layer-cache issue).
- Git push to Gitea: `GIT_SSH_COMMAND="ssh -i /opt/boocode/secrets/boocode_gitea -o IdentitiesOnly=yes" git push origin <branch>`. The default agent identity is rejected; the in-repo deploy key (`secrets/`, gitignored) is the working one. Transient `Connection reset by peer` retries cleanly after `sleep 5`.
- Don't accumulate `.bak-*` files. Clean them up in the same batch or immediately after merge.
- Fastify global JSON parser tolerates empty bodies (overridden in `index.ts`); bodyless POSTs (archive, unarchive, stop) work without setting `Content-Type` tricks on the client.
- Event dedup discipline: for any mutation the server publishes via `broker.publishUser`, do NOT add a local `sessionEvents.emit(...)` after the API call — `useUserEvents` forwards the WS frame onto the bus. Frontend mutation handlers must be idempotent (dedup by id, no-op on already-present).
- `node:20-*` base images ship a `node` user at uid/gid 1000 — delete it (`userdel`/`groupdel` on debian, `deluser`/`delgroup` on alpine) before adding samkintop at 1000.
- node-pty's compiled `.node` is libc-specific: proddeps and runtime Dockerfile stages must share libc (alpine↔musl or bookworm-slim↔glibc); the TS-only builder stage can stay alpine for speed.
- pnpm 10 `--frozen-lockfile` skips node-pty's postinstall — the Docker proddeps stage runs `cd node_modules/node-pty && npm run install` to force the native compile.
- A local PreToolUse hook (`security_reminder_hook.py`) regex-flags Node's older `child_process` spawn helpers as unsafe (false positive even on the File-suffixed variant). Use `spawn` — it's accepted.
- `/opt/boolab` hosts a working sibling BooCode terminal at `boocode.indifferentketchup.com`. Useful for visual side-by-side comparison on the same iPhone when debugging booterm rendering. Boolab uses Tailwind v3 (`@tailwind base`); boocode uses v4 — many subtle build differences. Don't assume parity.
- booterm SSHs to the host as `samkintop@100.114.205.53` (the Tailscale IP). The hostname `ubuntu-homelab` (shown in the bash prompt after login) does NOT resolve from inside the container — only the host's `/etc/hosts` knows it. Override via `BOOTERM_SSH_HOST` / `BOOTERM_SSH_USER` env vars in docker-compose if you ever move the shell to a different machine.
## Conventions
@@ -107,3 +124,7 @@ Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0
- Discriminated unions for type narrowing: `Pane` (by `kind`), `SessionEvent` (by `type`), `InferenceFrame` (by `type`).
- shadcn primitives live in `components/ui/`. Don't modify them unless adding a new primitive.
- `inferLanguage()` from `lib/attachments.ts` is the canonical file-extension-to-language map. `CodeBlock.tsx` keeps its own `LANG_MAP` because it also resolves markdown fence names.
- Two UI event buses: `hooks/sessionEvents.ts` for DB-state events (chat_created, session_updated); `lib/events.ts` for ephemeral UI (`sendToTerminal`, `terminalsRegistry`). Don't merge — different subscriber lifecycles.
- `vite.config.ts` proxy entries are order-sensitive: more-specific prefixes (`/api/term`, `/ws/term`) must come BEFORE `/api`.
- Mobile pane URL sync (`Session.tsx`): the `?pane=<id>` effect resets `activePaneIdx` whenever `panes` changes. New-pane creation on mobile must push `?pane=` atomically — `addPaneAndSwitch` is the wrapper that does this. `addSplitPane` returns the new pane id for callers.
- xterm.js v5 uses canvas rendering — browser doesn't see xterm's selection; the native right-click menu has no working Copy for terminal text. App keybindings (`Cmd/Ctrl-C`, `Cmd/Ctrl-Shift-C`) are the path.

67
apps/booterm/Dockerfile Normal file
View File

@@ -0,0 +1,67 @@
# syntax=docker/dockerfile:1.7
# ---- Build stage: compile TypeScript ----
FROM node:20-alpine AS builder
ENV COREPACK_DEFAULT_TO_LATEST=0
RUN corepack enable && corepack prepare pnpm@10.15.1 --activate
RUN apk add --no-cache python3 make g++
WORKDIR /build
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml tsconfig.base.json ./
COPY apps/server/package.json ./apps/server/
COPY apps/web/package.json ./apps/web/
COPY apps/booterm/package.json ./apps/booterm/
RUN pnpm install --frozen-lockfile
COPY apps/booterm ./apps/booterm
RUN pnpm --filter=@boocode/booterm build
# ---- Prod-deps stage: hoisted, native built via npm rebuild ----
# v1.10.2: switched to bookworm-slim (glibc) so node-pty's native .node is
# compiled against the same libc as the runtime stage. A musl-built .node
# won't dlopen in a glibc node binary, so both stages must match.
FROM node:20-bookworm-slim AS proddeps
ENV COREPACK_DEFAULT_TO_LATEST=0
RUN corepack enable && corepack prepare pnpm@10.15.1 --activate
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /prod
COPY apps/booterm/package.json ./package.json
RUN pnpm install --prod --config.node-linker=hoisted --config.strict-peer-dependencies=false
# pnpm 10 ignores build scripts; force compile with npm directly.
# node-gyp is bundled with npm in the node:20-bookworm-slim image.
RUN cd node_modules/node-pty && npm run install
# Sanity check — fail the build if the artifact still isn't there
RUN test -f node_modules/node-pty/build/Release/pty.node && echo "pty.node OK" || (echo "pty.node MISSING" && exit 1)
# ---- Runtime ----
# v1.10.2: switched from node:20-alpine (musl) to node:20-bookworm-slim (glibc)
# so glibc-linked binaries from /home/samkintop (Claude Code, opencode, the
# host's nvm node) run inside the container when invoked from the terminal
# pane. Side-effect: su-exec is alpine-only — Debian replacement is gosu.
FROM node:20-bookworm-slim AS runtime
# v1.10.8d: openssh-client added so the terminal can ssh -t samkintop@host
# (matching boolab's pattern) — that's how the in-pane shell gets access to
# host tools (docker, claude, opencode) that don't exist inside the container.
RUN apt-get update && apt-get install -y --no-install-recommends \
tmux bash gosu ca-certificates procps openssh-client \
&& rm -rf /var/lib/apt/lists/*
# Mirror uid/gid 1000:1000 from the host so the bind-mounted /home/samkintop
# (added in docker-compose) is owned by the user from the container's view.
# bookworm-slim ships a `node` user at 1000 — wipe whatever sits on uid/gid
# 1000 first, then create samkintop fresh.
RUN if id -u 1000 >/dev/null 2>&1; then \
userdel -r "$(id -un 1000)" 2>/dev/null || true; \
fi; \
if getent group 1000 >/dev/null 2>&1; then \
groupdel "$(getent group 1000 | cut -d: -f1)" 2>/dev/null || true; \
fi; \
groupadd -g 1000 samkintop && \
useradd -m -u 1000 -g 1000 -s /bin/bash samkintop
WORKDIR /app
COPY --from=builder /build/apps/booterm/dist ./dist
COPY --from=proddeps /prod/package.json ./package.json
COPY --from=proddeps /prod/node_modules ./node_modules
COPY apps/booterm/tmux.conf /etc/booterm/tmux.conf
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "dist/index.js"]

27
apps/booterm/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "@boocode/booterm",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"typecheck": "tsc --noEmit",
"start": "node dist/index.js"
},
"dependencies": {
"@fastify/websocket": "^10.0.1",
"fastify": "^4.28.1",
"node-pty": "^1.0.0",
"pg": "^8.13.0",
"tslib": "^2.6.3",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^20.14.10",
"@types/pg": "^8.11.10",
"tsx": "^4.16.2",
"typescript": "^5.5.0"
}
}

11
apps/booterm/src/auth.ts Normal file
View File

@@ -0,0 +1,11 @@
import type { FastifyRequest } from 'fastify';
// Mirrors the boocode pattern: there is no app-layer auth — Authelia handles
// it at the reverse proxy (CLAUDE.md). All broker.publishUser calls use
// 'default' as the user key. We accept Remote-User when present (set by the
// proxy in prod) and fall back to 'default' on direct Tailscale access.
export function getUser(req: FastifyRequest): string {
const header = req.headers['remote-user'];
if (typeof header === 'string' && header.length > 0) return header;
return 'default';
}

View File

@@ -0,0 +1,26 @@
import { z } from 'zod';
const ConfigSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.coerce.number().int().positive().default(3000),
HOST: z.string().default('0.0.0.0'),
DATABASE_URL: z.string().url(),
LOG_LEVEL: z.string().default('info'),
TMUX_CONF_PATH: z.string().default('/etc/booterm/tmux.conf'),
});
export type Config = z.infer<typeof ConfigSchema>;
let cached: Config | null = null;
export function loadConfig(): Config {
if (cached) return cached;
const parsed = ConfigSchema.safeParse(process.env);
if (!parsed.success) {
console.error('Invalid environment configuration:');
console.error(parsed.error.flatten().fieldErrors);
process.exit(1);
}
cached = parsed.data;
return cached;
}

46
apps/booterm/src/db.ts Normal file
View File

@@ -0,0 +1,46 @@
import pg from 'pg';
const { Pool } = pg;
let pool: pg.Pool | null = null;
export function getPool(databaseUrl: string): pg.Pool {
if (pool) return pool;
pool = new Pool({ connectionString: databaseUrl, max: 5, idleTimeoutMillis: 30_000 });
return pool;
}
export interface SessionInfo {
id: string;
project_id: string;
project_path: string;
}
export async function getSessionInfo(sessionId: string): Promise<SessionInfo | null> {
if (!pool) throw new Error('db pool not initialized');
const res = await pool.query<SessionInfo>(
`SELECT s.id, s.project_id, p.path AS project_path
FROM sessions s
JOIN projects p ON p.id = s.project_id
WHERE s.id = $1`,
[sessionId],
);
return res.rows[0] ?? null;
}
export async function pingDb(): Promise<boolean> {
if (!pool) return false;
try {
await pool.query('SELECT 1');
return true;
} catch {
return false;
}
}
export async function closeDb(): Promise<void> {
if (pool) {
await pool.end();
pool = null;
}
}

60
apps/booterm/src/index.ts Normal file
View File

@@ -0,0 +1,60 @@
import Fastify from 'fastify';
import fastifyWebsocket from '@fastify/websocket';
import { loadConfig } from './config.js';
import { getPool, closeDb } from './db.js';
import { registerHealthRoutes } from './routes/health.js';
import { registerTerminalRoutes } from './routes/terminals.js';
import { registerWsAttachRoute } from './ws/attach.js';
async function main(): Promise<void> {
const config = loadConfig();
const app = Fastify({
logger: { level: config.LOG_LEVEL },
});
app.removeContentTypeParser(['application/json']);
app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => {
const str = (body as string) ?? '';
if (str.trim().length === 0) {
done(null, {});
return;
}
try {
done(null, JSON.parse(str));
} catch (err) {
done(err as Error, undefined);
}
});
getPool(config.DATABASE_URL);
await app.register(fastifyWebsocket);
registerHealthRoutes(app);
registerTerminalRoutes(app, config.TMUX_CONF_PATH);
registerWsAttachRoute(app, config.TMUX_CONF_PATH);
const shutdown = async (signal: string) => {
app.log.info(`received ${signal}, shutting down`);
try {
await app.close();
await closeDb();
process.exit(0);
} catch (err) {
app.log.error(err);
process.exit(1);
}
};
process.on('SIGINT', () => void shutdown('SIGINT'));
process.on('SIGTERM', () => void shutdown('SIGTERM'));
await app.listen({ port: config.PORT, host: config.HOST });
app.log.info(`booterm listening on http://${config.HOST}:${config.PORT}`);
}
main().catch((err) => {
console.error('Fatal startup error:', err);
process.exit(1);
});

View File

@@ -0,0 +1,164 @@
import { spawn } from 'node:child_process';
import type { FastifyBaseLogger } from 'fastify';
const ID_RE = /^[a-zA-Z0-9_-]{1,64}$/;
export function sanitizeId(raw: string): string | null {
if (!ID_RE.test(raw)) return null;
return raw.toLowerCase();
}
// v1.10.8c: per-pane tmux sessions (boolab pattern). Previously booterm used
// one tmux session per chat-session with one window per pane; that meant the
// session-level window-size policy was shared across panes, and
// `attach-session -d` (used to take over from a stale browser) would detach
// every other pane attached to the same session — the "[detached]" bug.
// Now each pane gets its own tmux session named `bc-<paneId>`. The bc- prefix
// namespaces booterm sessions on the shared tmux server.
export function tmuxSessionName(paneId: string): string {
return `bc-${paneId}`;
}
interface CmdResult {
stdout: string;
stderr: string;
code: number;
}
function runTmux(tmuxConfPath: string, args: string[]): Promise<CmdResult> {
return new Promise((resolve) => {
const child = spawn('tmux', ['-f', tmuxConfPath, ...args], { shell: false });
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk: Buffer) => {
stdout += chunk.toString('utf8');
});
child.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString('utf8');
});
child.on('error', (err) => {
resolve({ stdout, stderr: stderr + String(err), code: 1 });
});
child.on('close', (code) => {
resolve({ stdout, stderr, code: code ?? 0 });
});
});
}
export async function hasSession(tmuxConfPath: string, sessionName: string): Promise<boolean> {
const res = await runTmux(tmuxConfPath, ['has-session', '-t', `=${sessionName}`]);
return res.code === 0;
}
// Default fallback size — wider than any real terminal would care about; the
// real client size lands via the WS resize frame within a few ms of attach.
const DEFAULT_COLS = 200;
const DEFAULT_ROWS = 50;
// v1.10.8d: per-pane shell is `ssh -t samkintop@SSH_HOST` (matches boolab's
// pattern). The container has no docker / claude / opencode binaries; SSH'ing
// to the host gives the user their full normal shell environment. Default is
// the host's Tailscale IP (100.114.205.53) — the hostname `ubuntu-homelab`
// only resolves on the host's local /etc/hosts, not from inside containers,
// so SSH'ing to the hostname fails with `Could not resolve hostname` even
// though the host machine is reachable. Boolab uses the same IP.
const SSH_HOST = process.env['BOOTERM_SSH_HOST']?.trim() || '100.114.205.53';
const SSH_USER = process.env['BOOTERM_SSH_USER']?.trim() || 'samkintop';
// POSIX shell single-quote escape: wrap in '…', escape embedded singles by
// closing-the-quote, inserting an escaped quote, and re-opening.
function shellEscape(s: string): string {
return `'${s.replace(/'/g, `'\\''`)}'`;
}
// Idempotent. Creates the tmux session if it doesn't exist, sized via -x/-y
// from the client's measured xterm dimensions. With `window-size = largest`
// + `aggressive-resize on` in tmux.conf, the attached client's actual size
// wins once it reports in — but seeding at the right size avoids the brief
// window where bash/TUI inherits the default 80x24 from a stale fallback.
export async function ensureSession(
tmuxConfPath: string,
sessionName: string,
projectRoot: string,
log: FastifyBaseLogger,
cols?: number,
rows?: number,
): Promise<void> {
if (await hasSession(tmuxConfPath, sessionName)) return;
const sizeCols = cols && cols > 0 ? Math.floor(cols) : DEFAULT_COLS;
const sizeRows = rows && rows > 0 ? Math.floor(rows) : DEFAULT_ROWS;
// Bypass tmux.conf's default-command — build the per-pane argv explicitly
// so we can wrap ssh in the gosu privilege drop. The remote shell sequence
// (per boolab's invariants in services/tmux_session.py target_cmd_for):
// 1. ssh's argv must flatten into a single quoted bash -lc <script>
// 2. -l on the outer bash sources ~/.profile on the remote (PATH etc.)
// 3. cd to projectRoot, then exec bash -l so the user lands in the repo
// /opt is bind-mounted host↔container, so projectRoot resolves to the
// same files on both sides.
const remoteScript = `cd ${shellEscape(projectRoot)} && exec bash -l`;
const remoteCmd = `bash -lc ${shellEscape(remoteScript)}`;
const argv = [
'new-session', '-d',
'-s', sessionName,
'-c', projectRoot,
'-x', String(sizeCols),
'-y', String(sizeRows),
'--',
// gosu drops privs from the container's root (tmux server runs as root)
// to samkintop:samkintop. env restores HOME/USER/SHELL so ssh finds the
// right ~/.ssh/id_ed25519 (key is mode 0600 and ssh refuses keys whose
// UID doesn't match the running user — both are 1000 here).
'gosu', 'samkintop:samkintop',
'env', 'HOME=/home/samkintop', 'USER=samkintop', 'SHELL=/bin/bash',
'ssh', '-t',
'-o', 'StrictHostKeyChecking=yes',
'-o', 'ServerAliveInterval=30',
'-o', 'ServerAliveCountMax=3',
`${SSH_USER}@${SSH_HOST}`,
remoteCmd,
];
log.info(
{ sessionName, projectRoot, cols: sizeCols, rows: sizeRows, sshTarget: `${SSH_USER}@${SSH_HOST}` },
'creating tmux session (ssh to host)',
);
const res = await runTmux(tmuxConfPath, argv);
if (res.code !== 0) {
log.error({ res }, 'tmux new-session failed');
throw new Error(`tmux new-session failed: ${res.stderr}`);
}
}
export async function killSession(
tmuxConfPath: string,
sessionName: string,
): Promise<boolean> {
const res = await runTmux(tmuxConfPath, ['kill-session', '-t', sessionName]);
return res.code === 0;
}
// v1.10.8c: capture-pane on WS attach to replay the buffer state to the fresh
// xterm (boolab pattern). `-e` preserves ANSI escape sequences so colours and
// cursor position survive the replay. Returns empty string on failure — the
// client falls back to whatever tmux itself decides to repaint, which is
// non-fatal but visually noisier.
//
// v1.10.8d: strip trailing blank rows. tmux capture-pane emits one `\n` per
// pane row (including all the empty rows below the actual content), so on a
// fresh 35-row pane with just the bash prompt at row 0, the output is
// `<prompt>` followed by 35 `\n` bytes. When xterm.write()s those naively,
// the cursor advances row-by-row until it hits the bottom of the canvas and
// scrolls — pushing the prompt into the scrollback buffer where the user
// can't see it. Stripping the trailing newlines leaves xterm's cursor at the
// natural end of the rendered content (matching tmux's actual cursor
// position for the common single-line-prompt case).
export async function capturePane(
tmuxConfPath: string,
sessionName: string,
lines: number = 2000,
): Promise<string> {
const res = await runTmux(tmuxConfPath, [
'capture-pane', '-t', sessionName, '-p', '-e', '-S', `-${lines}`,
]);
if (res.code !== 0) return '';
return res.stdout.replace(/(?:\r?\n)+$/, '');
}

View File

@@ -0,0 +1,48 @@
import * as pty from 'node-pty';
import type { IPty } from 'node-pty';
export interface AttachPtyOptions {
sessionName: string;
projectRoot: string;
cols: number;
rows: number;
tmuxConfPath: string;
}
function cleanEnv(): { [key: string]: string } {
const out: { [key: string]: string } = {};
for (const [k, v] of Object.entries(process.env)) {
if (typeof v === 'string') out[k] = v;
}
out['TERM'] = 'screen-256color';
return out;
}
// v1.10.8c: no `-d` (multi-attach friendly — boolab pattern). With per-pane
// tmux sessions, dropping `-d` means multiple browser tabs viewing the same
// pane share one tmux session as N clients; tmux fans I/O at the session
// layer just like boolab's backend. The earlier `-d` flag detached EVERY
// other client of the session — across windows — which caused the
// "[detached] from session" bug whenever a new pane attached to a chat
// session that already had another pane open.
//
// Tmux server + session persist across PTY exits, so a refresh resumes with
// full scrollback. Explicit destroy happens via the /kill route (called from
// the frontend when the user closes a pane).
export function attachPty(opts: AttachPtyOptions): IPty {
return pty.spawn(
'tmux',
[
'-f', opts.tmuxConfPath,
'attach-session',
'-t', opts.sessionName,
],
{
name: 'xterm-256color',
cols: opts.cols,
rows: opts.rows,
cwd: opts.projectRoot,
env: cleanEnv(),
},
);
}

View File

@@ -0,0 +1,9 @@
import type { FastifyInstance } from 'fastify';
import { pingDb } from '../db.js';
export function registerHealthRoutes(app: FastifyInstance): void {
app.get('/api/term/health', async () => {
const dbOk = await pingDb();
return { ok: true, db: dbOk };
});
}

View File

@@ -0,0 +1,93 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { getSessionInfo } from '../db.js';
import {
sanitizeId,
tmuxSessionName,
ensureSession,
killSession,
hasSession,
} from '../pty/manager.js';
const ParamsSchema = z.object({ sid: z.string(), pid: z.string() });
// v1.10.8c: optional cols/rows on /start so the per-pane tmux session is
// born at the right dimensions. Bodyless POSTs remain valid (Fastify's
// tolerant parser).
const StartBodySchema = z
.object({
cols: z.coerce.number().int().min(1).max(2000).optional(),
rows: z.coerce.number().int().min(1).max(2000).optional(),
})
.partial()
.optional();
export function registerTerminalRoutes(app: FastifyInstance, tmuxConfPath: string): void {
// v1.10.8c: /start creates the per-pane tmux session. Idempotent — a second
// /start on the same paneId is a no-op (hasSession returns true). The WS
// attach handler also calls ensureSession as belt-and-suspenders, so /start
// is technically optional, but having it as a separate step surfaces tmux
// errors as HTTP responses (vs WS 1011 close codes).
app.post<{
Params: { sid: string; pid: string };
Body: { cols?: number; rows?: number } | undefined;
}>(
'/api/term/sessions/:sid/panes/:pid/start',
async (req, reply) => {
const p = ParamsSchema.safeParse(req.params);
if (!p.success) return reply.code(400).send({ error: 'bad_params' });
const sid = sanitizeId(p.data.sid);
const pid = sanitizeId(p.data.pid);
if (!sid || !pid) return reply.code(400).send({ error: 'bad_id_format' });
const b = StartBodySchema.safeParse(req.body ?? {});
const cols = b.success ? b.data?.cols : undefined;
const rows = b.success ? b.data?.rows : undefined;
const session = await getSessionInfo(sid);
if (!session) return reply.code(404).send({ error: 'unknown_session' });
const sessionName = tmuxSessionName(pid);
try {
await ensureSession(
tmuxConfPath,
sessionName,
session.project_path,
req.log,
cols,
rows,
);
} catch (err) {
req.log.error({ err }, 'ensureSession failed');
return reply.code(500).send({ error: 'tmux_failed' });
}
return reply.code(200).send({ tmux_session: sessionName });
},
);
// v1.10.8c: explicit pane teardown. Frontend calls this when the user
// intentionally closes a terminal pane (vs an implicit WS disconnect, which
// leaves the tmux session intact for refresh-driven resume).
app.post<{ Params: { sid: string; pid: string } }>(
'/api/term/sessions/:sid/panes/:pid/kill',
async (req, reply) => {
const p = ParamsSchema.safeParse(req.params);
if (!p.success) return reply.code(400).send({ error: 'bad_params' });
const sid = sanitizeId(p.data.sid);
const pid = sanitizeId(p.data.pid);
if (!sid || !pid) return reply.code(400).send({ error: 'bad_id_format' });
const sessionName = tmuxSessionName(pid);
if (!(await hasSession(tmuxConfPath, sessionName))) {
return reply.code(404).send({ error: 'unknown_pane' });
}
const killed = await killSession(tmuxConfPath, sessionName);
if (!killed) return reply.code(500).send({ error: 'tmux_kill_failed' });
return reply.code(200).send({ ok: true });
},
);
// Resize endpoint removed in v1.10.8c. Resize now flows in-band via the
// WebSocket as a `{type:"resize",cols,rows}` text frame — no more race
// between active-PTY-map registration and HTTP POST lookup. See ws/attach.ts.
}

View File

@@ -0,0 +1,168 @@
import type { FastifyInstance } from 'fastify';
import type { IPty } from 'node-pty';
import { getSessionInfo } from '../db.js';
import {
sanitizeId,
tmuxSessionName,
ensureSession,
capturePane,
} from '../pty/manager.js';
import { attachPty } from '../pty/pty.js';
import { getUser } from '../auth.js';
export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string): void {
app.get<{
Params: { sid: string; pid: string };
Querystring: { cols?: string; rows?: string };
}>(
'/ws/term/sessions/:sid/panes/:pid',
{ websocket: true },
async (socket, req) => {
const sid = sanitizeId(req.params.sid);
const pid = sanitizeId(req.params.pid);
if (!sid || !pid) {
socket.close(1008, 'bad_id_format');
return;
}
const user = getUser(req);
req.log.info({ user, sid, pid }, 'ws attach');
const session = await getSessionInfo(sid);
if (!session) {
socket.close(1008, 'unknown_session');
return;
}
const sessionName = tmuxSessionName(pid);
const cols = parseInt(req.query.cols ?? '', 10) || 80;
const rows = parseInt(req.query.rows ?? '', 10) || 24;
// Idempotent — /start typically created the session already, but cover
// the race where the client opens the WS before /start's response lands
// (or skips /start entirely). With per-pane tmux sessions there's no
// cross-pane interference, so creating-on-attach is safe.
try {
await ensureSession(
tmuxConfPath,
sessionName,
session.project_path,
req.log,
cols,
rows,
);
} catch (err) {
req.log.error({ err }, 'ensureSession failed in WS handler');
socket.close(1011, 'tmux_failed');
return;
}
let handle: IPty;
try {
handle = attachPty({
sessionName,
projectRoot: session.project_path,
cols,
rows,
tmuxConfPath,
});
} catch (err) {
req.log.error({ err }, 'attachPty failed');
socket.close(1011, 'pty_spawn_failed');
return;
}
// Frame contract (boolab pattern):
// server → client text: JSON control — `init` on connect, `exit` on PTY death
// server → client binary: raw PTY bytes (first frame after init = capture-pane replay)
// client → server binary: user keystrokes
// client → server text: JSON control — `{type:"resize", cols, rows}`
//
// The init frame lets the client term.clear() before paint so a remount
// doesn't show stale buffer content. The capture-pane replay then
// paints the current tmux pane state into the fresh xterm.
try {
socket.send(JSON.stringify({ type: 'init', cols, rows, tmux_session: sessionName }));
} catch (err) {
req.log.warn({ err }, 'init frame send failed');
}
try {
const capture = await capturePane(tmuxConfPath, sessionName);
if (capture.length > 0) {
socket.send(Buffer.from(capture, 'utf8'), { binary: true });
}
} catch (err) {
req.log.warn({ err }, 'capture-pane failed');
}
const onData = (data: string): void => {
if (socket.readyState !== socket.OPEN) return;
try {
socket.send(Buffer.from(data, 'utf8'), { binary: true });
} catch (err) {
req.log.warn({ err }, 'ws send failed');
}
};
handle.onData(onData);
socket.on('message', (rawData: Buffer | string, isBinary?: boolean) => {
// ws v8 emits Buffer + isBinary boolean; older versions emit string
// for text frames. Either way: text path tries JSON parse for the
// resize control; binary path writes to the PTY.
const isTextFrame = typeof rawData === 'string' || isBinary === false;
if (isTextFrame) {
const text = typeof rawData === 'string' ? rawData : rawData.toString('utf8');
try {
const parsed = JSON.parse(text) as { type?: string; cols?: number; rows?: number };
if (parsed.type === 'resize') {
const newCols = Math.max(1, Math.min(2000, Math.floor(Number(parsed.cols) || 80)));
const newRows = Math.max(1, Math.min(2000, Math.floor(Number(parsed.rows) || 24)));
req.log.info({ pid, cols: newCols, rows: newRows }, 'resize');
try {
handle.resize(newCols, newRows);
} catch {
/* ignore — invalid winsize bubble */
}
}
} catch {
/* malformed text frame — drop silently */
}
return;
}
try {
handle.write((rawData as Buffer).toString('utf8'));
} catch (err) {
req.log.warn({ err }, 'pty write failed');
}
});
handle.onExit(({ exitCode }) => {
try {
if (socket.readyState === socket.OPEN) {
socket.send(JSON.stringify({ type: 'exit', code: exitCode }));
}
} catch {
/* ignore */
}
try {
socket.close(1000);
} catch {
/* ignore */
}
});
// WS close kills the tmux client (the local PTY) but the tmux server +
// session persist — so a refresh resumes with full scrollback. Permanent
// teardown happens via the /kill route called from the frontend when the
// user closes the pane.
socket.on('close', () => {
try {
handle.kill();
} catch {
/* ignore */
}
});
},
);
}

30
apps/booterm/tmux.conf Normal file
View File

@@ -0,0 +1,30 @@
set -g default-terminal "screen-256color"
set -g history-limit 50000
# v1.10.8c: per-pane tmux sessions (boolab pattern). With one session per
# pane, the session size adapts to the attached client; `window-size = largest`
# + `aggressive-resize on` make tmux pick up the client's actual cols/rows
# instead of falling back to 80x24. Critical for opencode/claude TUIs that
# read TIOCGWINSZ once at fork time.
set -g window-size largest
set -g aggressive-resize on
# v1.10.3: `set -g mouse on` removed. tmux's mouse mode captured wheel/touch
# events at the protocol level, so xterm.js never saw them and the viewport
# couldn't scroll on mobile. With mouse off, xterm.js handles scrollback
# natively (wheel on desktop, finger-drag on mobile via touch-action: pan-y).
# Tradeoff: lose tmux mouse pane-resize and scroll-inside-vim; acceptable for
# the homelab single-user setup.
set -g mouse off
setw -g mode-keys vi
set -g status off
set -g destroy-unattached off
# v1.10.1: shells drop privs to samkintop (uid 1000) so the terminal runs in
# the user's environment, not root. `env HOME=… USER=…` is required because
# gosu only changes uid/gid — env (including HOME) survives, and the tmux
# server runs as root so HOME would otherwise be /root. bash -l then sources
# samkintop's ~/.profile / ~/.bashrc to pick up PATH (nvm, ~/.local/bin,
# ~/.opencode/bin).
# v1.10.2: su-exec → gosu (alpine → debian; functionally identical).
set -g default-command "gosu samkintop:samkintop env HOME=/home/samkintop USER=samkintop SHELL=/bin/bash bash -l"

View File

@@ -0,0 +1,15 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2022"],
"types": ["node"],
"declaration": false,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["**/*.test.ts"]
}

View File

@@ -14,8 +14,13 @@ import { registerChatRoutes } from './routes/chats.js';
import { registerSidebarRoutes } from './routes/sidebar.js';
import { registerWebSocket } from './routes/ws.js';
import { registerModelRoutes } from './routes/models.js';
import { registerAgentRoutes } from './routes/agents.js';
import { registerSkillsRoutes } from './routes/skills.js';
import { createInferenceRunner } from './services/inference.js';
import { createBroker } from './services/broker.js';
import { listSkills } from './services/skills.js';
import * as compaction from './services/compaction.js';
import { configureModelContext } from './services/model-context.js';
async function main() {
const config = loadConfig();
@@ -44,6 +49,11 @@ async function main() {
await applySchema(sql);
app.log.info('database schema applied');
// v1.11.3: tell the model-context cache where llama-swap lives. Cache
// lookups go to ${LLAMA_SWAP_URL}/upstream/<model>/props to read
// default_generation_settings.n_ctx — the value persisted as messages.ctx_max.
configureModelContext({ llamaSwapUrl: config.LLAMA_SWAP_URL });
await app.register(fastifyWebsocket);
app.get('/api/health', async () => {
@@ -57,9 +67,19 @@ async function main() {
registerSessionRoutes(app, sql, config, broker);
registerSettingsRoutes(app, sql);
registerModelRoutes(app, config);
registerAgentRoutes(app, sql);
registerSidebarRoutes(app, sql);
registerChatRoutes(app, sql, broker);
// Batch 9.6: warm the skills cache at boot and surface the count. Empty or
// missing /data/skills is non-fatal — the skill tools just return empty.
try {
const skills = await listSkills();
app.log.info(`skills loaded: ${skills.length}`);
} catch (err) {
app.log.warn({ err }, 'skills boot walk failed');
}
const inference = createInferenceRunner(
{
sql,
@@ -68,6 +88,11 @@ async function main() {
publish: (sessionId, frame) => {
broker.publish(sessionId, frame as unknown as Record<string, unknown> & { type: string });
},
// v1.11: broker handle for compaction.process to publish 'compacted'
// frames on the per-session channel. Inference's regular publish path
// is bound to (sessionId, InferenceFrame); compaction publishes a
// different frame shape, so it goes through the raw broker.
broker,
},
(user, frame) => {
broker.publishUser(user, frame as unknown as Record<string, unknown> & { type: string });
@@ -77,9 +102,13 @@ async function main() {
enqueueInference: (sessionId, chatId, assistantId, user) => {
inference.enqueue(sessionId, chatId, assistantId, user);
},
enqueueCompact: (sessionId, chatId, compactId, user) => {
inference.enqueueCompact(sessionId, chatId, compactId, user);
},
// v1.11: synchronous compaction. Awaits the LLM call inside the route's
// request lifecycle; the new summary row arrives via the WS 'compacted'
// frame published from inside compaction.process. We let the error
// bubble up so the route can reply 500 — manual /compact failures
// should be loud (the user just clicked a button).
runCompaction: (chatId) =>
compaction.process({ sql, config, log: app.log, broker, chatId }),
cancelInference: async (sessionId, chatId) => {
return inference.cancel(sessionId, chatId);
},
@@ -110,6 +139,36 @@ async function main() {
chat_id: chatId,
});
},
publishSessionFrame: (sessionId, frame) => {
broker.publish(sessionId, frame);
},
});
registerSkillsRoutes(app, sql, {
enqueueInference: (sessionId, chatId, assistantId, user) => {
inference.enqueue(sessionId, chatId, assistantId, user);
},
publishUserMessage: (sessionId, chatId, userMessageId, content) => {
broker.publish(sessionId, {
type: 'message_started',
message_id: userMessageId,
chat_id: chatId,
role: 'user',
});
broker.publish(sessionId, {
type: 'delta',
message_id: userMessageId,
chat_id: chatId,
content,
});
broker.publish(sessionId, {
type: 'message_complete',
message_id: userMessageId,
chat_id: chatId,
});
},
publishSessionFrame: (sessionId, frame) => {
broker.publish(sessionId, frame);
},
});
registerWebSocket(app, sql, broker);

View File

@@ -0,0 +1,20 @@
import type { FastifyInstance } from 'fastify';
import type { Sql } from '../db.js';
import { getAgentsForProject } from '../services/agents.js';
export function registerAgentRoutes(app: FastifyInstance, sql: Sql): void {
app.get<{ Params: { id: string } }>(
'/api/projects/:id/agents',
async (req, reply) => {
const rows = await sql<{ path: string }[]>`
SELECT path FROM projects WHERE id = ${req.params.id}
`;
if (rows.length === 0) {
reply.code(404);
return { error: 'project not found' };
}
// getAgentsForProject handles AGENTS.md presence/parse/cache; never throws.
return await getAgentsForProject(rows[0]!.path);
}
);
}

View File

@@ -123,6 +123,53 @@ export function registerChatRoutes(
}
);
// v1.9: bulk-archive every open chat in a session. Mirrors the single
// /chats/:id/archive shape — N chat_archived frames published, useSidebar
// reducer handles each via the existing case.
app.post<{ Params: { id: string } }>(
'/api/sessions/:id/chats/archive-all',
async (req, reply) => {
const session = await sql`SELECT id FROM sessions WHERE id = ${req.params.id}`;
if (session.length === 0) {
reply.code(404);
return { error: 'session not found' };
}
const rows = await sql<{ id: string }[]>`
UPDATE chats
SET status = 'archived', updated_at = clock_timestamp()
WHERE session_id = ${req.params.id} AND status = 'open'
RETURNING id
`;
const ids = rows.map((r) => r.id);
for (const id of ids) {
broker.publishUser('default', {
type: 'chat_archived',
chat_id: id,
session_id: req.params.id,
});
}
return { archived: ids.length, ids };
}
);
// v1.9: count helper for the confirm dialog.
app.get<{ Params: { id: string } }>(
'/api/sessions/:id/chats/open-count',
async (req, reply) => {
const session = await sql`SELECT id FROM sessions WHERE id = ${req.params.id}`;
if (session.length === 0) {
reply.code(404);
return { error: 'session not found' };
}
const rows = await sql<{ count: number }[]>`
SELECT COUNT(*)::int AS count
FROM chats
WHERE session_id = ${req.params.id} AND status = 'open'
`;
return { count: rows[0]?.count ?? 0 };
}
);
app.post<{ Params: { id: string } }>(
'/api/chats/:id/archive',
async (req, reply) => {
@@ -231,7 +278,7 @@ export function registerChatRoutes(
INSERT INTO messages (
session_id, chat_id, role, content, kind, tool_calls, tool_results,
status, tokens_used, ctx_used, ctx_max, started_at, finished_at,
created_at
created_at, metadata
)
SELECT
${source.session_id}, ${chat!.id}, role, content, kind,
@@ -239,7 +286,8 @@ export function registerChatRoutes(
tokens_used, ctx_used, ctx_max, started_at, finished_at,
clock_timestamp() + (
ROW_NUMBER() OVER (ORDER BY created_at ASC, id ASC) * INTERVAL '1 microsecond'
)
),
metadata
FROM messages
WHERE chat_id = ${source.id}
AND created_at <= ${target.created_at}::timestamptz
@@ -268,7 +316,8 @@ export function registerChatRoutes(
}
const rows = await sql<Message[]>`
SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq,
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata,
summary, tail_start_id, compacted_at
FROM messages
WHERE chat_id = ${req.params.id}
ORDER BY created_at ASC, id ASC

View File

@@ -1,15 +1,60 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { Sql } from '../db.js';
import type { Chat, Message, Session } from '../types/api.js';
import type { Chat, Message, Session, ToolCall } from '../types/api.js';
const SendBody = z.object({
content: z.string().min(1).max(64_000),
});
// v1.8.2: Continue extends an inference loop that hit the tool budget. Caller
// passes the sentinel message it's continuing from; server validates shape
// and the per-chat hard ceiling before resuming.
const ContinueBody = z.object({
sentinel_message_id: z.string().uuid(),
});
// Batch 9.7: ask_user_input answer submission. Defensive shape — the question
// content is echoed back for traceability but the server does NOT trust it
// (the source of truth is the assistant message's tool_calls.args.questions).
const AnswerUserInputBody = z.object({
tool_call_id: z.string().min(1),
answers: z
.array(
z.object({
question: z.string(),
selected_options: z.array(z.string()),
free_text: z.string().nullable(),
}),
)
.min(1)
.max(3),
});
// Same shape the model declared via the tool's zod input. Re-derived here so
// the route can validate args without depending on services/tools.ts (which
// would pull in fs/path_guard for nothing).
const AskUserInputArgs = z.object({
questions: z
.array(
z.object({
question: z.string(),
type: z.enum(['single_select', 'multi_select']),
options: z.array(z.string()).min(1),
}),
)
.min(1)
.max(3),
});
interface MessageHandlers {
enqueueInference: (sessionId: string, chatId: string, assistantMessageId: string, user: string) => void;
enqueueCompact: (sessionId: string, chatId: string, compactMessageId: string, user: string) => void;
// v1.11: returns a promise that resolves after compaction.process finishes
// (await the LLM call). Throws on failure — the route surfaces a 500.
// Replaces the v1.10 enqueueCompact (which fired-and-forgot a kind='compact'
// streaming row). The new anchored-rolling strategy inserts a single
// summary=true assistant row only after the LLM responds.
runCompaction: (chatId: string) => Promise<void>;
publishUserMessage: (
sessionId: string,
chatId: string,
@@ -17,6 +62,13 @@ interface MessageHandlers {
content: string
) => void;
publishMessagesDeleted: (sessionId: string, chatId: string, messageIds: string[]) => void;
// Batch 9.7: lets the answer endpoint emit the tool_result frame that the
// pause path intentionally skipped. Matches SkillInvokeHandlers in
// routes/skills.ts so index.ts can pass the same broker.publish adapter.
publishSessionFrame: (
sessionId: string,
frame: Record<string, unknown> & { type: string }
) => void;
cancelInference: (sessionId: string, chatId: string) => Promise<boolean>;
hasActiveInference: (chatId: string) => boolean;
}
@@ -34,9 +86,15 @@ export function registerMessageRoutes(
reply.code(404);
return { error: 'session not found' };
}
// v1.11: returns ALL messages including compacted ones. The UI
// distinguishes via the new `summary` flag (renders an accordion
// SummaryCard) and shows compacted_at-stamped rows inline for context.
// Internal inference assembly filters compacted_at IS NULL separately —
// see services/inference.ts loadContext + services/compaction.ts.
const rows = await sql<Message[]>`
SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq,
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata,
summary, tail_start_id, compacted_at
FROM messages
WHERE session_id = ${req.params.id}
ORDER BY created_at ASC, id ASC
@@ -204,29 +262,30 @@ export function registerMessageRoutes(
}
);
// v1.11: manual /compact. Was a streaming kind='compact' row inserted by
// this handler; now delegates to the anchored-rolling compaction service.
// Synchronous (we await the LLM call) — callers either await or rely on
// the 'compacted' WS frame to refresh their view. The response carries
// no body of interest; the new summary row arrives via the WS frame.
app.post<{ Params: { id: string } }>(
'/api/chats/:id/compact',
async (req, reply) => {
const chatRows = await sql<Chat[]>`
SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open'
const chatRows = await sql<{ id: string }[]>`
SELECT id FROM chats WHERE id = ${req.params.id} AND status = 'open'
`;
if (chatRows.length === 0) {
reply.code(404);
return { error: 'chat not found' };
}
const chat = chatRows[0]!;
const sessionId = chat.session_id;
const [compactMsg] = await sql<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, kind, status, created_at)
VALUES (${sessionId}, ${chat.id}, 'system', '', 'compact', 'streaming', clock_timestamp())
RETURNING id
`;
handlers.enqueueCompact(sessionId, chat.id, compactMsg!.id, 'default');
reply.code(202);
return { compact_message_id: compactMsg!.id };
try {
await handlers.runCompaction(chatRows[0]!.id);
} catch (err) {
req.log.error({ err, chatId: chatRows[0]!.id }, 'manual compaction failed');
reply.code(500);
return { error: err instanceof Error ? err.message : 'compaction failed' };
}
reply.code(200);
return { ok: true };
}
);
@@ -253,6 +312,76 @@ export function registerMessageRoutes(
}
);
app.post<{ Params: { id: string } }>(
'/api/chats/:id/continue',
async (req, reply) => {
const parsed = ContinueBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const chatRows = await sql<Chat[]>`
SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open'
`;
if (chatRows.length === 0) {
reply.code(404);
return { error: 'chat not found' };
}
const chat = chatRows[0]!;
const sessionId = chat.session_id;
// Cap-hit sentinels are only ever inserted after a turn completes, so
// there must not be an active inference at this moment. If there is,
// the client is racing the cap-hit summary that just emitted the
// sentinel — bail rather than enqueue a parallel run.
if (handlers.hasActiveInference(chat.id)) {
reply.code(409);
return { error: 'chat is currently streaming' };
}
const sentinel = await sql<{ metadata: { kind?: unknown; can_continue?: unknown } | null }[]>`
SELECT metadata
FROM messages
WHERE id = ${parsed.data.sentinel_message_id}
AND chat_id = ${chat.id}
AND role = 'system'
`;
if (sentinel.length === 0) {
reply.code(404);
return { error: 'sentinel not found' };
}
const meta = sentinel[0]!.metadata;
if (!meta || meta.kind !== 'cap_hit') {
reply.code(400);
return { error: 'message is not a cap-hit sentinel' };
}
// Server-side hard ceiling check. UI already disables the button when
// can_continue is false; defending against a stale tab or a direct
// API hit is the only reason this lives on the server too.
if (meta.can_continue !== true) {
reply.code(409);
return { error: 'hard limit reached for this chat' };
}
const result = await sql.begin(async (tx) => {
const [assistantMsg] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'streaming', clock_timestamp())
RETURNING id
`;
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chat.id}`;
return { assistant_message_id: assistantMsg!.id };
});
handlers.enqueueInference(sessionId, chat.id, result.assistant_message_id, 'default');
reply.code(202);
return result;
}
);
app.post<{ Params: { id: string } }>(
'/api/chats/:id/force_send',
async (req, reply) => {
@@ -312,4 +441,169 @@ export function registerMessageRoutes(
return result;
}
);
// Batch 9.7: resume an ask_user_input pause. Validates the body matches the
// question shape the model declared, UPDATEs the pending tool row's
// tool_results to the AnswerSet, publishes the deferred tool_result frame,
// and enqueues the next assistant turn. Error codes per spec:
// 400 invalid_body / mismatched_answer_shape
// 404 chat_not_found / unknown_tool_call_id
// 409 tool_call_already_answered
app.post<{ Params: { id: string } }>(
'/api/chats/:id/answer_user_input',
async (req, reply) => {
const parsed = AnswerUserInputBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid_body', details: parsed.error.flatten() };
}
const { tool_call_id, answers } = parsed.data;
const chatRows = await sql<Chat[]>`
SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open'
`;
if (chatRows.length === 0) {
reply.code(404);
return { error: 'chat_not_found' };
}
const chat = chatRows[0]!;
const sessionId = chat.session_id;
// Find the assistant message that emitted this tool_call. Scoped by
// chat_id + role to avoid cross-chat lookups; ordered by created_at DESC
// because the most recent issuance wins when an LLM reuses call IDs
// across turns (the older, already-answered one is a different row with
// populated tool_results downstream).
const callerRows = await sql<{ id: string; tool_calls: ToolCall[] | null }[]>`
SELECT id, tool_calls FROM messages
WHERE chat_id = ${chat.id}
AND role = 'assistant'
AND tool_calls IS NOT NULL
ORDER BY created_at DESC
`;
let foundCall: ToolCall | null = null;
for (const row of callerRows) {
const match = row.tool_calls?.find((tc) => tc.id === tool_call_id);
if (match) {
foundCall = match;
break;
}
}
if (!foundCall) {
reply.code(404);
return { error: 'unknown_tool_call_id' };
}
if (foundCall.name !== 'ask_user_input') {
reply.code(400);
return { error: 'tool_call_not_ask_user_input' };
}
// Validate the args themselves — the LLM could have emitted bad JSON.
const argsParsed = AskUserInputArgs.safeParse(foundCall.args);
if (!argsParsed.success) {
reply.code(400);
return { error: 'mismatched_answer_shape', detail: 'tool_call args invalid' };
}
const questions = argsParsed.data.questions;
if (answers.length !== questions.length) {
reply.code(400);
return {
error: 'mismatched_answer_shape',
detail: `expected ${questions.length} answer(s), got ${answers.length}`,
};
}
for (let i = 0; i < questions.length; i++) {
const q = questions[i]!;
const a = answers[i]!;
for (const sel of a.selected_options) {
if (!q.options.includes(sel)) {
reply.code(400);
return {
error: 'mismatched_answer_shape',
detail: `answer ${i + 1} contains option not in question: ${sel}`,
};
}
}
if (q.type === 'single_select' && a.selected_options.length > 1) {
reply.code(400);
return {
error: 'mismatched_answer_shape',
detail: `answer ${i + 1} has multiple selections on single_select`,
};
}
const hasOpt = a.selected_options.length > 0;
const hasText = a.free_text !== null && a.free_text.trim().length > 0;
if (!hasOpt && !hasText) {
reply.code(400);
return { error: 'mismatched_answer_shape', detail: `answer ${i + 1} is empty` };
}
}
// Find the pending tool row. ORDER BY created_at DESC + LIMIT 1 picks
// the most recent row with this tool_call_id; the already-answered
// check below guards against UPDATE-ing a stale answer.
const toolRows = await sql<{
id: string;
tool_results: { tool_call_id: string; output: unknown } | null;
}[]>`
SELECT id, tool_results FROM messages
WHERE chat_id = ${chat.id}
AND role = 'tool'
AND tool_results->>'tool_call_id' = ${tool_call_id}
ORDER BY created_at DESC
LIMIT 1
`;
const toolRow = toolRows[0];
if (!toolRow) {
reply.code(404);
return { error: 'unknown_tool_call_id', detail: 'tool message not found' };
}
if (toolRow.tool_results && toolRow.tool_results.output !== null) {
reply.code(409);
return { error: 'tool_call_already_answered' };
}
const answerSet = { answers };
const newToolResults = {
tool_call_id,
output: answerSet,
truncated: false,
};
const result = await sql.begin(async (tx) => {
await tx`
UPDATE messages
SET tool_results = ${tx.json(newToolResults as never)}
WHERE id = ${toolRow.id}
`;
const [assistantMsg] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'streaming', clock_timestamp())
RETURNING id
`;
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chat.id}`;
return {
tool_message_id: toolRow.id,
assistant_message_id: assistantMsg!.id,
};
});
// Publish the deferred tool_result frame. useSessionStream's reducer
// updates the matching tool_run.result so AskUserInputCard flips into
// its read-only "answered" mode without a refetch.
handlers.publishSessionFrame(sessionId, {
type: 'tool_result',
tool_message_id: result.tool_message_id,
tool_call_id,
chat_id: chat.id,
output: answerSet,
truncated: false,
});
handlers.enqueueInference(sessionId, chat.id, result.assistant_message_id, 'default');
reply.code(202);
return result;
},
);
}

View File

@@ -9,6 +9,7 @@ import type { Project, AvailableProject } from '../types/api.js';
import { resolveProjectRoot, PathScopeError } from '../services/path_guard.js';
import { listDir, viewFile } from '../services/file_ops.js';
import { getProjectFiles } from '../services/file_index.js';
import { getGitMeta } from '../services/git_meta.js';
import {
bootstrapProject,
BootstrapNameError,
@@ -21,8 +22,14 @@ const AddProjectBody = z.object({
name: z.string().min(1).optional(),
});
// v1.9: PATCH accepts the new per-project defaults. All fields optional so
// the existing rename-only callers keep working. Empty string on
// default_system_prompt is the "no override" sentinel — same convention as
// sessions.system_prompt.
const PatchProjectBody = z.object({
name: z.string().min(1).max(200),
name: z.string().min(1).max(200).optional(),
default_system_prompt: z.string().max(8000).optional(),
default_web_search_enabled: z.boolean().optional(),
});
const CreateProjectBody = z.object({
@@ -69,7 +76,8 @@ export function registerProjectRoutes(
app.get<{ Querystring: { status?: string } }>('/api/projects', async (req) => {
const status = req.query.status === 'archived' ? 'archived' : 'open';
const rows = await sql<Project[]>`
SELECT id, name, path, added_at, last_session_id, status, gitea_remote
SELECT id, name, path, added_at, last_session_id, status, gitea_remote,
default_system_prompt, default_web_search_enabled
FROM projects
WHERE status = ${status}
ORDER BY added_at DESC
@@ -118,7 +126,8 @@ export function registerProjectRoutes(
const [row] = await sql<Project[]>`
INSERT INTO projects (name, path, gitea_remote)
VALUES (${parsed.data.name}, ${bootstrap.folder_real_path}, ${bootstrap.gitea_remote_url})
RETURNING id, name, path, added_at, last_session_id, status, gitea_remote
RETURNING id, name, path, added_at, last_session_id, status, gitea_remote,
default_system_prompt, default_web_search_enabled
`;
broker.publishUser('default', { type: 'project_created', project: row as unknown as Project });
reply.code(201);
@@ -172,7 +181,8 @@ export function registerProjectRoutes(
INSERT INTO projects (name, path)
VALUES (${name}, ${resolved.real})
ON CONFLICT (path) DO UPDATE SET status = 'open'
RETURNING id, name, path, added_at, last_session_id, status, gitea_remote
RETURNING id, name, path, added_at, last_session_id, status, gitea_remote,
default_system_prompt, default_web_search_enabled
`;
if (existing.length === 0) {
@@ -186,22 +196,53 @@ export function registerProjectRoutes(
return row;
});
// v1.9: single-project fetch so the settings pane can refetch on
// project_updated without pulling the whole project list.
app.get<{ Params: { id: string } }>('/api/projects/:id', async (req, reply) => {
const rows = await sql<Project[]>`
SELECT id, name, path, added_at, last_session_id, status, gitea_remote,
default_system_prompt, default_web_search_enabled
FROM projects WHERE id = ${req.params.id}
`;
if (rows.length === 0) {
reply.code(404);
return { error: 'not found' };
}
return rows[0];
});
app.patch<{ Params: { id: string } }>('/api/projects/:id', async (req, reply) => {
const parsed = PatchProjectBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const { name, default_system_prompt, default_web_search_enabled } = parsed.data;
// v1.9: every field optional. COALESCE on the bind keeps the prior value
// when the caller omits it. Boolean has its own branch since COALESCE
// can't disambiguate "omitted" from "explicitly false" via a single
// nullable parameter.
const dwsProvided = default_web_search_enabled !== undefined;
const rows = await sql<Project[]>`
UPDATE projects SET name = ${parsed.data.name}
UPDATE projects
SET
name = COALESCE(${name ?? null}, name),
default_system_prompt = COALESCE(${default_system_prompt ?? null}, default_system_prompt),
default_web_search_enabled = CASE WHEN ${dwsProvided}
THEN ${default_web_search_enabled ?? false}
ELSE default_web_search_enabled END
WHERE id = ${req.params.id}
RETURNING id, name, path, added_at, last_session_id, status, gitea_remote
RETURNING id, name, path, added_at, last_session_id, status, gitea_remote,
default_system_prompt, default_web_search_enabled
`;
if (rows.length === 0) {
reply.code(404);
return { error: 'not found' };
}
const project = rows[0]!;
// v1.9: the project_updated frame still only carries id + name. Clients
// that need the new fields refetch via api.projects.list() — keeps the
// frame payload lean, per the locked recon decision (d).
broker.publishUser('default', {
type: 'project_updated',
project_id: project.id,
@@ -228,7 +269,8 @@ export function registerProjectRoutes(
const rows = await sql<Project[]>`
UPDATE projects SET status = 'open'
WHERE id = ${req.params.id} AND status = 'archived'
RETURNING id, name, path, added_at, last_session_id, status, gitea_remote
RETURNING id, name, path, added_at, last_session_id, status, gitea_remote,
default_system_prompt, default_web_search_enabled
`;
if (rows.length === 0) {
reply.code(404);
@@ -381,6 +423,38 @@ export function registerProjectRoutes(
}
);
// GET /api/projects/:id/git
// v1.8 mobile-tabs: feeds the header branch indicator and is the same
// resolver the model's git_status tool uses. Returns 200 with branch=null
// for non-git directories (not 404) so the UI can degrade gracefully.
app.get<{ Params: { id: string } }>(
'/api/projects/:id/git',
async (req, reply) => {
const { id } = req.params;
const rows = await sql<Project[]>`
SELECT id, name, path, added_at, last_session_id, status, gitea_remote
FROM projects WHERE id = ${id}
`;
if (rows.length === 0) {
reply.code(404);
return { error: 'not found' };
}
const project = rows[0]!;
let projectRoot: string;
try {
projectRoot = await resolveProjectRoot(project.path);
} catch (err) {
if (err instanceof PathScopeError) {
reply.code(404);
return { error: err.message };
}
throw err;
}
const meta = await getGitMeta(projectRoot);
return meta ?? { branch: null, is_dirty: false, ahead: 0, behind: 0 };
}
);
// GET /api/projects/:id/files
app.get<{ Params: { id: string } }>(
'/api/projects/:id/files',

View File

@@ -5,17 +5,22 @@ import type { Config } from '../config.js';
import type { Broker } from '../services/broker.js';
import type { Session } from '../types/api.js';
import { getSetting } from './settings.js';
import { getAgentsForProject } from '../services/agents.js';
const CreateBody = z.object({
name: z.string().min(1).max(200).optional(),
model: z.string().min(1).max(200).optional(),
system_prompt: z.string().max(8000).optional(),
agent_id: z.string().min(1).max(200).nullable().optional(),
});
const PatchBody = z.object({
name: z.string().min(1).max(200).optional(),
model: z.string().min(1).max(200).optional(),
system_prompt: z.string().max(8000).optional(),
agent_id: z.string().min(1).max(200).nullable().optional(),
// v1.9: null = inherit from project default; true/false = explicit override.
web_search_enabled: z.boolean().nullable().optional(),
});
async function resolveDefaultModel(sql: Sql, config: Config): Promise<string> {
@@ -24,6 +29,13 @@ async function resolveDefaultModel(sql: Sql, config: Config): Promise<string> {
return config.DEFAULT_MODEL;
}
// First agent in the project's effective list (file-defined or builtin),
// or null if somehow none exist.
async function resolveDefaultAgent(projectPath: string): Promise<string | null> {
const { agents } = await getAgentsForProject(projectPath);
return agents[0]?.id ?? null;
}
export function registerSessionRoutes(
app: FastifyInstance,
sql: Sql,
@@ -40,7 +52,7 @@ export function registerSessionRoutes(
}
const status = req.query.status === 'archived' ? 'archived' : 'open';
const rows = await sql<Session[]>`
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled
FROM sessions
WHERE project_id = ${req.params.id} AND status = ${status}
ORDER BY updated_at DESC
@@ -57,11 +69,14 @@ export function registerSessionRoutes(
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const project = await sql`SELECT id FROM projects WHERE id = ${req.params.id}`;
const project = await sql<{ id: string; path: string }[]>`
SELECT id, path FROM projects WHERE id = ${req.params.id}
`;
if (project.length === 0) {
reply.code(404);
return { error: 'project not found' };
}
const projectPath = project[0]!.path;
let model = parsed.data.model;
if (!model) {
@@ -76,12 +91,18 @@ export function registerSessionRoutes(
const name = parsed.data.name ?? 'New session';
const systemPrompt = parsed.data.system_prompt ?? '';
// If the client provided agent_id (string or null), use it; otherwise
// resolve to the project's first agent (file-defined or builtin), or null.
const agentId =
parsed.data.agent_id !== undefined
? parsed.data.agent_id
: await resolveDefaultAgent(projectPath);
const row = await sql.begin(async (tx) => {
const [session] = await tx<Session[]>`
INSERT INTO sessions (project_id, name, model, system_prompt)
VALUES (${req.params.id}, ${name}, ${model}, ${systemPrompt})
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at
INSERT INTO sessions (project_id, name, model, system_prompt, agent_id)
VALUES (${req.params.id}, ${name}, ${model}, ${systemPrompt}, ${agentId})
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled
`;
await tx`
INSERT INTO chats (session_id, name, status)
@@ -101,7 +122,7 @@ export function registerSessionRoutes(
app.get<{ Params: { id: string } }>('/api/sessions/:id', async (req, reply) => {
const rows = await sql<Session[]>`
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled
FROM sessions WHERE id = ${req.params.id}
`;
if (rows.length === 0) {
@@ -120,6 +141,13 @@ export function registerSessionRoutes(
return { error: 'invalid body', details: parsed.error.flatten() };
}
const { name, model, system_prompt } = parsed.data;
// agent_id and web_search_enabled are both tri-state on the wire: omitted
// = no change, null = clear/inherit, value = set. CASE WHEN inside SET
// handles all three atomically.
const agentIdProvided = parsed.data.agent_id !== undefined;
const newAgentId = parsed.data.agent_id ?? null;
const wseProvided = parsed.data.web_search_enabled !== undefined;
const newWse = parsed.data.web_search_enabled ?? null;
// Read the prior name so the post-update publish can skip no-op renames
// (PATCH { name: "Foo" } where the session is already "Foo"). The window
// between SELECT and UPDATE is sub-millisecond in the same request handler;
@@ -135,9 +163,12 @@ export function registerSessionRoutes(
name = COALESCE(${name ?? null}, name),
model = COALESCE(${model ?? null}, model),
system_prompt = COALESCE(${system_prompt ?? null}, system_prompt),
agent_id = CASE WHEN ${agentIdProvided} THEN ${newAgentId} ELSE agent_id END,
web_search_enabled = CASE WHEN ${wseProvided} THEN ${newWse} ELSE web_search_enabled END,
updated_at = clock_timestamp()
WHERE id = ${req.params.id}
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at,
agent_id, web_search_enabled
`;
if (rows.length === 0) {
reply.code(404);
@@ -151,10 +182,69 @@ export function registerSessionRoutes(
name: session.name,
});
}
// v1.9: any successful PATCH broadcasts session_updated so listeners
// (notably the SettingsPane open in another tab) can refetch and pick
// up the new fields. Frame stays lean (decision d) — payload is just
// ids + name + updated_at, the client refetches via api.sessions.get.
broker.publishUser('default', {
type: 'session_updated',
session_id: session.id,
project_id: session.project_id,
name: session.name,
updated_at: session.updated_at,
});
return session;
}
);
// v1.9: bulk-archive every open session in a project. Mirrors the
// single-archive shape (same broker frame type) so the existing useSidebar
// reducer cases handle it without changes — just N frames instead of 1.
app.post<{ Params: { id: string } }>(
'/api/projects/:id/sessions/archive-all',
async (req, reply) => {
const project = await sql`SELECT id FROM projects WHERE id = ${req.params.id}`;
if (project.length === 0) {
reply.code(404);
return { error: 'project not found' };
}
const rows = await sql<{ id: string }[]>`
UPDATE sessions
SET status = 'archived', updated_at = clock_timestamp()
WHERE project_id = ${req.params.id} AND status = 'open'
RETURNING id
`;
const ids = rows.map((r) => r.id);
for (const id of ids) {
broker.publishUser('default', {
type: 'session_archived',
session_id: id,
project_id: req.params.id,
});
}
return { archived: ids.length, ids };
}
);
// v1.9: count helper for the confirm dialog. Cheap COUNT(*) — the settings
// pane calls it on click, not on render.
app.get<{ Params: { id: string } }>(
'/api/projects/:id/sessions/open-count',
async (req, reply) => {
const project = await sql`SELECT id FROM projects WHERE id = ${req.params.id}`;
if (project.length === 0) {
reply.code(404);
return { error: 'project not found' };
}
const rows = await sql<{ count: number }[]>`
SELECT COUNT(*)::int AS count
FROM sessions
WHERE project_id = ${req.params.id} AND status = 'open'
`;
return { count: rows[0]?.count ?? 0 };
}
);
app.post<{ Params: { id: string } }>(
'/api/sessions/:id/archive',
async (req, reply) => {
@@ -183,7 +273,7 @@ export function registerSessionRoutes(
const rows = await sql<Session[]>`
UPDATE sessions SET status = 'open', updated_at = clock_timestamp()
WHERE id = ${req.params.id} AND status = 'archived'
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled
`;
if (rows.length === 0) {
reply.code(404);

View File

@@ -22,6 +22,50 @@ export async function setSetting(
`;
}
// themes-v1: whitelist of the 18 preset theme ids. Kept in sync with
// docs/themes_v1.md §1 and apps/web/src/lib/theme.ts THEMES.
const THEME_IDS = [
'obsidian',
'gunmetal',
'espresso',
'volcanic-brown',
'copper',
'gold',
'oxblood',
'crimson',
'elderflower',
'plum',
'steel-pink',
'fuchsia-noir',
'matrix',
'sage',
'ivory',
'chalk',
'cobalt',
'midnight-sapphire',
] as const;
const THEME_MODES = ['dark', 'light', 'system'] as const;
// PATCH body is still a free-form key/value bag for everything except the
// two theme keys, which carry strict per-key validation. Anything outside
// THEME_IDS / THEME_MODES on those keys is rejected with 400.
function validateThemeKeys(body: Record<string, unknown>): string | null {
if ('theme_id' in body) {
const v = body.theme_id;
if (typeof v !== 'string' || !(THEME_IDS as readonly string[]).includes(v)) {
return `theme_id must be one of: ${THEME_IDS.join(', ')}`;
}
}
if ('theme_mode' in body) {
const v = body.theme_mode;
if (typeof v !== 'string' || !(THEME_MODES as readonly string[]).includes(v)) {
return `theme_mode must be one of: ${THEME_MODES.join(', ')}`;
}
}
return null;
}
const PatchBody = z.record(z.string(), z.unknown());
export function registerSettingsRoutes(app: FastifyInstance, sql: Sql): void {
@@ -38,6 +82,11 @@ export function registerSettingsRoutes(app: FastifyInstance, sql: Sql): void {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const themeError = validateThemeKeys(parsed.data);
if (themeError) {
reply.code(400);
return { error: themeError };
}
for (const [k, v] of Object.entries(parsed.data)) {
await setSetting(sql, k, v);
}

View File

@@ -0,0 +1,156 @@
import { randomUUID } from 'node:crypto';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { Sql } from '../db.js';
import type { Chat } from '../types/api.js';
import { getSkillBody, listSkills } from '../services/skills.js';
// Batch 9.6 slash-invoke handlers. Mirrors the MessageHandlers shape in
// routes/messages.ts so index.ts can pass thin adapters around broker +
// inference runner without skills.ts importing them directly.
export interface SkillInvokeHandlers {
enqueueInference: (
sessionId: string,
chatId: string,
assistantMessageId: string,
user: string,
) => void;
publishUserMessage: (
sessionId: string,
chatId: string,
userMessageId: string,
content: string,
) => void;
publishSessionFrame: (
sessionId: string,
frame: Record<string, unknown> & { type: string },
) => void;
}
const SkillInvokeBody = z.object({
skill_name: z.string().min(1),
// Optional — server fills in a default if absent or whitespace-only so the
// model always has something to act on (matches the spec's "Apply this
// skill." filler).
user_message: z.string().max(64_000).nullable().optional(),
});
const DEFAULT_USER_MESSAGE = 'Apply this skill.';
export function registerSkillsRoutes(
app: FastifyInstance,
sql: Sql,
handlers: SkillInvokeHandlers,
): void {
// Debug/admin surface — the model interacts with skills via the three
// skill_* tools, not through this endpoint.
app.get('/api/skills', async () => {
return { skills: await listSkills() };
});
// POST /api/chats/:id/skill_invoke — slash-command entry point. Loads the
// skill body server-side (clients never get to forge file content),
// persists 4 messages in one transaction (synthetic assistant tool_use,
// synthetic tool result, real user message, streaming assistant), and
// enqueues inference against the updated history.
app.post<{ Params: { id: string } }>(
'/api/chats/:id/skill_invoke',
async (req, reply) => {
const parsed = SkillInvokeBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const { skill_name } = parsed.data;
const userText = parsed.data.user_message?.trim() ? parsed.data.user_message : DEFAULT_USER_MESSAGE;
const chatRows = await sql<Chat[]>`
SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open'
`;
if (chatRows.length === 0) {
reply.code(404);
return { error: 'chat not found' };
}
const chat = chatRows[0]!;
const sessionId = chat.session_id;
const body = await getSkillBody(skill_name);
if (body === null) {
reply.code(404);
return { error: 'unknown_skill', message: `unknown skill: ${skill_name}` };
}
const toolCallId = randomUUID();
const toolCalls = [{ id: toolCallId, name: 'skill_use', args: { name: skill_name } }];
const toolResults = { tool_call_id: toolCallId, output: body, truncated: false };
const result = await sql.begin(async (tx) => {
const [synthAssistant] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, tool_calls, status, created_at)
VALUES (${sessionId}, ${chat.id}, 'assistant', '', ${sql.json(toolCalls as never)}, 'complete', clock_timestamp())
RETURNING id
`;
const [toolMsg] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, tool_results, status, created_at)
VALUES (${sessionId}, ${chat.id}, 'tool', '', ${sql.json(toolResults as never)}, 'complete', clock_timestamp())
RETURNING id
`;
const [userMsg] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chat.id}, 'user', ${userText}, 'complete', clock_timestamp())
RETURNING id
`;
const [assistantMsg] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'streaming', clock_timestamp())
RETURNING id
`;
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chat.id}`;
return {
synth_assistant_id: synthAssistant!.id,
tool_message_id: toolMsg!.id,
user_message_id: userMsg!.id,
assistant_message_id: assistantMsg!.id,
};
});
// Synthetic frames so useSessionStream's reducer reflects the new
// history without a refetch. Frame shapes match the streaming-inference
// protocol (see services/inference.ts InferenceFrame).
handlers.publishSessionFrame(sessionId, {
type: 'message_started',
message_id: result.synth_assistant_id,
chat_id: chat.id,
role: 'assistant',
});
handlers.publishSessionFrame(sessionId, {
type: 'tool_call',
message_id: result.synth_assistant_id,
chat_id: chat.id,
tool_call: toolCalls[0]!,
});
handlers.publishSessionFrame(sessionId, {
type: 'message_complete',
message_id: result.synth_assistant_id,
chat_id: chat.id,
});
// The tool_result frame's reducer branch creates the tool-role message
// in-place when it doesn't already exist — no separate message_started
// is needed for the tool side.
handlers.publishSessionFrame(sessionId, {
type: 'tool_result',
tool_message_id: result.tool_message_id,
tool_call_id: toolCallId,
chat_id: chat.id,
output: body,
truncated: false,
});
handlers.publishUserMessage(sessionId, chat.id, result.user_message_id, userText);
handlers.enqueueInference(sessionId, chat.id, result.assistant_message_id, 'default');
reply.code(202);
return result;
},
);
}

View File

@@ -21,9 +21,12 @@ export function registerWebSocket(
return;
}
// v1.11: snapshot includes compaction fields so MessageBubble can
// render the SummaryCard for summary=true rows on first connect.
const messages = await sql<Message[]>`
SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq,
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata,
summary, tail_start_id, compacted_at
FROM messages
WHERE session_id = ${sessionId}
ORDER BY created_at ASC, id ASC

View File

@@ -53,7 +53,7 @@ CREATE TABLE IF NOT EXISTS session_panes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
position INTEGER NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('chat', 'file_browser')),
kind TEXT NOT NULL CHECK (kind IN ('chat', 'file_browser', 'terminal')),
state JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
UNIQUE (session_id, position)
@@ -153,3 +153,51 @@ BEGIN
CHECK (status IN ('open', 'archived'));
END IF;
END $$;
-- v1.x-batch9: per-session agent reference. Agent definitions are not stored in
-- the DB; they live in builtins (services/agents.ts) and a per-project AGENTS.md.
-- agent_id is the slugified agent name. NULL means "use BooCode defaults".
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS agent_id TEXT;
-- v1.8.2: per-message metadata for sentinels (cap-hit) and structured error
-- reasons. JSONB so future kinds can extend without further schema churn.
-- Shape for cap_hit: { kind: 'cap_hit', used: number, limit: number,
-- agent_name: string|null, can_continue: boolean }
-- Shape for errors: { error_reason: 'llm_provider_error'|..., error_text: string }
ALTER TABLE messages ADD COLUMN IF NOT EXISTS metadata JSONB;
-- themes-v1: idempotent seeds for the two theme preference keys. The settings
-- table is a key/value store (see line 43) so theme prefs live as two rows,
-- not new columns. Defaults match docs/themes_v1.md: obsidian (dark).
INSERT INTO settings (key, value) VALUES ('theme_id', '"obsidian"') ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('theme_mode', '"dark"') ON CONFLICT (key) DO NOTHING;
-- v1.9: per-project defaults that new sessions inherit, plus a per-session
-- web-search override. Empty string on either prompt column means "inherit"
-- (resolved in inference.ts buildSystemPrompt). web_search_enabled is the
-- only tri-state field: null on session = inherit from project default.
ALTER TABLE projects ADD COLUMN IF NOT EXISTS default_system_prompt TEXT NOT NULL DEFAULT '';
ALTER TABLE projects ADD COLUMN IF NOT EXISTS default_web_search_enabled BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS web_search_enabled BOOLEAN;
-- v1.11: anchored rolling compaction.
-- compacted_at — marks rows that are "behind the curtain" of the latest
-- summary. Inference assembly filters compacted_at IS NULL;
-- the API GET still returns all rows so the UI can show
-- history with the summary card inline.
-- summary — true on the assistant row that IS the anchored summary.
-- Exactly one row per chat is the "current" summary
-- (every prior summary row is itself compacted_at-stamped
-- when superseded, leaving one live anchor).
-- tail_start_id — points at the first preserved message that the summary
-- covers up to (exclusive). Lets the UI/debug reason about
-- the boundary without re-deriving from compacted_at.
-- needs_compaction — flag on chats (not sessions) because chat history is
-- per-chat; sessions have 1:N chats. Set true post-overflow,
-- cleared by compaction.process at the start of the next
-- inference turn.
ALTER TABLE messages ADD COLUMN IF NOT EXISTS compacted_at TIMESTAMPTZ;
ALTER TABLE messages ADD COLUMN IF NOT EXISTS summary BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE messages ADD COLUMN IF NOT EXISTS tail_start_id UUID REFERENCES messages(id) ON DELETE SET NULL;
ALTER TABLE chats ADD COLUMN IF NOT EXISTS needs_compaction BOOLEAN NOT NULL DEFAULT FALSE;
CREATE INDEX IF NOT EXISTS idx_messages_chat_compacted ON messages (chat_id, compacted_at);

View File

@@ -0,0 +1,258 @@
import { describe, it, expect } from 'vitest';
import {
usable,
isOverflow,
estimate,
turns,
select,
buildPrompt,
type CompactionMessage,
} from '../compaction.js';
import { SUMMARY_TEMPLATE } from '../compaction-prompt.js';
// ---- fixture ----------------------------------------------------------------
// Tiny constructor for the message shape `compaction.ts` consumes. Default
// values match the post-CP1 schema (summary=false, kind='message', complete).
// Tests that need a summary row pass `summary: true`.
let counter = 0;
function mkMsg(
role: CompactionMessage['role'],
content: string,
overrides: Partial<CompactionMessage> = {},
): CompactionMessage {
counter += 1;
return {
id: `m${counter}`,
role,
content,
kind: 'message',
summary: false,
status: 'complete',
tool_calls: null,
tool_results: null,
metadata: null,
created_at: new Date(counter * 1000).toISOString(),
...overrides,
};
}
// ---- usable -----------------------------------------------------------------
describe('usable', () => {
it('returns 0 when contextLimit is 0', () => {
expect(usable(0)).toBe(0);
});
it('returns 0 when contextLimit is below the 20k buffer', () => {
// Math.max(0, x - 20000) clamps the subtraction so we never report
// negative headroom. A 10k-context model reports 0 usable, which makes
// isOverflow short-circuit to false (correct — we can't size the
// compaction with no headroom).
expect(usable(10_000)).toBe(0);
expect(usable(19_999)).toBe(0);
expect(usable(20_000)).toBe(0);
});
it('subtracts the 20k buffer from a normal-sized context window', () => {
expect(usable(100_000)).toBe(80_000);
expect(usable(32_768)).toBe(12_768);
});
});
// ---- isOverflow -------------------------------------------------------------
describe('isOverflow', () => {
it('returns false when usable is 0 (unknown / sub-buffer context)', () => {
expect(isOverflow({ prompt_tokens: 999_999, completion_tokens: 0 }, 0)).toBe(false);
expect(isOverflow({ prompt_tokens: 0, completion_tokens: 999_999 }, 10_000)).toBe(false);
});
it('returns false at 50% of usable', () => {
// usable(100k) = 80k → 50% = 40k.
expect(isOverflow({ prompt_tokens: 30_000, completion_tokens: 10_000 }, 100_000)).toBe(false);
});
it('returns false just under usable', () => {
expect(isOverflow({ prompt_tokens: 79_000, completion_tokens: 999 }, 100_000)).toBe(false);
});
it('returns true exactly at usable (>=, not strict >)', () => {
expect(isOverflow({ prompt_tokens: 80_000, completion_tokens: 0 }, 100_000)).toBe(true);
});
it('returns true above usable', () => {
expect(isOverflow({ prompt_tokens: 50_000, completion_tokens: 40_000 }, 100_000)).toBe(true);
});
});
// ---- estimate ---------------------------------------------------------------
describe('estimate', () => {
it('returns a tiny value for an empty array (JSON.stringify([]) is "[]")', () => {
// Math.ceil('[]'.length / 4) = 1. Documented here so the next reader
// doesn't think "0" is the expected baseline — char-count/4 will never
// be exactly 0 for any JSON-serializable input.
expect(estimate([])).toBe(1);
});
it('scales roughly with content length', () => {
const tiny = estimate([mkMsg('user', 'hi')]);
const big = estimate([mkMsg('user', 'x'.repeat(4000))]);
expect(big).toBeGreaterThan(tiny);
expect(big).toBeGreaterThanOrEqual(1000); // 4000 chars / 4 = 1000 floor
});
it('is deterministic across repeated calls', () => {
const msgs = [mkMsg('user', 'one'), mkMsg('assistant', 'two')];
expect(estimate(msgs)).toBe(estimate(msgs));
});
});
// ---- turns ------------------------------------------------------------------
describe('turns', () => {
it('returns [] for an empty message list', () => {
expect(turns([])).toEqual([]);
});
it('returns one turn for a single user message', () => {
const u = mkMsg('user', 'hi');
const result = turns([u]);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({ start: 0, end: 1, id: u.id });
});
it('returns two turns for user/assistant/user/assistant', () => {
const u1 = mkMsg('user', 'q1');
const a1 = mkMsg('assistant', 'a1');
const u2 = mkMsg('user', 'q2');
const a2 = mkMsg('assistant', 'a2');
const result = turns([u1, a1, u2, a2]);
expect(result).toEqual([
{ start: 0, end: 2, id: u1.id },
{ start: 2, end: 4, id: u2.id },
]);
});
it('extends the final turn end to include trailing non-user messages', () => {
// Spec wording: "user/assistant + trailing system → trailing included
// in last turn's range". Single-turn variant: [user, assistant, system]
// should produce one turn with end=3 (covers all three indices).
const u = mkMsg('user', 'q');
const a = mkMsg('assistant', 'a');
const s = mkMsg('system', 'note');
const result = turns([u, a, s]);
expect(result).toEqual([{ start: 0, end: 3, id: u.id }]);
});
it('skips user rows flagged as summary (anchored-rolling rows)', () => {
// Defense-in-depth — process() pre-filters summary rows, but turns()
// also skips them so a misuse from another caller doesn't create a
// bogus turn boundary on the summary row itself.
const u1 = mkMsg('user', 'q1');
const a1 = mkMsg('assistant', 'a1');
const sum = mkMsg('user', 'rolled-up', { summary: true });
const u2 = mkMsg('user', 'q2');
const result = turns([u1, a1, sum, u2]);
expect(result.map((t) => t.id)).toEqual([u1.id, u2.id]);
});
});
// ---- select -----------------------------------------------------------------
describe('select', () => {
it('returns empty head + undefined tail for an empty message list', () => {
const result = select([], 100_000);
expect(result.head).toEqual([]);
expect(result.tail_start_id).toBeUndefined();
});
it('full-preserves when there are fewer turns than tail_turns', () => {
// 1 turn but tail_turns=2: keep === turn0 → keep.start === 0 →
// sentinel-return path that signals "no compaction this round".
const u = mkMsg('user', 'only');
const a = mkMsg('assistant', 'a');
const result = select([u, a], 100_000, 2);
expect(result.head).toEqual([u, a]);
expect(result.tail_start_id).toBeUndefined();
});
it('keeps the last tail_turns turns when they all fit the budget', () => {
// 3 turns, all small. tail_turns=2 means keep the last 2; head =
// messages[0..turn2.start] = just turn1's content.
const u1 = mkMsg('user', 'q1');
const a1 = mkMsg('assistant', 'a1');
const u2 = mkMsg('user', 'q2');
const a2 = mkMsg('assistant', 'a2');
const u3 = mkMsg('user', 'q3');
const a3 = mkMsg('assistant', 'a3');
const msgs = [u1, a1, u2, a2, u3, a3];
const result = select(msgs, 100_000, 2);
// Turn boundaries: [0,2), [2,4), [4,6). slice(-2) = turns at 2 and 4.
// Walking backward: u3 fits, then u2 fits → keep={start:2, id:u2.id}.
expect(result.tail_start_id).toBe(u2.id);
expect(result.head).toEqual([u1, a1]);
});
it('splits a turn mid-stream when the whole turn would overflow the budget', () => {
// tail_turns=1 so we look only at the most recent turn. Stuff it past
// 8k of content (max preserve budget) and the splitter walks forward
// looking for the largest suffix that fits.
const u1 = mkMsg('user', 'q1');
const a1 = mkMsg('assistant', 'a1');
const u2 = mkMsg('user', 'q2 with a giant payload');
const huge = mkMsg('assistant', 'X'.repeat(40_000)); // ~10k tokens
const smallTail = mkMsg('assistant', 'short answer');
const msgs = [u1, a1, u2, huge, smallTail];
const result = select(msgs, 100_000, 1);
// The split walks from turn.start+1 forward; the first index whose
// [i, end) slice fits the budget becomes the new keep. We don't assert
// a specific id (depends on character math), only that compaction was
// triggered (tail_start_id set, head non-empty) and that the head
// doesn't include the final small message.
expect(result.tail_start_id).toBeDefined();
expect(result.head.length).toBeGreaterThan(0);
expect(result.head).not.toContain(smallTail);
});
it('full-preserves when no split point fits', () => {
// Single oversized turn; splitTurn walks but each suffix is still too
// big. After the loop, keep is undefined → full-preserve sentinel.
// Force this with a sub-buffer context so budget is the floor (2k),
// and a single 40k-char message.
const u = mkMsg('user', 'oversized');
const a = mkMsg('assistant', 'Y'.repeat(40_000));
const result = select([u, a], 30_000, 1);
// usable(30k) = 10k → budget = min(8k, max(2k, floor(10k*0.25))) =
// min(8k, max(2k, 2500)) = 2500. 40k chars ≈ 10k tokens. Can't fit.
expect(result.tail_start_id).toBeUndefined();
expect(result.head).toEqual([u, a]);
});
});
// ---- buildPrompt ------------------------------------------------------------
describe('buildPrompt', () => {
it('opens with the "create new" anchor when previousSummary is undefined', () => {
const out = buildPrompt(undefined, []);
expect(out.startsWith('Create a new anchored summary')).toBe(true);
expect(out).toContain(SUMMARY_TEMPLATE);
expect(out).not.toContain('<previous-summary>');
});
it('opens with the "update" anchor and embeds previousSummary verbatim', () => {
const prev = '## Goal\n- finish v1.11 compaction';
const out = buildPrompt(prev, []);
expect(out.startsWith('Update the anchored summary')).toBe(true);
expect(out).toContain('<previous-summary>');
expect(out).toContain(prev);
expect(out).toContain('</previous-summary>');
expect(out).toContain(SUMMARY_TEMPLATE);
});
it('appends extra context strings after the template (reserved for plugin injection)', () => {
const out = buildPrompt(undefined, ['extra-context-line']);
expect(out.endsWith('extra-context-line')).toBe(true);
});
});

View File

@@ -21,6 +21,8 @@ function makeSession(overrides: Partial<Session> = {}): Session {
status: 'open',
created_at: new Date(0).toISOString(),
updated_at: new Date(0).toISOString(),
agent_id: null,
web_search_enabled: null,
...overrides,
};
}
@@ -34,6 +36,8 @@ function makeProject(overrides: Partial<Project> = {}): Project {
last_session_id: null,
status: 'open',
gitea_remote: null,
default_system_prompt: '',
default_web_search_enabled: false,
...overrides,
};
}
@@ -62,6 +66,7 @@ function makeMessage(
started_at: null,
finished_at: null,
created_at: new Date(counter * 1000).toISOString(),
metadata: null,
...overrides,
};
}

View File

@@ -0,0 +1,205 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
configureModelContext,
getModelContext,
invalidateModelContext,
} from '../model-context.js';
// ---- fixtures ---------------------------------------------------------------
const TEST_URL = 'http://llama-swap.test:8401';
function mockOkProps(n_ctx: number, total_slots = 1) {
return new Response(
JSON.stringify({
default_generation_settings: { n_ctx },
total_slots,
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}
beforeEach(() => {
invalidateModelContext();
configureModelContext({ llamaSwapUrl: TEST_URL });
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
// ---- positive cache ---------------------------------------------------------
describe('getModelContext — positive cache', () => {
it('returns the parsed body on a 200 with valid shape', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(mockOkProps(262_144, 1));
const result = await getModelContext('qwen3.6');
expect(result).not.toBeNull();
expect(result!.n_ctx).toBe(262_144);
expect(result!.total_slots).toBe(1);
expect(typeof result!.fetched_at).toBe('number');
// Verify the URL was constructed correctly — encodes the model name in
// case it contains characters that would break the path.
expect(fetchSpy).toHaveBeenCalledExactlyOnceWith(
`${TEST_URL}/upstream/qwen3.6/props`,
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
});
it('serves the second call from cache without refetching', async () => {
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(mockOkProps(262_144));
const a = await getModelContext('qwen3.6');
const b = await getModelContext('qwen3.6');
expect(a).toEqual(b);
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it('defaults total_slots to 1 when the server omits it', async () => {
// Mirror the docstring claim — total_slots is informational and we don't
// reject the response just because it's missing.
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
new Response(JSON.stringify({ default_generation_settings: { n_ctx: 8192 } }), {
status: 200,
}),
);
const result = await getModelContext('partial-model');
expect(result).not.toBeNull();
expect(result!.n_ctx).toBe(8192);
expect(result!.total_slots).toBe(1);
});
});
// ---- negative cache (single-shot) ------------------------------------------
describe('getModelContext — negative cache (single failure modes)', () => {
it('returns null and negative-caches when default_generation_settings is missing', async () => {
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(new Response(JSON.stringify({ total_slots: 1 }), { status: 200 }));
const result = await getModelContext('broken');
expect(result).toBeNull();
// Second call within TTL must not refetch.
const result2 = await getModelContext('broken');
expect(result2).toBeNull();
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it('returns null and negative-caches when n_ctx is missing inside default_generation_settings', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
new Response(JSON.stringify({ default_generation_settings: {}, total_slots: 1 }), {
status: 200,
}),
);
await getModelContext('half-broken');
await getModelContext('half-broken');
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it('returns null and negative-caches on non-200 (404)', async () => {
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(new Response('not found', { status: 404 }));
const result = await getModelContext('missing-model');
expect(result).toBeNull();
const result2 = await getModelContext('missing-model');
expect(result2).toBeNull();
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it('returns null and negative-caches on network error', async () => {
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockRejectedValueOnce(new TypeError('fetch failed: connect ECONNREFUSED'));
const result = await getModelContext('down-upstream');
expect(result).toBeNull();
const result2 = await getModelContext('down-upstream');
expect(result2).toBeNull();
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
});
// ---- negative cache TTL -----------------------------------------------------
describe('getModelContext — negative cache TTL', () => {
it('does NOT refetch when a second call lands within the 60s TTL', async () => {
vi.useFakeTimers();
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(new Response('boom', { status: 500 }));
await getModelContext('flapping');
vi.advanceTimersByTime(30_000);
await getModelContext('flapping');
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it('refetches when the second call lands after the 60s TTL expires', async () => {
vi.useFakeTimers();
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(new Response('boom', { status: 500 }))
// Recovered upstream on the retry — we expect a positive cache hit
// after this fires.
.mockResolvedValueOnce(mockOkProps(8192));
await getModelContext('flapping');
vi.advanceTimersByTime(61_000);
const result = await getModelContext('flapping');
expect(result).not.toBeNull();
expect(result!.n_ctx).toBe(8192);
expect(fetchSpy).toHaveBeenCalledTimes(2);
});
});
// ---- invalidateModelContext -------------------------------------------------
describe('invalidateModelContext', () => {
it('clears a single positive entry by model name', async () => {
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(mockOkProps(8192))
.mockResolvedValueOnce(mockOkProps(8192));
await getModelContext('cleared');
invalidateModelContext('cleared');
await getModelContext('cleared');
expect(fetchSpy).toHaveBeenCalledTimes(2);
});
it('clears ALL entries when called with no arg', async () => {
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(mockOkProps(8192))
.mockResolvedValueOnce(mockOkProps(16_384))
// After the full clear, both models re-fetch.
.mockResolvedValueOnce(mockOkProps(8192))
.mockResolvedValueOnce(mockOkProps(16_384));
await getModelContext('alpha');
await getModelContext('beta');
invalidateModelContext();
await getModelContext('alpha');
await getModelContext('beta');
expect(fetchSpy).toHaveBeenCalledTimes(4);
});
it('clearing a positive entry also clears the matching negative entry', async () => {
// Mixed state: first call fails (negative-caches), then we invalidate
// explicitly and the next call should fetch again rather than serve
// the stale negative entry.
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(new Response('boom', { status: 500 }))
.mockResolvedValueOnce(mockOkProps(4096));
await getModelContext('formerly-broken');
invalidateModelContext('formerly-broken');
const result = await getModelContext('formerly-broken');
expect(result).not.toBeNull();
expect(result!.n_ctx).toBe(4096);
expect(fetchSpy).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,330 @@
import { promises as fs } from 'node:fs';
import { join } from 'node:path';
import type { Agent, AgentsResponse, AgentParseError } from '../types/api.js';
// v1.8.1: global agents live at /data/AGENTS.md inside the container
// (./data:/data:ro mount on the host). Per-project AGENTS.md at the project
// root overrides global by name. In-code builtins are gone — the seed file is
// the contents of the previous BUILTIN_AGENTS list, copied into /data/AGENTS.md
// once on first deploy.
const GLOBAL_AGENTS_PATH = '/data/AGENTS.md';
const CACHE_TTL_MS = 60_000;
// Tools whitelist universe matches services/tools.ts ALL_TOOLS. Keep in sync.
// Batch 9.6: skill_find / skill_use / skill_resource added. Agents without an
// explicit `tools:` field inherit the full default set (which now includes
// the skill tools); agents with an explicit `tools:` array must list any
// skill tool they want to use — strict opt-in.
// Batch 9.7: ask_user_input added — same opt-in semantics. Agents with an
// explicit tools list that omits it cannot trigger the interactive picker.
const ALL_TOOL_NAMES = [
'view_file', 'list_dir', 'grep', 'find_files', 'git_status',
'skill_find', 'skill_use', 'skill_resource',
'ask_user_input',
] as const;
const DEFAULT_TOOLS: string[] = [...ALL_TOOL_NAMES];
const DEFAULT_TEMPERATURE = 0.7;
export function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
// ---- AGENTS.md parser ------------------------------------------------------
interface ParsedFrontmatter {
temperature?: number;
tools?: string[];
description?: string;
model?: string;
// v1.8.2: optional per-agent tool-loop budget. Absent → inference resolves
// from the agent's toolset at runtime.
max_tool_calls?: number;
}
function stripQuotes(s: string): string {
if (
s.length >= 2 &&
(s[0] === '"' || s[0] === "'") &&
s[0] === s[s.length - 1]
) {
return s.slice(1, -1);
}
return s;
}
function parseFrontmatter(yaml: string): { data: ParsedFrontmatter; errors: string[] } {
const data: ParsedFrontmatter = {};
const errors: string[] = [];
const lines = yaml.split('\n');
let arrayKey: 'tools' | null = null;
for (const rawLine of lines) {
const line = rawLine.trim();
if (line.length === 0) continue;
// Block-list continuation: "- value" under a key that was set to empty
if (arrayKey && line.startsWith('- ')) {
data[arrayKey]!.push(line.slice(2).trim());
continue;
}
arrayKey = null;
const colonIdx = line.indexOf(':');
if (colonIdx < 0) continue;
const key = line.slice(0, colonIdx).trim();
const valueRaw = line.slice(colonIdx + 1).trim();
if (key === 'temperature') {
const n = Number(valueRaw);
if (Number.isFinite(n)) data.temperature = n;
else errors.push(`temperature must be a number (got "${valueRaw}")`);
} else if (key === 'tools') {
if (valueRaw === '') {
data.tools = [];
arrayKey = 'tools';
} else if (valueRaw.startsWith('[') && valueRaw.endsWith(']')) {
const inner = valueRaw.slice(1, -1);
data.tools = inner
.split(',')
.map((s) => stripQuotes(s.trim()))
.filter((s) => s.length > 0);
} else {
// Loose form: "tools: a, b, c"
data.tools = valueRaw
.split(',')
.map((s) => stripQuotes(s.trim()))
.filter((s) => s.length > 0);
}
} else if (key === 'description') {
data.description = stripQuotes(valueRaw);
} else if (key === 'model') {
data.model = stripQuotes(valueRaw);
} else if (key === 'max_tool_calls') {
// v1.8.2: 1..100 inclusive integer. Out-of-range values are skipped
// with a warning rather than throwing — agents shouldn't be unusable
// because of a typo on a defaulted field. Non-numeric or non-integer
// still hard-fails the block, matching `temperature` behavior.
const n = Number(valueRaw);
if (Number.isInteger(n) && n >= 1 && n <= 100) {
data.max_tool_calls = n;
} else if (Number.isInteger(n)) {
console.warn(
`agents: max_tool_calls ${n} out of range 1-100, ignoring (falling back to default)`,
);
} else {
errors.push(`max_tool_calls must be an integer 1-100 (got "${valueRaw}")`);
}
}
// Unknown keys silently ignored — forward-compat.
}
return { data, errors };
}
interface RawSection {
name: string;
body: string;
}
function splitSections(content: string): RawSection[] {
// Split by lines matching exactly "## <name>". Level-3+ headings are body content.
const sections: RawSection[] = [];
let currentName: string | null = null;
let currentLines: string[] = [];
for (const line of content.split('\n')) {
const h2 = /^##\s+(.+?)\s*$/.exec(line);
const h3 = line.startsWith('### ');
if (h2 && !h3) {
if (currentName !== null) {
sections.push({ name: currentName, body: currentLines.join('\n') });
}
currentName = h2[1]!.trim();
currentLines = [];
continue;
}
if (currentName !== null) {
currentLines.push(line);
}
}
if (currentName !== null) {
sections.push({ name: currentName, body: currentLines.join('\n') });
}
return sections;
}
// Throws on malformed section — caller handles per-block error collection.
function parseAgentSection(section: RawSection): Omit<Agent, 'source'> {
const lines = section.body.split('\n');
// Opening "---" fence must be the first non-empty line.
let openIdx = -1;
for (let i = 0; i < lines.length; i++) {
const t = lines[i]!.trim();
if (t === '') continue;
if (t === '---') {
openIdx = i;
}
break;
}
if (openIdx < 0) {
throw new Error('missing opening --- fence after heading');
}
let closeIdx = -1;
for (let i = openIdx + 1; i < lines.length; i++) {
if (lines[i]!.trim() === '---') {
closeIdx = i;
break;
}
}
if (closeIdx < 0) {
throw new Error('missing closing --- fence');
}
const yamlText = lines.slice(openIdx + 1, closeIdx).join('\n');
const systemPrompt = lines.slice(closeIdx + 1).join('\n').trim();
const { data: fm, errors: fmErrors } = parseFrontmatter(yamlText);
if (fmErrors.length > 0) {
throw new Error(fmErrors.join('; '));
}
const filteredTools = Array.isArray(fm.tools)
? fm.tools.filter((t): t is string =>
(ALL_TOOL_NAMES as readonly string[]).includes(t),
)
: DEFAULT_TOOLS;
return {
id: slugify(section.name),
name: section.name,
description: fm.description ?? '',
system_prompt: systemPrompt,
temperature: typeof fm.temperature === 'number' ? fm.temperature : DEFAULT_TEMPERATURE,
tools: filteredTools,
model: typeof fm.model === 'string' && fm.model.length > 0 ? fm.model : null,
max_tool_calls: typeof fm.max_tool_calls === 'number' ? fm.max_tool_calls : null,
};
}
interface ParseResult {
agents: Omit<Agent, 'source'>[];
errors: AgentParseError[];
}
// v1.8.1: parse each `## Name` block independently. A failure in one block
// does not abort the rest of the file — we collect a per-agent error and
// keep parsing. Server logs a console.warn for each skipped agent.
export function parseAgentsMd(content: string): ParseResult {
const sections = splitSections(content);
const agents: Omit<Agent, 'source'>[] = [];
const errors: AgentParseError[] = [];
for (const section of sections) {
try {
agents.push(parseAgentSection(section));
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
console.warn(`agents: skipped "${section.name}" — ${reason}`);
errors.push({ agent_name: section.name, reason });
}
}
return { agents, errors };
}
// ---- mtime-keyed cache + public API ----------------------------------------
interface CacheEntry {
globalMtime: number | null;
projectMtime: number | null;
cachedAt: number;
result: AgentsResponse;
}
// Keyed by projectPath ('' is fine — no project case, e.g. tests). Two files
// participate in the cache key (global + project); editing either bumps the
// corresponding mtime so the next read sees a miss without a watcher.
const cache = new Map<string, CacheEntry>();
export function invalidateAgentsCache(projectPath?: string): void {
if (projectPath === undefined) {
cache.clear();
} else {
cache.delete(projectPath);
}
}
async function safeStat(path: string): Promise<number | null> {
try {
const s = await fs.stat(path);
return s.mtimeMs;
} catch {
return null;
}
}
async function safeRead(path: string): Promise<string | null> {
try {
return await fs.readFile(path, 'utf8');
} catch {
return null;
}
}
export async function getAgentsForProject(projectPath: string): Promise<AgentsResponse> {
const projectAgentsPath = projectPath ? join(projectPath, 'AGENTS.md') : null;
const [globalMtime, projectMtime] = await Promise.all([
safeStat(GLOBAL_AGENTS_PATH),
projectAgentsPath ? safeStat(projectAgentsPath) : Promise.resolve(null),
]);
const cacheKey = projectPath || '__none__';
const cached = cache.get(cacheKey);
const now = Date.now();
if (
cached &&
cached.globalMtime === globalMtime &&
cached.projectMtime === projectMtime &&
now - cached.cachedAt < CACHE_TTL_MS
) {
return cached.result;
}
const [globalContent, projectContent] = await Promise.all([
globalMtime !== null ? safeRead(GLOBAL_AGENTS_PATH) : Promise.resolve(null),
projectAgentsPath && projectMtime !== null ? safeRead(projectAgentsPath) : Promise.resolve(null),
]);
const errors: AgentParseError[] = [];
const byName = new Map<string, Agent>();
if (globalContent !== null) {
const r = parseAgentsMd(globalContent);
for (const a of r.agents) byName.set(a.name, { ...a, source: 'global' });
errors.push(...r.errors);
}
if (projectContent !== null) {
const r = parseAgentsMd(projectContent);
for (const a of r.agents) byName.set(a.name, { ...a, source: 'project' });
errors.push(...r.errors);
}
const result: AgentsResponse = {
agents: Array.from(byName.values()),
errors,
};
cache.set(cacheKey, { globalMtime, projectMtime, cachedAt: now, result });
return result;
}
export async function getAgentById(
projectPath: string,
agentId: string,
): Promise<Agent | null> {
const { agents } = await getAgentsForProject(projectPath);
return agents.find((a) => a.id === agentId) ?? null;
}

View File

@@ -144,4 +144,23 @@ export async function maybeAutoNameChat(
updated_at: updated[0]!.updated_at,
});
ctx.log.info({ chatId, name }, 'chat auto-named');
// Propagate to the parent session if it's still on its default name.
// The WHERE guard makes the check atomic — if the user has already
// renamed (or a prior chat already propagated), this UPDATE matches
// zero rows and we do nothing. First chat wins; manual renames win.
const renamedSession = await ctx.sql<{ id: string; name: string }[]>`
UPDATE sessions
SET name = ${name}
WHERE id = ${sessionId} AND name = 'New session'
RETURNING id, name
`;
if (renamedSession.length > 0) {
ctx.publishUser({
type: 'session_renamed',
session_id: sessionId,
name,
});
ctx.log.info({ sessionId, name }, 'session auto-named from chat');
}
}

View File

@@ -0,0 +1,40 @@
// v1.11: anchored rolling summary template. Verbatim port from opencode
// (packages/opencode/src/session/compaction.ts SUMMARY_TEMPLATE). Kept in a
// separate module so the long template literal doesn't bloat compaction.ts.
export const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
<template>
## Goal
- [single-sentence task summary]
## Constraints & Preferences
- [user constraints, preferences, specs, or "(none)"]
## Progress
### Done
- [completed work or "(none)"]
### In Progress
- [current work or "(none)"]
### Blocked
- [blockers or "(none)"]
## Key Decisions
- [decision and why, or "(none)"]
## Next Steps
- [ordered next actions or "(none)"]
## Critical Context
- [important technical facts, errors, open questions, or "(none)"]
## Relevant Files
- [file or directory path: why it matters, or "(none)"]
</template>
Rules:
- Keep every section, even when empty.
- Use terse bullets, not prose paragraphs.
- Preserve exact file paths, commands, error strings, and identifiers when known.
- Do not mention the summary process or that context was compacted.`;

View File

@@ -0,0 +1,510 @@
// v1.11: anchored rolling compaction. Ported algorithms (not Effect-TS code)
// from opencode (packages/opencode/src/session/{compaction,overflow}.ts).
//
// What's different from BooCode's legacy /compact:
// - Operates per-chat (chats have N:1 to sessions; history is per-chat).
// - Detects overflow automatically after each inference completion using
// llama-swap's reported n_ctx; flags chats.needs_compaction=true.
// - On the next turn (or manual /compact) we summarize the *head* (messages
// prior to a preserved tail of N user-turns) into a single
// summary=true assistant row. Older messages get compacted_at-stamped so
// inference assembly filters them out; the GET endpoint still returns
// them so the UI can show history with the summary card inline.
// - The summary is *anchored rolling* — exactly one live summary=true row
// per chat. Subsequent compactions read the prior summary as
// previousSummary, ask the LLM to update-merge it, then mark the prior
// summary row compacted_at too (it stays in the UI but isn't sent to the
// LLM again).
import type { FastifyBaseLogger } from 'fastify';
import type { Sql } from '../db.js';
import type { Config } from '../config.js';
import type { Broker } from './broker.js';
import { SUMMARY_TEMPLATE } from './compaction-prompt.js';
import * as modelContextLookup from './model-context.js';
const COMPACTION_BUFFER = 20_000;
const MIN_PRESERVE_RECENT_TOKENS = 2_000;
const MAX_PRESERVE_RECENT_TOKENS = 8_000;
const DEFAULT_TAIL_TURNS = 2;
// Subset of Message fields compaction touches. Selecting only what's needed
// keeps process() independent of api.ts mutations and reduces DB egress.
export interface CompactionMessage {
id: string;
role: 'user' | 'assistant' | 'system' | 'tool';
content: string;
kind: 'message' | 'compact';
summary: boolean;
status: 'streaming' | 'complete' | 'failed' | 'cancelled';
tool_calls: Array<{ id: string; name: string; args: Record<string, unknown> }> | null;
tool_results: { tool_call_id: string; output: unknown; truncated: boolean; error?: string } | null;
metadata: { kind?: string } | null;
created_at: string;
}
// === overflow ===
// Tokens we hold in reserve for the model's response so a near-full context
// can still produce a useful turn. Mirrors opencode's COMPACTION_BUFFER.
// Returns 0 when the context limit is unknown (caller treats 0 as "do not
// trigger overflow"); avoids dividing-by-zero downstream.
export function usable(contextLimit: number): number {
if (!contextLimit || contextLimit <= 0) return 0;
return Math.max(0, contextLimit - COMPACTION_BUFFER);
}
export interface Usage {
prompt_tokens: number;
completion_tokens: number;
}
// True when the assistant just used >= usable() tokens. Unknown limit → false
// (we never auto-trigger compaction without a budget — better to keep
// inference flowing than to fall into a compaction we can't size properly).
export function isOverflow(usage: Usage, contextLimit: number): boolean {
const budget = usable(contextLimit);
if (budget <= 0) return false;
return (usage.prompt_tokens + usage.completion_tokens) >= budget;
}
// === selection ===
interface Turn {
start: number;
end: number;
id: string;
}
// Char-count / 4 token estimate. Matches opencode's Token.estimate (which
// also goes through JSON.stringify). Adequate for tail-fitting math; we
// don't need a real tokenizer here — the 20k buffer absorbs the slop.
export function estimate(messages: CompactionMessage[]): number {
return Math.ceil(JSON.stringify(messages).length / 4);
}
// Walk messages, return one Turn per user message that is NOT a summary row.
// end = next-user-start; final turn ends at messages.length.
export function turns(messages: CompactionMessage[]): Turn[] {
const result: Turn[] = [];
for (let i = 0; i < messages.length; i++) {
const m = messages[i]!;
if (m.role !== 'user') continue;
if (m.summary) continue;
result.push({ start: i, end: messages.length, id: m.id });
}
for (let i = 0; i < result.length - 1; i++) {
result[i]!.end = result[i + 1]!.start;
}
return result;
}
// Inside a turn that doesn't fit whole, walk forward from start+1 looking for
// the largest suffix that fits the remaining budget. Returns the keep-start
// index (the first preserved message) or undefined if no suffix fits.
function splitTurn(
messages: CompactionMessage[],
turn: Turn,
budget: number,
): { start: number; id: string } | undefined {
if (budget <= 0) return undefined;
if (turn.end - turn.start <= 1) return undefined;
for (let start = turn.start + 1; start < turn.end; start++) {
const size = estimate(messages.slice(start, turn.end));
if (size > budget) continue;
return { start, id: messages[start]!.id };
}
return undefined;
}
export interface SelectResult {
head: CompactionMessage[];
tail_start_id: string | undefined;
}
// Choose the boundary between the "head" (to be summarized) and the "tail"
// (preserved verbatim). Strategy:
// 1. Reserve a budget for the recent tail. Default ranges [2k, 8k] tokens
// with 25% of usable() as the target.
// 2. Take the last `tail_turns` user-turns; greedily fit from newest back.
// 3. If the next-older turn doesn't fit whole, split it mid-turn.
// 4. If we couldn't keep anything OR everything fit (keep.start === 0),
// return full-preserve (no compaction this round).
export function select(
messages: CompactionMessage[],
contextLimit: number,
tailTurns: number = DEFAULT_TAIL_TURNS,
): SelectResult {
if (tailTurns <= 0) return { head: messages, tail_start_id: undefined };
const budget = Math.min(
MAX_PRESERVE_RECENT_TOKENS,
Math.max(MIN_PRESERVE_RECENT_TOKENS, Math.floor(usable(contextLimit) * 0.25)),
);
const all = turns(messages);
if (all.length === 0) return { head: messages, tail_start_id: undefined };
const recent = all.slice(-tailTurns);
let total = 0;
let keep: { start: number; id: string } | undefined;
for (let i = recent.length - 1; i >= 0; i--) {
const turn = recent[i]!;
const size = estimate(messages.slice(turn.start, turn.end));
if (total + size <= budget) {
total += size;
keep = { start: turn.start, id: turn.id };
continue;
}
const remaining = budget - total;
const split = splitTurn(messages, turn, remaining);
if (split) keep = split;
break;
}
if (!keep || keep.start === 0) {
return { head: messages, tail_start_id: undefined };
}
return {
head: messages.slice(0, keep.start),
tail_start_id: keep.id,
};
}
// === prompt assembly ===
// Build the final user message that asks the model to (re)produce the
// anchored summary. `context` is reserved for future plugin injection;
// callers pass [] today.
export function buildPrompt(
previousSummary: string | undefined,
context: string[],
): string {
const anchor = previousSummary
? [
'Update the anchored summary below using the conversation history above.',
'Preserve still-true details, remove stale details, and merge in the new facts.',
'<previous-summary>',
previousSummary,
'</previous-summary>',
].join('\n')
: 'Create a new anchored summary from the conversation history above.';
return [anchor, SUMMARY_TEMPLATE, ...context].join('\n\n');
}
// === OpenAI conversion (compaction-local; intentionally does NOT call
// inference.ts buildMessagesPayload because that uses the legacy "find latest
// kind='compact' marker and skip everything before it" shortcircuit, which
// would silently drop pre-legacy-compact history before the LLM sees it.
// Compaction wants to send the entire head, full stop.) ===
interface OpenAiMessage {
role: 'system' | 'user' | 'assistant' | 'tool';
content: string | null;
tool_calls?: Array<{
id: string;
type: 'function';
function: { name: string; arguments: string };
}>;
tool_call_id?: string;
}
function isCapHitSentinel(m: CompactionMessage): boolean {
return m.role === 'system' && m.metadata != null && m.metadata.kind === 'cap_hit';
}
function buildHeadPayload(head: CompactionMessage[]): OpenAiMessage[] {
const out: OpenAiMessage[] = [];
for (const m of head) {
if (isCapHitSentinel(m)) continue;
if (m.role === 'assistant' && (m.status === 'streaming' || m.status === 'cancelled')) continue;
if (m.kind === 'compact') {
// Legacy compact row — pass through as system context. The new
// anchored summary will subsume it, but the LLM should see it during
// the bridging round so it can carry forward the still-true bits.
out.push({ role: 'system', content: m.content });
continue;
}
if (m.summary) {
// Defense in depth: process() filters these out of the select-input
// already. If one slips through, render it as assistant content so we
// never crash here.
out.push({ role: 'assistant', content: m.content });
continue;
}
if (m.role === 'tool') {
const tr = m.tool_results;
if (!tr) continue;
const outputText = tr.error
? `error: ${tr.error}`
: typeof tr.output === 'string'
? tr.output
: JSON.stringify(tr.output);
out.push({ role: 'tool', content: outputText, tool_call_id: tr.tool_call_id });
continue;
}
if (m.role === 'assistant') {
const msg: OpenAiMessage = {
role: 'assistant',
content: m.content && m.content.length > 0 ? m.content : null,
};
if (m.tool_calls && m.tool_calls.length > 0) {
msg.tool_calls = m.tool_calls.map((tc) => ({
id: tc.id,
type: 'function' as const,
function: { name: tc.name, arguments: JSON.stringify(tc.args) },
}));
}
out.push(msg);
continue;
}
out.push({ role: 'user', content: m.content });
}
return out;
}
// === llama-swap call ===
// Non-streaming completion. Opencode streams; for a one-shot summary call a
// single POST is less code and the latency hit is acceptable (the user
// doesn't see this directly — useSessionStream emits the toast + refetches
// on the 'compacted' frame).
interface CompletionResult {
content: string;
promptTokens: number;
completionTokens: number;
}
async function callLlamaSwap(
config: Config,
model: string,
messages: OpenAiMessage[],
log: FastifyBaseLogger,
): Promise<CompletionResult> {
const res = await fetch(`${config.LLAMA_SWAP_URL}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, messages, stream: false }),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`llama-swap returned ${res.status}: ${text.slice(0, 200)}`);
}
const json = (await res.json()) as {
choices?: Array<{ message?: { content?: string } }>;
usage?: { prompt_tokens?: number; completion_tokens?: number };
};
// v1.11.3: removed the dead `json.timings?.n_ctx` read — llama-server's
// completions don't emit n_ctx in timings. ctx_max on the summary row
// comes from model-context.getModelContext below in process().
const content = json.choices?.[0]?.message?.content ?? '';
const promptTokens = json.usage?.prompt_tokens ?? 0;
const completionTokens = json.usage?.completion_tokens ?? 0;
log.debug({ promptTokens, completionTokens, chars: content.length }, 'compaction llm complete');
return { content, promptTokens, completionTokens };
}
// === entry point ===
export interface ProcessInput {
sql: Sql;
config: Config;
log: FastifyBaseLogger;
broker: Broker;
chatId: string;
}
// Runs one round of anchored rolling compaction on `chatId`. No-ops cleanly
// (clearing needs_compaction) when there's nothing reasonable to compact.
// Throws on LLM failure — callers decide whether to log+swallow or surface.
export async function process(input: ProcessInput): Promise<void> {
const { sql, config, log, broker, chatId } = input;
// 1. Resolve chat → session for model + WS publish channel.
const chatRows = await sql<{ id: string; session_id: string }[]>`
SELECT id, session_id FROM chats WHERE id = ${chatId}
`;
if (chatRows.length === 0) {
log.warn({ chatId }, 'compaction: chat not found');
return;
}
const chat = chatRows[0]!;
const sessionId = chat.session_id;
const sessRows = await sql<{ id: string; model: string }[]>`
SELECT id, model FROM sessions WHERE id = ${sessionId}
`;
if (sessRows.length === 0) {
log.warn({ chatId, sessionId }, 'compaction: session not found');
return;
}
const session = sessRows[0]!;
// 2. All currently-active messages in this chat (compacted_at IS NULL).
// ORDER BY (created_at, id) matches loadContext in inference.ts so the
// turns() boundary logic sees the same sequence the LLM will.
const messages = await sql<CompactionMessage[]>`
SELECT id, role, content, kind, summary, status, tool_calls, tool_results, metadata, created_at
FROM messages
WHERE chat_id = ${chatId} AND compacted_at IS NULL
ORDER BY created_at ASC, id ASC
`;
if (messages.length === 0) {
await sql`UPDATE chats SET needs_compaction = false WHERE id = ${chatId}`;
return;
}
// 3. Find the prior anchored summary (newest summary=true row). Its content
// becomes previousSummary — the anchor in the prompt. Filter it out of the
// select-input so we don't double-encode (it's already in the anchor text).
const previousSummary = messages.filter((m) => m.summary).at(-1)?.content;
const forSelect = messages.filter((m) => !m.summary);
// 4. Resolve a recent context limit. llama-swap reports timings.n_ctx per
// completion; we cache it on messages.ctx_max. Use the most recent value
// from any message in this chat (oldest assumption is the same model is
// still running). When unknown, fall back to model.context_limit-less
// defaults via the buffer-only path (see usable()).
const ctxRows = await sql<{ ctx_max: number | null }[]>`
SELECT ctx_max FROM messages
WHERE chat_id = ${chatId} AND ctx_max IS NOT NULL
ORDER BY created_at DESC LIMIT 1
`;
const contextLimit = ctxRows[0]?.ctx_max ?? 0;
// 5. Decide head / tail.
const sel = select(forSelect, contextLimit);
if (!sel.tail_start_id || sel.head.length === 0) {
// Full preserve — nothing to compact this round. Clear the flag so we
// don't loop. (Could happen when the chat is short or the budget swung
// wider after a model context bump.)
await sql`UPDATE chats SET needs_compaction = false WHERE id = ${chatId}`;
log.info({ chatId, contextLimit, msgCount: messages.length }, 'compaction: nothing to compact');
return;
}
// 6. Build the OpenAI request: head as user/assistant/tool turns + a final
// user message carrying buildPrompt(previousSummary, []). No system prompt
// — matches opencode (`system: []`); the template + anchor are sufficient.
const headPayload = buildHeadPayload(sel.head);
const finalUser: OpenAiMessage = { role: 'user', content: buildPrompt(previousSummary, []) };
const payload = [...headPayload, finalUser];
log.info(
{
chatId,
contextLimit,
headLen: sel.head.length,
tailStartId: sel.tail_start_id,
hadPrevSummary: previousSummary !== undefined,
},
'compaction: invoking model',
);
// 6a. Flip the chat dot amber for the duration of the LLM call + DB writes.
// Same { type: 'chat_status', status: 'working', at } shape inference.ts
// emits at runner enqueue. publishUser → broadcasts on the per-user channel
// (all devices / tabs see it) since chat_status is a user-channel frame in
// BooCode (see useChatStatus.ts, which is the consumer).
broker.publishUser('default', {
type: 'chat_status',
chat_id: chatId,
status: 'working',
at: new Date().toISOString(),
});
// try/finally so the dot ALWAYS drops back to idle, even if the LLM call
// throws or a downstream DB write fails. The succeeded flag gates the
// 'compacted' frame + final log: we only signal completion to the UI when
// the new summary row actually landed.
let succeeded = false;
let newId = '';
let result: CompletionResult | undefined;
try {
// 7. Single completion (no tools). Throws on llama-swap failure.
result = await callLlamaSwap(config, session.model, payload, log);
// 7b. v1.11.3: fetch the model's true context window from llama-swap's
// /upstream/<model>/props (the streaming completion doesn't carry it).
// Same pattern as inference.ts; the cache makes repeated calls free.
const mctx = await modelContextLookup.getModelContext(session.model);
const nCtx = mctx?.n_ctx ?? null;
// 8. Insert the new anchored summary row. role='assistant' per spec; the
// UI distinguishes via summary=true. tail_start_id points at the first
// preserved tail message so debug surfaces / future tools can reason
// about the boundary without re-deriving from compacted_at.
const insertRows = await sql<{ id: string }[]>`
INSERT INTO messages (
session_id, chat_id, role, content, kind, status,
summary, tail_start_id,
tokens_used, ctx_used, ctx_max,
created_at, finished_at
)
VALUES (
${sessionId}, ${chatId}, 'assistant', ${result.content}, 'message', 'complete',
true, ${sel.tail_start_id},
${result.completionTokens}, ${result.promptTokens}, ${nCtx},
clock_timestamp(), clock_timestamp()
)
RETURNING id
`;
newId = insertRows[0]!.id;
// 9. Mark every prior live message (head + prior summary) as compacted.
// Bound by "created_at strictly less than tail_start_id's created_at" so
// the preserved tail stays compacted_at=NULL. Exclude the new summary
// row we just inserted (it's "now", which is >= tail_start_id's
// created_at anyway, but defensive).
await sql`
UPDATE messages
SET compacted_at = clock_timestamp()
WHERE chat_id = ${chatId}
AND compacted_at IS NULL
AND id != ${newId}
AND created_at < (SELECT created_at FROM messages WHERE id = ${sel.tail_start_id})
`;
// 10. Clear the flag and bump the chat's updated_at so the sidebar
// reflects recent activity.
await sql`
UPDATE chats
SET needs_compaction = false, updated_at = clock_timestamp()
WHERE id = ${chatId}
`;
succeeded = true;
} finally {
// Always restore the dot. Status='idle' (not 'error') even on failure —
// the caller logs/re-surfaces the error separately; the dot doesn't
// need to stay red across reloads for a transient compaction blip.
broker.publishUser('default', {
type: 'chat_status',
chat_id: chatId,
status: 'idle',
at: new Date().toISOString(),
});
}
// 11. Tell the client. useSessionStream subscribes to the per-session WS
// channel; the handler refetches messages (so the new summary row + the
// compacted_at-stamped older rows render correctly) and fires a sonner
// toast. Order matters: idle must precede 'compacted' so the dot is
// already green by the time the refetch toast appears.
if (succeeded) {
broker.publish(sessionId, {
type: 'compacted',
session_id: sessionId,
chat_id: chatId,
summary_message_id: newId,
});
log.info(
{
chatId,
newId,
completionTokens: result?.completionTokens,
promptTokens: result?.promptTokens,
},
'compaction: complete',
);
}
}

View File

@@ -0,0 +1,92 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
const CACHE_TTL_MS = 30_000;
const GIT_TIMEOUT_MS = 2_000;
// Cap stdout size so a pathological repo can't blow the buffer. Branch + status
// porcelain + diverge counts never approach this on a real repo.
const GIT_MAX_BUFFER = 1024 * 1024;
export interface GitMeta {
branch: string | null;
is_dirty: boolean;
ahead: number;
behind: number;
}
interface CacheEntry {
at: number;
value: GitMeta | null;
}
const cache = new Map<string, CacheEntry>();
// Runs a single git invocation with a hard 2s timeout. Returns null on any
// failure (non-zero exit, timeout, git not installed) so callers can decide
// how to degrade. Stderr is intentionally swallowed; we don't surface git's
// error text to the model or UI.
async function runGit(args: string[], cwd: string): Promise<string | null> {
try {
const { stdout } = await execFileAsync('git', args, {
cwd,
timeout: GIT_TIMEOUT_MS,
windowsHide: true,
maxBuffer: GIT_MAX_BUFFER,
});
return stdout.toString();
} catch {
return null;
}
}
export async function getGitMeta(rootPath: string): Promise<GitMeta | null> {
const cached = cache.get(rootPath);
const now = Date.now();
if (cached && now - cached.at < CACHE_TTL_MS) {
return cached.value;
}
// Three calls in parallel. rev-parse establishes repo + branch name;
// status --porcelain detects dirtiness with no false-positives from formatting;
// rev-list --left-right --count compares HEAD to upstream and is allowed to
// fail silently (returns null → ahead/behind = 0) when no upstream is set.
const [branchOut, statusOut, divergedOut] = await Promise.all([
runGit(['rev-parse', '--abbrev-ref', 'HEAD'], rootPath),
runGit(['status', '--porcelain'], rootPath),
runGit(['rev-list', '--left-right', '--count', 'HEAD...@{upstream}'], rootPath),
]);
// If rev-parse fails, this isn't a git repo (or git isn't installed). Cache
// the null result so the next 30s of requests don't re-probe.
if (branchOut === null) {
cache.set(rootPath, { at: now, value: null });
return null;
}
const branch = branchOut.trim() || null;
const is_dirty = statusOut !== null && statusOut.trim().length > 0;
let ahead = 0;
let behind = 0;
if (divergedOut !== null) {
const match = divergedOut.trim().match(/^(\d+)\s+(\d+)/);
if (match) {
ahead = Number(match[1]);
behind = Number(match[2]);
}
}
const value: GitMeta = { branch, is_dirty, ahead, behind };
cache.set(rootPath, { at: now, value });
return value;
}
export function invalidateGitMetaCache(rootPath?: string): void {
if (rootPath) {
cache.delete(rootPath);
} else {
cache.clear();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,113 @@
// v1.11.3: llama-swap model-context cache. Replaces the dead
// `parsed.timings.n_ctx` capture in inference.ts / compaction.ts —
// llama-server's streaming completion never emits n_ctx in timings (verified
// empirically: timings carries prompt_n / predicted_n / *_ms / *_per_second
// only). The authoritative source is llama-swap's
// /upstream/<model>/props endpoint at .default_generation_settings.n_ctx.
//
// Cache design:
// - Positive entries (n_ctx + total_slots) have no TTL. A model's context
// size doesn't change while llama-swap is running; an admin endpoint
// can invalidateModelContext() if it ever does.
// - Negative entries (failed fetch) have a 60s TTL so a misconfigured or
// down model doesn't get hammered every inference turn, but recovers
// within a minute once the upstream comes back.
// - 3s AbortController timeout on the fetch — long enough for a healthy
// upstream, short enough that a stuck upstream doesn't block the
// ctx_max UPDATE that follows.
export interface ModelContext {
n_ctx: number;
total_slots: number;
fetched_at: number;
}
const NEGATIVE_TTL_MS = 60_000;
const FETCH_TIMEOUT_MS = 3_000;
const positiveCache = new Map<string, ModelContext>();
// Value is the unix-ms timestamp of the last failed fetch. Used to gate
// re-fetches within the 60s window.
const negativeCache = new Map<string, number>();
// Set once at startup by index.ts. We don't import loadConfig() directly
// here to keep this module trivially mockable in tests (set the URL in
// beforeEach instead of stubbing process.env + loadConfig's cache).
let llamaSwapUrl: string | null = null;
export function configureModelContext(opts: { llamaSwapUrl: string }): void {
llamaSwapUrl = opts.llamaSwapUrl;
}
export async function getModelContext(model: string): Promise<ModelContext | null> {
// 1. Positive cache hit — no TTL check, model n_ctx is invariant.
const pos = positiveCache.get(model);
if (pos) return pos;
// 2. Negative cache hit within TTL — return null without refetching.
// Stale negative entries (older than the TTL) fall through to a fresh
// attempt below; we don't delete them eagerly because the next successful
// fetch will overwrite via the positive map and the negative entry
// becomes irrelevant.
const negTs = negativeCache.get(model);
if (negTs !== undefined && Date.now() - negTs < NEGATIVE_TTL_MS) {
return null;
}
// 3. Module not initialized. Defensive — index.ts calls
// configureModelContext at startup; if a test forgets, fail closed so
// the chat still works (ctx_max stays null, UI degrades gracefully).
if (!llamaSwapUrl) {
negativeCache.set(model, Date.now());
return null;
}
// 4. Fetch with timeout. AbortController fires after FETCH_TIMEOUT_MS;
// both the timeout path and a fetch reject end up in the catch below
// and produce a negative cache entry.
const url = `${llamaSwapUrl}/upstream/${encodeURIComponent(model)}/props`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const res = await fetch(url, { signal: controller.signal });
clearTimeout(timer);
if (!res.ok) {
negativeCache.set(model, Date.now());
return null;
}
const body = (await res.json()) as {
default_generation_settings?: { n_ctx?: number };
total_slots?: number;
};
const n_ctx = body?.default_generation_settings?.n_ctx;
if (typeof n_ctx !== 'number' || n_ctx <= 0) {
negativeCache.set(model, Date.now());
return null;
}
// total_slots is informational; default to 1 if missing rather than
// reject the whole response. Most local llama-swap setups run a
// single slot anyway.
const total_slots =
typeof body?.total_slots === 'number' && body.total_slots > 0 ? body.total_slots : 1;
const entry: ModelContext = { n_ctx, total_slots, fetched_at: Date.now() };
positiveCache.set(model, entry);
// Clear any stale negative entry so a future query sees the positive
// hit cleanly (otherwise the negative TTL never expires from the map).
negativeCache.delete(model);
return entry;
} catch {
clearTimeout(timer);
negativeCache.set(model, Date.now());
return null;
}
}
export function invalidateModelContext(model?: string): void {
if (model === undefined) {
positiveCache.clear();
negativeCache.clear();
} else {
positiveCache.delete(model);
negativeCache.delete(model);
}
}

View File

@@ -0,0 +1,321 @@
import { promises as fs } from 'node:fs';
import { join, isAbsolute, basename } from 'node:path';
import { pathGuard, PathScopeError } from './path_guard.js';
// Batch 9.6: read-only skill library. Folders under /data/skills/<group>/<skill>/
// contain a SKILL.md with YAML frontmatter (name + description) and a markdown
// body. Three tools expose the library: skill_find (search), skill_use (load
// body), skill_resource (read a support file inside the folder).
//
// Layout is intentionally uniform — scan /data/skills/*/*/SKILL.md at fixed
// depth 3. Group folders (depth 1) hold LICENSE + ATTRIBUTION.md + skill
// subfolders and are NOT themselves skills. Support files inside skill
// folders are reachable via skill_resource, never auto-parsed.
//
// Cache model mirrors agents.ts: walk on first access, TTL re-walk to pick up
// new skills, per-entry mtime check on body access so a hot-edited SKILL.md
// is re-read without a restart. No watcher.
const SKILLS_ROOT = '/data/skills';
const MAX_RESOURCE_BYTES = 5 * 1024 * 1024;
const LIST_CACHE_TTL_MS = 60_000;
export interface Skill {
name: string;
description: string;
path: string;
mtime: number;
}
interface CachedSkill extends Skill {
body: string;
}
const cache = new Map<string, CachedSkill>();
let lastWalkedAt = 0;
// ---- Frontmatter parser ----------------------------------------------------
// Minimal `---\n...\n---` extractor. Only `name` and `description` keys are
// honored; other frontmatter keys are silently ignored for forward-compat
// with the anthropics/skills upstream spec.
interface Frontmatter {
name?: string;
description?: string;
}
function stripQuotes(s: string): string {
if (s.length >= 2 && (s[0] === '"' || s[0] === "'") && s[0] === s[s.length - 1]) {
return s.slice(1, -1);
}
return s;
}
function parseFrontmatter(yaml: string): Frontmatter {
const fm: Frontmatter = {};
for (const raw of yaml.split('\n')) {
const line = raw.trim();
if (line.length === 0) continue;
const colon = line.indexOf(':');
if (colon < 0) continue;
const key = line.slice(0, colon).trim();
const val = stripQuotes(line.slice(colon + 1).trim());
if (key === 'name') fm.name = val;
else if (key === 'description') fm.description = val;
}
return fm;
}
interface ParsedSkillFile {
name: string;
description: string;
body: string;
}
function parseSkillFile(content: string): ParsedSkillFile {
const lines = content.split('\n');
let openIdx = -1;
for (let i = 0; i < lines.length; i++) {
const t = lines[i]!.trim();
if (t === '') continue;
if (t === '---') openIdx = i;
break;
}
if (openIdx < 0) throw new Error('missing opening --- fence');
let closeIdx = -1;
for (let i = openIdx + 1; i < lines.length; i++) {
if (lines[i]!.trim() === '---') { closeIdx = i; break; }
}
if (closeIdx < 0) throw new Error('missing closing --- fence');
const yamlText = lines.slice(openIdx + 1, closeIdx).join('\n');
const body = lines.slice(closeIdx + 1).join('\n');
const fm = parseFrontmatter(yamlText);
if (!fm.name) throw new Error('frontmatter missing name');
if (!fm.description) throw new Error('frontmatter missing description');
return { name: fm.name, description: fm.description, body };
}
// ---- Tree walk -------------------------------------------------------------
// Fixed depth-3 scan: /data/skills/<group>/<skill>/SKILL.md. Two layers of
// readdir, no recursion. Group folders without SKILL.md are skipped silently;
// LICENSE / ATTRIBUTION.md / other non-SKILL.md files are ignored entirely.
// Returns all parseable skills as-found — dedup + collision logging happens
// in ensureCache where the sort order is established.
async function walkSkills(root: string): Promise<CachedSkill[]> {
const found: CachedSkill[] = [];
let groups;
try {
groups = await fs.readdir(root, { withFileTypes: true });
} catch {
return found;
}
for (const group of groups) {
if (!group.isDirectory() || group.name.startsWith('.')) continue;
const groupPath = join(root, group.name);
let entries;
try {
entries = await fs.readdir(groupPath, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
const skillFolder = join(groupPath, entry.name);
const skillFile = join(skillFolder, 'SKILL.md');
let stat;
try {
stat = await fs.stat(skillFile);
} catch {
continue; // folder without SKILL.md — silent skip
}
if (!stat.isFile()) continue;
try {
const content = await fs.readFile(skillFile, 'utf8');
const parsed = parseSkillFile(content);
found.push({
name: parsed.name,
description: parsed.description,
path: skillFolder,
mtime: stat.mtimeMs,
body: parsed.body,
});
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
console.warn(`skills: failed to parse ${skillFile}${reason}`);
}
}
}
return found;
}
// ---- Cache ----------------------------------------------------------------
async function ensureCache(): Promise<void> {
const now = Date.now();
if (cache.size > 0 && now - lastWalkedAt < LIST_CACHE_TTL_MS) return;
let stat;
try {
stat = await fs.stat(SKILLS_ROOT);
} catch {
cache.clear();
lastWalkedAt = now;
return;
}
if (!stat.isDirectory()) {
cache.clear();
lastWalkedAt = now;
return;
}
const found = await walkSkills(SKILLS_ROOT);
// Sort by name asc, then path asc — gives alphabetically-first-wins on
// collision and stable, deterministic ordering for /api/skills + skill_find.
found.sort((a, b) => {
const n = a.name.localeCompare(b.name);
return n !== 0 ? n : a.path.localeCompare(b.path);
});
cache.clear();
const winnerPath = new Map<string, string>();
for (const skill of found) {
const prev = winnerPath.get(skill.name);
if (prev) {
console.warn(
`skills: name collision "${skill.name}" — kept ${prev}, skipped ${skill.path}`,
);
continue;
}
winnerPath.set(skill.name, skill.path);
cache.set(skill.name, skill);
}
lastWalkedAt = now;
}
// ---- Public API -----------------------------------------------------------
export async function listSkills(): Promise<Skill[]> {
await ensureCache();
return Array.from(cache.values()).map((s) => ({
name: s.name,
description: s.description,
path: s.path,
mtime: s.mtime,
}));
}
export interface SkillSummary {
name: string;
description: string;
}
export async function findSkills(query: string): Promise<SkillSummary[]> {
await ensureCache();
const all = Array.from(cache.values());
const q = (query ?? '').trim().toLowerCase();
if (q === '' || q === '*') {
return all.map((s) => ({ name: s.name, description: s.description }));
}
// name match weighted 2x description match. No fancy ranking — substring
// scoring is enough for ≤20 skills.
const scored = all
.map((s) => {
let score = 0;
if (s.name.toLowerCase().includes(q)) score += 2;
if (s.description.toLowerCase().includes(q)) score += 1;
return { s, score };
})
.filter((x) => x.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 5);
return scored.map(({ s }) => ({ name: s.name, description: s.description }));
}
// Returns the SKILL.md body with frontmatter stripped, or null if the skill
// is unknown. Single-entry mtime refresh: a hot edit shows up on next call.
export async function getSkillBody(name: string): Promise<string | null> {
await ensureCache();
const cached = cache.get(name);
if (!cached) return null;
let stat;
try {
stat = await fs.stat(join(cached.path, 'SKILL.md'));
} catch {
cache.delete(name);
return null;
}
if (stat.mtimeMs === cached.mtime) return cached.body;
try {
const raw = await fs.readFile(join(cached.path, 'SKILL.md'), 'utf8');
const parsed = parseSkillFile(raw);
if (parsed.name !== name) {
// Skill renamed itself; drop the stale entry. Next listSkills() walks.
cache.delete(name);
return null;
}
cached.body = parsed.body;
cached.description = parsed.description;
cached.mtime = stat.mtimeMs;
return cached.body;
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
console.warn(`skills: re-parse failed for ${name}${reason}`);
cache.delete(name);
return null;
}
}
export type SkillResourceErrorCode = 'unknown_skill' | 'unknown_resource' | 'path_escape';
export type SkillResourceResult =
| { ok: true; content: string }
| { ok: false; code: SkillResourceErrorCode; message: string };
export async function getSkillResource(
name: string,
relativePath: string,
): Promise<SkillResourceResult> {
await ensureCache();
const cached = cache.get(name);
if (!cached) {
return { ok: false, code: 'unknown_skill', message: `unknown skill: ${name}` };
}
if (typeof relativePath !== 'string' || relativePath.trim() === '') {
return { ok: false, code: 'unknown_resource', message: 'path is required' };
}
// Syntactic pre-check — catches the common "../../etc/passwd" attempt
// before realpath dereferences any symlinks.
if (isAbsolute(relativePath) || relativePath.split(/[\\/]/).some((seg) => seg === '..')) {
return { ok: false, code: 'path_escape', message: `path escapes skill folder: ${relativePath}` };
}
// SKILL.md is the manifest — skill_use is the right tool to read it.
if (basename(relativePath) === 'SKILL.md') {
return { ok: false, code: 'unknown_resource', message: 'use skill_use to read SKILL.md' };
}
let real: string;
try {
real = await pathGuard(cached.path, relativePath);
} catch (err) {
if (err instanceof PathScopeError) {
const code: SkillResourceErrorCode = err.message.includes('escapes')
? 'path_escape'
: 'unknown_resource';
return { ok: false, code, message: err.message };
}
throw err;
}
const stat = await fs.stat(real);
if (!stat.isFile()) {
return { ok: false, code: 'unknown_resource', message: 'not a file' };
}
if (stat.size > MAX_RESOURCE_BYTES) {
return {
ok: false,
code: 'unknown_resource',
message: `file too large (${stat.size} bytes, max ${MAX_RESOURCE_BYTES})`,
};
}
const content = await fs.readFile(real, 'utf8');
return { ok: true, content };
}

View File

@@ -3,6 +3,8 @@ import { resolve, basename, relative } from 'node:path';
import { z } from 'zod';
import { pathGuard, PathScopeError } from './path_guard.js';
import { grep as fileOpsGrep, findFiles as fileOpsFindFiles } from './file_ops.js';
import { getGitMeta } from './git_meta.js';
import { findSkills, getSkillBody, getSkillResource } from './skills.js';
const MAX_FILE_BYTES = 5 * 1024 * 1024;
const DEFAULT_VIEW_LINES = 200;
@@ -266,13 +268,250 @@ export const findFiles: ToolDef<FindFilesInputT> = {
},
};
// v1.8 Level 1 branch awareness: gives the model a read-only view of the
// project's git state. No path input — operates on the inference-resolved
// project root via getGitMeta. Subprocess runs with a 2s timeout (see git_meta).
const GitStatusInput = z.object({}).strict();
type GitStatusInputT = z.infer<typeof GitStatusInput>;
export const gitStatus: ToolDef<GitStatusInputT> = {
name: 'git_status',
description:
"Returns the current git branch, whether the working tree is dirty, and ahead/behind counts vs upstream. Read-only. Use when you need to know which branch the user is currently working on.",
inputSchema: GitStatusInput,
jsonSchema: {
type: 'function',
function: {
name: 'git_status',
description:
'Returns the current git branch, dirty flag, and ahead/behind counts vs upstream. Read-only.',
parameters: {
type: 'object',
properties: {},
additionalProperties: false,
},
},
},
async execute(_input, projectRoot) {
const meta = await getGitMeta(projectRoot);
if (meta === null) {
return { repo: false, branch: null, is_dirty: false, ahead: 0, behind: 0 };
}
return { repo: true, ...meta };
},
};
// Batch 9.6: skill_find, skill_use, skill_resource. Lazy-loaded markdown
// playbooks at /data/skills/. Three tools rather than one to keep each call
// cheap — the model lists, then loads, then optionally pulls support files.
const SkillFindInput = z.object({
query: z.string().optional(),
});
type SkillFindInputT = z.infer<typeof SkillFindInput>;
export const skillFind: ToolDef<SkillFindInputT> = {
name: 'skill_find',
description:
'Find skills (markdown playbooks under /data/skills) by name or description. Returns up to 5 matches. Empty query or "*" returns all available skills. Call this first to discover what skills are available.',
inputSchema: SkillFindInput,
jsonSchema: {
type: 'function',
function: {
name: 'skill_find',
description:
'Find skills by name or description. Returns up to 5 matches. Empty or "*" returns all.',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: 'substring matched against skill name and description' },
},
additionalProperties: false,
},
},
},
async execute(input) {
return await findSkills(input.query ?? '');
},
};
const SkillUseInput = z.object({
name: z.string().min(1),
});
type SkillUseInputT = z.infer<typeof SkillUseInput>;
export const skillUse: ToolDef<SkillUseInputT> = {
name: 'skill_use',
description:
"Load the full body of a skill's SKILL.md by name. Returns the markdown playbook to follow. Discover names via skill_find. Errors: unknown_skill.",
inputSchema: SkillUseInput,
jsonSchema: {
type: 'function',
function: {
name: 'skill_use',
description: "Load the full body of a skill's SKILL.md by name.",
parameters: {
type: 'object',
properties: {
name: { type: 'string', description: 'skill name from skill_find' },
},
required: ['name'],
additionalProperties: false,
},
},
},
async execute(input) {
const body = await getSkillBody(input.name);
if (body === null) {
return { error: 'unknown_skill', message: `unknown skill: ${input.name}` };
}
return { body };
},
};
const SkillResourceInput = z.object({
name: z.string().min(1),
path: z.string().min(1),
});
type SkillResourceInputT = z.infer<typeof SkillResourceInput>;
export const skillResource: ToolDef<SkillResourceInputT> = {
name: 'skill_resource',
description:
"Read a support file inside a skill's folder (e.g. references/root-cause-tracing.md). Path is relative to the skill folder. Use skill_use to read SKILL.md itself. Errors: unknown_skill, unknown_resource, path_escape.",
inputSchema: SkillResourceInput,
jsonSchema: {
type: 'function',
function: {
name: 'skill_resource',
description: "Read a support file inside a skill's folder. Path is relative to the skill folder.",
parameters: {
type: 'object',
properties: {
name: { type: 'string', description: 'skill name' },
path: { type: 'string', description: 'relative path under the skill folder' },
},
required: ['name', 'path'],
additionalProperties: false,
},
},
},
async execute(input) {
const result = await getSkillResource(input.name, input.path);
if (!result.ok) {
return { error: result.code, message: result.message };
}
return { content: result.content };
},
};
// Batch 9.7: ask_user_input. Interactive elicitation. The model emits a tool
// call with 1-3 structured questions; the inference loop PAUSES (does not
// execute the tool server-side, does not recurse) and waits for the frontend
// to POST /api/chats/:id/answer_user_input with the user's selections. See
// routes/messages.ts for the resume path and services/inference.ts for the
// pause branch in executeToolPhase.
const AskUserInputInput = z.object({
questions: z
.array(
z.object({
question: z.string().min(1).max(200),
type: z.enum(['single_select', 'multi_select']),
options: z.array(z.string().min(1).max(80)).min(2).max(6),
}),
)
.min(1)
.max(3),
});
type AskUserInputInputT = z.infer<typeof AskUserInputInput>;
export const askUserInput: ToolDef<AskUserInputInputT> = {
name: 'ask_user_input',
description:
"Ask the user 1-3 structured questions through an inline picker UI. Use when you genuinely need a choice the user must make (e.g. scope, options, preferences) before continuing. Each question has 2-6 options and accepts free-text answers in addition. The tool call pauses the conversation until the user submits — the next assistant turn sees their answers as the tool result. Do not use for trivial yes/no clarifications you could infer; prefer it over multi-paragraph speculation about what the user might want.",
inputSchema: AskUserInputInput,
jsonSchema: {
type: 'function',
function: {
name: 'ask_user_input',
description:
'Ask the user 1-3 structured questions through an inline picker. Pauses the conversation until the user answers; the next turn sees their selections.',
parameters: {
type: 'object',
properties: {
questions: {
type: 'array',
minItems: 1,
maxItems: 3,
items: {
type: 'object',
properties: {
question: { type: 'string', description: '<=200 chars, shown to the user' },
type: {
type: 'string',
enum: ['single_select', 'multi_select'],
description: 'single_select = at most one option; multi_select = any subset',
},
options: {
type: 'array',
minItems: 2,
maxItems: 6,
items: { type: 'string' },
description: '2-6 strings, each <=80 chars; free-text input is always available alongside',
},
},
required: ['question', 'type', 'options'],
additionalProperties: false,
},
},
},
required: ['questions'],
additionalProperties: false,
},
},
},
// Server-side no-op. The "execution" of ask_user_input is the user's
// response, captured client-side and posted to /api/chats/:id/answer_user_input.
// The inference loop detects this tool by name and pauses before reaching
// executeToolCall — this fallback only runs if something bypasses that
// branch, in which case the pending sentinel matches the pause-path shape.
async execute(input) {
return { _pending: true, questions: input.questions };
},
};
export const ALL_TOOLS: ReadonlyArray<ToolDef<unknown>> = [
viewFile as ToolDef<unknown>,
listDir as ToolDef<unknown>,
grep as ToolDef<unknown>,
findFiles as ToolDef<unknown>,
gitStatus as ToolDef<unknown>,
skillFind as ToolDef<unknown>,
skillUse as ToolDef<unknown>,
skillResource as ToolDef<unknown>,
askUserInput as ToolDef<unknown>,
];
// v1.8.2: forward-compatible read-only whitelist. An agent whose `tools` is
// fully contained in this set gets a generous default tool budget (30);
// anything outside means the agent can mutate state and gets a tighter
// default (10). Every tool in v1.8.2 happens to be read-only, so the
// non-RO branch only takes effect once BooCoder lands write tools.
// Batch 9.6: skill_* added; all still read-only.
// Batch 9.7: ask_user_input added — it pauses execution but doesn't mutate
// project state, so it belongs in the read-only set for budget purposes.
export const READ_ONLY_TOOL_NAMES = [
'view_file',
'list_dir',
'grep',
'find_files',
'git_status',
'skill_find',
'skill_use',
'skill_resource',
'ask_user_input',
] as const;
export const TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries(
ALL_TOOLS.map((t) => [t.name, t])
);

View File

@@ -10,6 +10,12 @@ export interface Project {
last_session_id: string | null;
status: ProjectStatus;
gitea_remote: string | null;
// v1.9: per-project defaults inherited by new sessions. Empty string on
// default_system_prompt means "no override" — the model gets the base
// BooCode system prompt only. default_web_search_enabled is the inherited
// value for sessions where web_search_enabled is null.
default_system_prompt: string;
default_web_search_enabled: boolean;
}
export interface AvailableProject {
@@ -28,6 +34,44 @@ export interface Session {
status: SessionStatus;
created_at: string;
updated_at: string;
agent_id: string | null;
// v1.9: per-session override for web_search. null = inherit from
// project.default_web_search_enabled. Plumbed but inert in v1.9 — the
// actual web_search tool ships in Batch 8.
web_search_enabled: boolean | null;
}
// v1.8.1: agents come from two sources. 'global' = /data/AGENTS.md (always
// loaded inside the container), 'project' = per-project override at
// <root>/AGENTS.md. Project entries override global by name (case-sensitive).
export type AgentSource = 'global' | 'project';
export interface Agent {
id: string; // slug of name; stable handle stored in sessions.agent_id
name: string;
description: string;
system_prompt: string;
temperature: number;
tools: string[]; // whitelist of tool names; empty = no tools allowed
model: string | null; // null means "session.model wins"
source: AgentSource;
// v1.8.2: per-agent tool-loop budget. null means resolve at runtime from the
// agent's toolset (30 if all tools are read-only, 10 otherwise) or 15 for
// raw chat with no agent.
max_tool_calls: number | null;
}
// One entry per malformed `## Name` block. Per-block errors don't fail the
// whole file — the loader returns parsed-successfully agents AND the list of
// skipped ones so the UI can show a non-blocking warning chip.
export interface AgentParseError {
agent_name: string;
reason: string;
}
export interface AgentsResponse {
agents: Agent[];
errors: AgentParseError[];
}
// KEEP IN SYNC: apps/server/src/schema.sql chats_status_chk
@@ -70,6 +114,31 @@ export interface ToolResult {
error?: string;
}
// v1.8.2: structured reason codes for failed inferences. `error` carries the
// human text; `reason` is the machine-readable discriminator the UI matches
// on (with `error` as fallback when reason is absent or unrecognized).
export type ErrorReason =
| 'llm_provider_error'
| 'tool_execution_failed'
| 'summary_after_cap_failed';
// v1.8.2: shapes stored in messages.metadata. Discriminated on `kind`.
// cap_hit — system sentinel emitted when tool budget is exhausted
// error — attached to a failed assistant message so UI can show reason
export type MessageMetadata =
| {
kind: 'cap_hit';
used: number;
limit: number;
agent_name: string | null;
can_continue: boolean;
}
| {
kind: 'error';
error_reason: ErrorReason;
error_text: string;
};
export interface Message {
id: string;
session_id: string;
@@ -87,6 +156,15 @@ export interface Message {
started_at: string | null;
finished_at: string | null;
created_at: string;
// v1.8.2: per-message metadata. See MessageMetadata for the discriminated
// shapes currently in use.
metadata: MessageMetadata | null;
// v1.11: anchored rolling compaction. Optional so consumers that SELECT
// the pre-v1.11 column set still type-check. See compaction.ts +
// schema.sql for semantics.
summary?: boolean;
tail_start_id?: string | null;
compacted_at?: string | null;
}
export interface ModelInfo {
@@ -225,6 +303,17 @@ export interface ProjectUpdatedFrame {
project_id: string;
name: string;
}
// v1.8 mobile-tabs: server can't know about client-side panes, so status
// is keyed by chat_id. Frontend dot derives pane status from pane.activeChatId.
// v1.8.2: optional `reason` carries a machine-readable code when status is
// 'error'. UI prefers reason; falls back to no detail when absent.
export interface ChatStatusFrame {
type: 'chat_status';
chat_id: string;
status: 'working' | 'idle' | 'error';
at: string;
reason?: ErrorReason;
}
export type UserStreamFrame =
| ProjectCreatedFrame
| ProjectDeletedFrame
@@ -240,4 +329,5 @@ export type UserStreamFrame =
| ChatDeletedFrame
| ProjectArchivedFrame
| ProjectUnarchivedFrame
| ProjectUpdatedFrame;
| ProjectUpdatedFrame
| ChatStatusFrame;

View File

@@ -4,8 +4,31 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BooCode</title>
<script>
// themes-v1 FOUC guard: read the last-applied theme from localStorage
// and stamp the class on <html> before React mounts. Falls back to
// obsidian + dark when no cache. Light-only themes (ivory, chalk) with
// a dark mode pref fall back to obsidian dark — mirrors the rule in
// lib/theme.ts effectiveThemeId().
(function () {
try {
var t = JSON.parse(localStorage.getItem('boocode.theme') || '{}');
var id = t.id || 'obsidian';
var mode = t.mode || 'dark';
if (mode === 'system') {
mode = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
if ((id === 'ivory' || id === 'chalk') && mode === 'dark') {
id = 'obsidian';
}
document.documentElement.className = 'theme-' + id + (mode === 'dark' ? ' dark' : '');
} catch (e) {
document.documentElement.className = 'theme-obsidian dark';
}
})();
</script>
</head>
<body class="bg-neutral-950 text-neutral-100">
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>

View File

@@ -12,6 +12,11 @@
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@xterm/addon-fit": "0.10.0",
"@xterm/addon-search": "^0.15.0",
"@xterm/addon-web-links": "0.11.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "5.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.16.0",

View File

@@ -6,8 +6,10 @@ import { RightRail } from '@/components/RightRail';
import { Home } from '@/pages/Home';
import { Project } from '@/pages/Project';
import { Session } from '@/pages/Session';
import { Settings } from '@/pages/Settings';
import { Toaster } from '@/components/ui/sonner';
import { useUserEvents } from '@/hooks/useUserEvents';
import { useTheme } from '@/lib/theme';
import { SidebarDrawerProvider, useSidebarDrawer } from '@/hooks/useSidebarDrawer';
import { RightRailDrawerProvider, useRightRailDrawer } from '@/hooks/useRightRailDrawer';
import { useViewport } from '@/hooks/useViewport';
@@ -61,9 +63,18 @@ function MobileRightRailBackdrop() {
}
function AppShell() {
// themes-v1: useTheme() owns the matchMedia subscription for system mode
// and reconciles cache with /api/settings on mount. Mounted first so the
// theme class on <html> is correct before any child renders.
useTheme();
useUserEvents();
// v1.10.8c: h-dvh (dynamic viewport) instead of h-screen (100vh) so the
// root height excludes the iOS URL-bar overlay area. Without this, every
// descendant — including the terminal pane — measures itself against a
// height that extends behind the URL bar, and xterm allocates extra rows
// that scroll out of reach on iPhone.
return (
<div className="dark h-screen flex bg-background text-foreground">
<div className="h-dvh flex bg-background text-foreground">
<ProjectSidebar />
<MobileBackdrop />
<main className="flex-1 flex flex-col min-w-0">
@@ -71,6 +82,7 @@ function AppShell() {
<Route path="/" element={<Home />} />
<Route path="/project/:id" element={<Project />} />
<Route path="/session/:id" element={<Session />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</main>
<MobileRightRailBackdrop />

View File

@@ -8,6 +8,10 @@ import type {
SidebarResponse,
ListDirResult,
ViewFileResult,
AgentsResponse,
GitMeta,
Skill,
AskUserAnswer,
} from './types';
export class ApiError extends Error {
@@ -49,15 +53,29 @@ export const api = {
method: 'POST',
body: JSON.stringify(body),
}),
update: (id: string, body: { name: string }) =>
update: (
id: string,
body: Partial<Pick<Project, 'name' | 'default_system_prompt' | 'default_web_search_enabled'>>,
) =>
request<Project>(`/api/projects/${id}`, {
method: 'PATCH',
body: JSON.stringify(body),
}),
get: (id: string) => request<Project>(`/api/projects/${id}`),
archive: (id: string) =>
request<void>(`/api/projects/${id}/archive`, { method: 'POST' }),
unarchive: (id: string) =>
request<Project>(`/api/projects/${id}/unarchive`, { method: 'POST' }),
// v1.9: bulk-archive every open session in this project. Server publishes
// one session_archived frame per affected id, so the sidebar reducer
// updates incrementally rather than waiting for a refetch.
archiveAllSessions: (id: string) =>
request<{ archived: number; ids: string[] }>(
`/api/projects/${id}/sessions/archive-all`,
{ method: 'POST' },
),
openSessionsCount: (id: string) =>
request<{ count: number }>(`/api/projects/${id}/sessions/open-count`),
create: (body: {
name: string;
commit_message?: string;
@@ -86,6 +104,8 @@ export const api = {
request<ViewFileResult>(`/api/projects/${id}/view_file?path=${encodeURIComponent(path)}`),
files: (id: string) =>
request<{ files: string[] }>(`/api/projects/${id}/files`),
git: (id: string) =>
request<GitMeta>(`/api/projects/${id}/git`),
},
sessions: {
@@ -93,7 +113,7 @@ export const api = {
request<Session[]>(`/api/projects/${projectId}/sessions${status ? `?status=${status}` : ''}`),
create: (
projectId: string,
body: { name?: string; model?: string; system_prompt?: string }
body: { name?: string; model?: string; system_prompt?: string; agent_id?: string | null }
) =>
request<Session>(`/api/projects/${projectId}/sessions`, {
method: 'POST',
@@ -102,7 +122,7 @@ export const api = {
get: (id: string) => request<Session>(`/api/sessions/${id}`),
update: (
id: string,
body: Partial<Pick<Session, 'name' | 'model' | 'system_prompt'>>
body: Partial<Pick<Session, 'name' | 'model' | 'system_prompt' | 'agent_id' | 'web_search_enabled'>>
) =>
request<Session>(`/api/sessions/${id}`, {
method: 'PATCH',
@@ -114,6 +134,15 @@ export const api = {
request<void>(`/api/sessions/${id}/archive`, { method: 'POST' }),
unarchive: (id: string) =>
request<Session>(`/api/sessions/${id}/unarchive`, { method: 'POST' }),
// v1.9: bulk-archive every open chat in this session. Same pattern as
// archiveAllSessions — server publishes one chat_archived per id.
archiveAllChats: (id: string) =>
request<{ archived: number; ids: string[] }>(
`/api/sessions/${id}/chats/archive-all`,
{ method: 'POST' },
),
openChatsCount: (id: string) =>
request<{ count: number }>(`/api/sessions/${id}/chats/open-count`),
},
chats: {
@@ -139,8 +168,11 @@ export const api = {
request<void>(`/api/chats/${chatId}`, { method: 'DELETE' }),
messages: (chatId: string) =>
request<Message[]>(`/api/chats/${chatId}/messages`),
// v1.11: anchored-rolling compaction. POST awaits the LLM call inside
// the route's lifecycle; the new summary row arrives via the 'compacted'
// WS frame (useSessionStream refetches + toasts).
compact: (chatId: string) =>
request<{ compact_message_id: string }>(`/api/chats/${chatId}/compact`, { method: 'POST' }),
request<{ ok: true }>(`/api/chats/${chatId}/compact`, { method: 'POST' }),
stop: (chatId: string) =>
request<{ stopped: boolean }>(`/api/chats/${chatId}/stop`, { method: 'POST' }),
forceSend: (chatId: string, content: string) =>
@@ -148,11 +180,43 @@ export const api = {
`/api/chats/${chatId}/force_send`,
{ method: 'POST', body: JSON.stringify({ content }) }
),
// v1.8.2: extend an inference that hit the tool budget. `sentinelMessageId`
// is the cap-hit sentinel message the user clicked Continue on.
continue: (chatId: string, sentinelMessageId: string) =>
request<{ assistant_message_id: string }>(
`/api/chats/${chatId}/continue`,
{ method: 'POST', body: JSON.stringify({ sentinel_message_id: sentinelMessageId }) }
),
fork: (chatId: string, body: { messageId: string; name?: string }) =>
request<Chat>(`/api/chats/${chatId}/fork`, {
method: 'POST',
body: JSON.stringify({ message_id: body.messageId, name: body.name }),
}),
// Batch 9.6: slash-command invocation. Server loads the skill body
// authoritatively (client doesn't get to forge file contents), persists
// a synthetic skill_use tool_use + tool_result + user message + streaming
// assistant, and enqueues inference. Returns all 4 new message IDs.
skillInvoke: (chatId: string, skillName: string, userMessage: string | null) =>
request<{
synth_assistant_id: string;
tool_message_id: string;
user_message_id: string;
assistant_message_id: string;
}>(`/api/chats/${chatId}/skill_invoke`, {
method: 'POST',
body: JSON.stringify({ skill_name: skillName, user_message: userMessage }),
}),
// Batch 9.7: submit answers for a paused ask_user_input call. Server
// validates against the question shape, UPDATEs the pending tool row,
// publishes the deferred tool_result frame, and enqueues the next turn.
answerUserInput: (chatId: string, toolCallId: string, answers: AskUserAnswer[]) =>
request<{ tool_message_id: string; assistant_message_id: string }>(
`/api/chats/${chatId}/answer_user_input`,
{
method: 'POST',
body: JSON.stringify({ tool_call_id: toolCallId, answers }),
},
),
},
messages: {
@@ -179,6 +243,15 @@ export const api = {
models: () => request<ModelInfo[]>('/api/models'),
agents: {
list: (projectId: string) =>
request<AgentsResponse>(`/api/projects/${projectId}/agents`),
},
skills: {
list: () => request<{ skills: Skill[] }>('/api/skills'),
},
settings: {
get: () => request<Record<string, unknown>>('/api/settings'),
patch: (body: Record<string, unknown>) =>
@@ -191,4 +264,31 @@ export const api = {
sidebar: {
get: () => request<SidebarResponse>('/api/sidebar'),
},
// v1.10 booterm: REST control plane for terminal panes. WebSocket attach
// lives at /ws/term/sessions/:sid/panes/:pid (handled directly by
// TerminalPane). v1.10.8c: resize moved in-band onto the WebSocket as a
// `{type:"resize",cols,rows}` text frame — the old /resize HTTP endpoint is
// gone, eliminating the race between WS attach and PTY-map registration.
terminals: {
// cols/rows are optional. When passed, booterm sizes the per-pane tmux
// session at creation time so the inner bash (and any TUI it spawns) is
// born with the correct PTY dimensions instead of tmux's 80x24 default.
start: (sessionId: string, paneId: string, cols?: number, rows?: number) =>
request<{ tmux_session: string }>(
`/api/term/sessions/${sessionId}/panes/${paneId}/start`,
{
method: 'POST',
body:
cols !== undefined && rows !== undefined
? JSON.stringify({ cols, rows })
: undefined,
},
),
kill: (sessionId: string, paneId: string) =>
request<{ ok: true }>(
`/api/term/sessions/${sessionId}/panes/${paneId}/kill`,
{ method: 'POST' },
),
},
};

View File

@@ -9,6 +9,10 @@ export interface Project {
last_session_id: string | null;
status: ProjectStatus;
gitea_remote: string | null;
// v1.9: per-project defaults. Empty string on default_system_prompt means
// "no override" — inference falls through to the base system prompt.
default_system_prompt: string;
default_web_search_enabled: boolean;
}
export interface AvailableProject {
@@ -27,6 +31,39 @@ export interface Session {
status: SessionStatus;
created_at: string;
updated_at: string;
agent_id: string | null;
// v1.9: null = inherit from project.default_web_search_enabled.
web_search_enabled: boolean | null;
}
// v1.8.1: 'global' = /data/AGENTS.md (always-on), 'project' = per-project
// override at <root>/AGENTS.md. In-code builtins were retired; the seed file
// lives at /data/AGENTS.md.
export type AgentSource = 'global' | 'project';
export interface Agent {
id: string;
name: string;
description: string;
system_prompt: string;
temperature: number;
tools: string[];
model: string | null;
source: AgentSource;
// v1.8.2: per-agent tool-loop budget. null means resolve at runtime from
// the agent's toolset (30 for all read-only, 10 otherwise) or 15 for raw
// chat with no agent.
max_tool_calls: number | null;
}
export interface AgentParseError {
agent_name: string;
reason: string;
}
export interface AgentsResponse {
agents: Agent[];
errors: AgentParseError[];
}
export const CHAT_STATUSES = ['open', 'archived'] as const;
@@ -62,6 +99,32 @@ export interface ToolResult {
error?: string;
}
// v1.8.2: structured reason codes that flow through error frames / metadata.
// `error` text stays human; `reason` is the discriminator the UI matches on.
export type ErrorReason =
| 'llm_provider_error'
| 'tool_execution_failed'
| 'summary_after_cap_failed';
// v1.8.2: shapes stored in Message.metadata. Discriminated on `kind`.
// cap_hit — sentinel emitted when the tool budget is hit; carries the
// budget + agent name + whether Continue is still allowed.
// error — attached to a failed assistant message so the bubble can show
// a specific reason on reload (WS error frame is one-shot).
export type MessageMetadata =
| {
kind: 'cap_hit';
used: number;
limit: number;
agent_name: string | null;
can_continue: boolean;
}
| {
kind: 'error';
error_reason: ErrorReason;
error_text: string;
};
export interface Message {
id: string;
session_id: string;
@@ -79,6 +142,22 @@ export interface Message {
started_at: string | null;
finished_at: string | null;
created_at: string;
// v1.8.2: per-message metadata; see MessageMetadata. null for the vast
// majority of messages.
metadata: MessageMetadata | null;
// v1.11: anchored rolling compaction fields. Optional on the wire so that
// older API responses (or test fixtures) parse without explicit nulls.
// summary — true on the assistant row that holds the active
// anchored summary. Render via SummaryCard.
// tail_start_id — first preserved tail message the summary covers up to
// (exclusive). Diagnostic only on the client.
// compacted_at — set on rows that are "behind the curtain" of the
// current summary. Returned by the GET endpoint so the
// UI can show history, but the server-side inference
// assembly filters these out.
summary?: boolean;
tail_start_id?: string | null;
compacted_at?: string | null;
}
export interface ModelInfo {
@@ -156,7 +235,50 @@ export interface PaneUpdateRequest {
position?: number;
}
export type WorkspacePaneKind = 'chat' | 'terminal' | 'agent' | 'empty';
// v1.8 mobile-tabs: shape returned by GET /api/projects/:id/git. Mirrors
// services/git_meta.ts on the server. branch=null means "not a git repo".
export interface GitMeta {
branch: string | null;
is_dirty: boolean;
ahead: number;
behind: number;
}
// Batch 9.6: skill catalog row. Returned by GET /api/skills and consumed by
// the slash-command dropdown. `path` and `mtime` are exposed for debug surface
// (/api/skills) but the dropdown only renders name + description.
export interface Skill {
name: string;
description: string;
path: string;
mtime: number;
}
// Batch 9.7: ask_user_input shapes. The tool_call.args is { questions: AskUserQuestion[] }
// (1-3 entries); the eventual tool_result.output is { answers: AskUserAnswer[] } in the
// same order. AskUserInputCard renders questions and POSTs answers.
export type AskUserQuestionType = 'single_select' | 'multi_select';
export interface AskUserQuestion {
question: string;
type: AskUserQuestionType;
options: string[];
}
export interface AskUserAnswer {
question: string;
selected_options: string[];
free_text: string | null;
}
export interface AskUserAnswerSet {
answers: AskUserAnswer[];
}
// v1.9: 'settings' is an ephemeral pane kind — never persisted, always
// singleton per workspace. The pane hook filters it out before writing to
// localStorage and dedupes on insertion via toggleSettingsPane().
export type WorkspacePaneKind = 'chat' | 'terminal' | 'agent' | 'empty' | 'settings';
export interface WorkspacePane {
id: string;
@@ -189,7 +311,18 @@ export type WsFrame =
ctx_max?: number | null;
started_at?: string | null;
finished_at?: string | null;
// v1.8.2: piggybacks the persisted metadata onto the terminal frame so
// cap-hit sentinels (and any future stamped-on-complete metadata) flow
// to the client without a refetch.
metadata?: MessageMetadata | null;
}
| { type: 'messages_deleted'; message_ids: string[]; chat_id?: string }
| { type: 'chat_renamed'; chat_id: string; name: string }
| { type: 'error'; message_id?: string; chat_id?: string; error: string };
// v1.11: published by services/compaction.ts after the new anchored
// summary row lands. Carries the new summary row id for diagnostics; the
// session-stream handler ignores the id and re-fetches the full message
// list (the cohort of compacted_at-stamped rows changed too).
| { type: 'compacted'; session_id: string; chat_id: string; summary_message_id: string }
// v1.8.2: `reason` discriminates structured failures (the UI prefers it
// over `error` text when present).
| { type: 'error'; message_id?: string; chat_id?: string; error: string; reason?: ErrorReason };

View File

@@ -0,0 +1,121 @@
import { useEffect, useState } from 'react';
import { Check, ChevronDown } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import type { Agent, AgentParseError } from '@/api/types';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
interface Props {
projectId: string;
value: string | null;
onChange: (agentId: string | null) => void | Promise<void>;
}
export function AgentPicker({ projectId, value, onChange }: Props) {
const [agents, setAgents] = useState<Agent[] | null>(null);
const [parseErrors, setParseErrors] = useState<AgentParseError[]>([]);
const [error, setError] = useState<string | null>(null);
const [open, setOpen] = useState(false);
// v1.8.1: per-agent parse errors are non-blocking. Silent if any agents
// loaded successfully; a gray warning toast fires only when EVERY agent
// in AGENTS.md failed to parse. Server logs a console.warn either way.
useEffect(() => {
let cancelled = false;
setAgents(null);
setParseErrors([]);
setError(null);
api.agents
.list(projectId)
.then((res) => {
if (cancelled) return;
setAgents(res.agents);
setParseErrors(res.errors);
if (res.errors.length > 0 && res.agents.length === 0) {
toast.warning(
`AGENTS.md: ${res.errors.length} agent${res.errors.length === 1 ? '' : 's'} failed to parse, none loaded`,
);
}
})
.catch((err) => {
if (cancelled) return;
setError(err instanceof Error ? err.message : 'failed to load agents');
});
return () => {
cancelled = true;
};
}, [projectId]);
const selectedAgent = agents?.find((a) => a.id === value) ?? null;
const triggerLabel = value === null
? 'No agent'
: selectedAgent?.name ?? value;
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<button
type="button"
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted/60"
title={selectedAgent?.description ?? undefined}
>
<span className="truncate max-w-[160px]">{triggerLabel}</span>
<ChevronDown className="size-3 opacity-70" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-80 overflow-y-auto w-72">
{error && (
<div className="px-2 py-1.5 text-xs text-destructive">{error}</div>
)}
{agents === null && !error && (
<div className="px-2 py-1.5 text-xs text-muted-foreground">Loading</div>
)}
{agents !== null && (
<>
<DropdownMenuItem
onSelect={() => void onChange(null)}
className="text-xs"
>
<Check className={`size-3 ${value === null ? 'opacity-100' : 'opacity-0'}`} />
<span className="font-medium">No agent</span>
</DropdownMenuItem>
{agents.length > 0 && <DropdownMenuSeparator />}
{agents.map((a) => (
<DropdownMenuItem
key={a.id}
onSelect={() => void onChange(a.id)}
className="text-xs flex-col items-start gap-0.5"
>
<div className="flex items-center gap-1.5">
<Check
className={`size-3 ${a.id === value ? 'opacity-100' : 'opacity-0'}`}
/>
<span className="font-medium">{a.name}</span>
</div>
{a.description && (
<span className="text-muted-foreground pl-[18px] truncate w-full">
{a.description}
</span>
)}
</DropdownMenuItem>
))}
{parseErrors.length > 0 && (
<div
className="px-2 py-1.5 mt-1 text-xs text-amber-500 border-t border-border"
title={parseErrors.map((e) => `${e.agent_name}: ${e.reason}`).join('\n')}
>
{parseErrors.length} agent{parseErrors.length === 1 ? '' : 's'} skipped
</div>
)}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,324 @@
import { useMemo, useState } from 'react';
import { Check } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Button } from '@/components/ui/button';
import type {
AskUserAnswer,
AskUserAnswerSet,
AskUserQuestion,
ToolCall,
ToolResult,
} from '@/api/types';
// Batch 9.7. Inline interactive picker. Renders inside MessageList in place of
// the standard ToolCallLine when the assistant emits an ask_user_input tool
// call. While the tool result is null (server pre-stamps a sentinel with
// output=null), shows the form; once the WS tool_result frame arrives with a
// real AnswerSet, flips to read-only review mode.
interface Props {
toolCall: ToolCall;
toolResult: ToolResult | null;
chatId: string;
}
function parseQuestions(raw: unknown): AskUserQuestion[] {
if (!raw || typeof raw !== 'object' || !('questions' in raw)) return [];
const arr = (raw as { questions: unknown }).questions;
if (!Array.isArray(arr)) return [];
const out: AskUserQuestion[] = [];
for (const item of arr) {
if (!item || typeof item !== 'object') continue;
const q = item as { question?: unknown; type?: unknown; options?: unknown };
if (typeof q.question !== 'string') continue;
if (q.type !== 'single_select' && q.type !== 'multi_select') continue;
if (!Array.isArray(q.options)) continue;
const opts = q.options.filter((o): o is string => typeof o === 'string');
if (opts.length < 2) continue;
out.push({ question: q.question, type: q.type, options: opts });
}
return out;
}
function parseAnswerSet(raw: unknown): AskUserAnswerSet | null {
if (!raw || typeof raw !== 'object' || !('answers' in raw)) return null;
const arr = (raw as { answers: unknown }).answers;
if (!Array.isArray(arr)) return null;
const answers: AskUserAnswer[] = [];
for (const item of arr) {
if (!item || typeof item !== 'object') continue;
const a = item as { question?: unknown; selected_options?: unknown; free_text?: unknown };
if (typeof a.question !== 'string') continue;
if (!Array.isArray(a.selected_options)) continue;
if (a.free_text !== null && typeof a.free_text !== 'string') continue;
const sel = a.selected_options.filter((s): s is string => typeof s === 'string');
answers.push({
question: a.question,
selected_options: sel,
free_text: (a.free_text as string | null) ?? null,
});
}
return { answers };
}
export function AskUserInputCard({ toolCall, toolResult, chatId }: Props) {
const questions = useMemo(() => parseQuestions(toolCall.args), [toolCall.args]);
if (questions.length === 0) {
return (
<div className="rounded border border-destructive/40 bg-destructive/10 text-xs px-3 py-2 text-destructive">
ask_user_input: malformed tool args
</div>
);
}
// Tool result with a non-null output means the answer is already submitted.
// The pending sentinel uses output=null, so this branch only triggers after
// the real WS tool_result frame lands.
const answered = toolResult && toolResult.output !== null;
if (answered) {
const answerSet = parseAnswerSet(toolResult!.output);
return <AnsweredView questions={questions} answers={answerSet} />;
}
return (
<PendingView questions={questions} toolCallId={toolCall.id} chatId={chatId} />
);
}
function PendingView({
questions,
toolCallId,
chatId,
}: {
questions: AskUserQuestion[];
toolCallId: string;
chatId: string;
}) {
// Per-question selections + free text. Selections are option arrays so the
// multi_select case is uniform; single_select just constrains to length 1.
const [selections, setSelections] = useState<string[][]>(() => questions.map(() => []));
const [freeTexts, setFreeTexts] = useState<string[]>(() => questions.map(() => ''));
const [submitting, setSubmitting] = useState(false);
const singleQuestion = questions.length === 1;
const anyFreeText = freeTexts.some((t) => t.trim().length > 0);
// Submit button shows when:
// - more than one question (always batched), OR
// - one question and the user has typed free text (committing it needs an
// explicit Submit so an accidental Tab/click doesn't lose it).
// For one question with no free text, clicking an option submits inline.
const showSubmitButton = !singleQuestion || anyFreeText;
// Every question must have at least one of (option, free text).
const allComplete = questions.every((_, i) => {
return selections[i]!.length > 0 || freeTexts[i]!.trim().length > 0;
});
function buildAnswers(): AskUserAnswer[] {
return questions.map((q, i) => {
const freeText = freeTexts[i]!.trim();
return {
question: q.question,
selected_options: selections[i]!,
free_text: freeText.length > 0 ? freeText : null,
};
});
}
async function submit(answers: AskUserAnswer[]) {
if (submitting) return;
setSubmitting(true);
try {
await api.chats.answerUserInput(chatId, toolCallId, answers);
// Card stays mounted; the incoming WS tool_result frame will flip it
// into AnsweredView via the parent prop change.
} catch (err) {
toast.error(err instanceof Error ? err.message : 'submit failed');
setSubmitting(false);
}
}
function pickSingle(qIdx: number, option: string) {
setSelections((prev) => prev.map((arr, i) => (i === qIdx ? [option] : arr)));
// Immediate submit for the single-question single-select shortcut. Only
// fires when no free text exists anywhere — once the user typed, the
// Submit button takes over so the typed text isn't silently dropped.
if (singleQuestion && !anyFreeText) {
const answers: AskUserAnswer[] = [
{
question: questions[0]!.question,
selected_options: [option],
free_text: null,
},
];
void submit(answers);
}
}
function toggleMulti(qIdx: number, option: string) {
setSelections((prev) =>
prev.map((arr, i) => {
if (i !== qIdx) return arr;
return arr.includes(option) ? arr.filter((o) => o !== option) : [...arr, option];
}),
);
}
function setFreeText(qIdx: number, value: string) {
setFreeTexts((prev) => prev.map((t, i) => (i === qIdx ? value : t)));
}
return (
<div className="rounded-lg border bg-muted/20 text-sm">
<div className="px-4 py-3 space-y-4">
{questions.map((q, i) => (
<div key={i} className="space-y-2">
{questions.length > 1 && (
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
Question {i + 1}
</div>
)}
<div className="font-medium leading-snug">{q.question}</div>
{q.type === 'single_select' ? (
<RadioGroup
value={selections[i]![0] ?? ''}
onValueChange={(v) => pickSingle(i, v)}
disabled={submitting}
className="gap-1.5"
>
{q.options.map((opt, j) => {
const id = `q${i}-opt${j}`;
return (
<label
key={j}
htmlFor={id}
className="flex items-start gap-2 text-sm leading-snug cursor-pointer rounded px-1 py-0.5 hover:bg-muted/40"
>
<RadioGroupItem id={id} value={opt} className="mt-0.5" />
<span>{opt}</span>
</label>
);
})}
</RadioGroup>
) : (
<div className="grid gap-1.5">
{q.options.map((opt, j) => {
const id = `q${i}-opt${j}`;
const checked = selections[i]!.includes(opt);
return (
<label
key={j}
htmlFor={id}
className="flex items-start gap-2 text-sm leading-snug cursor-pointer rounded px-1 py-0.5 hover:bg-muted/40"
>
<input
id={id}
type="checkbox"
checked={checked}
disabled={submitting}
onChange={() => toggleMulti(i, opt)}
className="mt-1 size-3.5 rounded border-input accent-primary"
/>
<span>{opt}</span>
</label>
);
})}
</div>
)}
<div className="pt-1 space-y-1">
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
Or type a custom answer
</div>
<input
type="text"
value={freeTexts[i]}
disabled={submitting}
placeholder="Free text…"
onChange={(e) => setFreeText(i, e.target.value)}
className="w-full rounded border border-input bg-background px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:opacity-60"
/>
</div>
</div>
))}
</div>
{showSubmitButton && (
<div className="flex justify-end gap-2 border-t px-4 py-2">
<Button
type="button"
size="sm"
disabled={!allComplete || submitting}
onClick={() => void submit(buildAnswers())}
>
{submitting ? 'Submitting…' : 'Submit'}
</Button>
</div>
)}
</div>
);
}
function AnsweredView({
questions,
answers,
}: {
questions: AskUserQuestion[];
answers: AskUserAnswerSet | null;
}) {
if (!answers) {
return (
<div className="rounded-lg border bg-muted/20 text-xs px-4 py-3 text-muted-foreground">
ask_user_input: answers unavailable
</div>
);
}
return (
<div className="rounded-lg border bg-muted/10 text-sm">
<div className="px-4 py-3 space-y-3">
{questions.map((q, i) => {
const a = answers.answers[i];
if (!a) return null;
return (
<div key={i} className="space-y-1.5">
{questions.length > 1 && (
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
Question {i + 1}
</div>
)}
<div className="font-medium leading-snug">{q.question}</div>
<div className="space-y-0.5">
{q.options.map((opt, j) => {
const selected = a.selected_options.includes(opt);
return (
<div
key={j}
className={
selected
? 'flex items-start gap-2 text-sm leading-snug text-foreground'
: 'flex items-start gap-2 text-sm leading-snug text-muted-foreground/60 line-through'
}
>
<span className="mt-0.5 size-3.5 shrink-0 inline-flex items-center justify-center">
{selected && <Check className="size-3 text-primary" />}
</span>
<span>{opt}</span>
</div>
);
})}
</div>
{a.free_text && (
<div className="rounded bg-background border px-2 py-1 text-xs font-mono whitespace-pre-wrap">
{a.free_text}
</div>
)}
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,92 @@
import { useEffect, useRef, useState, type ReactNode, type TouchEvent } from 'react';
import { cn } from '@/lib/utils';
interface Props {
open: boolean;
onClose: () => void;
children: ReactNode;
title?: string;
}
// Past this drag distance, release dismisses the sheet.
const SWIPE_DISMISS_THRESHOLD_PX = 80;
export function BottomSheet({ open, onClose, children, title }: Props) {
const [dragY, setDragY] = useState(0);
const startYRef = useRef<number | null>(null);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]);
useEffect(() => {
if (!open) {
setDragY(0);
startYRef.current = null;
}
}, [open]);
function onTouchStart(e: TouchEvent<HTMLDivElement>) {
const t = e.touches[0];
if (!t) return;
startYRef.current = t.clientY;
}
function onTouchMove(e: TouchEvent<HTMLDivElement>) {
const t = e.touches[0];
if (!t || startYRef.current === null) return;
const dy = t.clientY - startYRef.current;
// Clamp to downward drags so the sheet doesn't "rubber-band" up.
if (dy > 0) setDragY(dy);
}
function onTouchEnd() {
if (dragY > SWIPE_DISMISS_THRESHOLD_PX) {
onClose();
} else {
setDragY(0);
}
startYRef.current = null;
}
if (!open) return null;
return (
<>
<div
className="fixed inset-0 z-40 bg-black/40"
onClick={onClose}
aria-hidden="true"
/>
<div
role="dialog"
aria-modal="true"
className={cn(
'fixed inset-x-0 bottom-0 z-50 rounded-t-2xl border-t border-border bg-popover text-popover-foreground shadow-2xl',
'transition-transform duration-150 will-change-transform',
'max-h-[70vh] flex flex-col',
)}
style={{
transform: `translateY(${dragY}px)`,
paddingBottom: 'env(safe-area-inset-bottom)',
}}
>
<div
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
className="flex flex-col items-center pt-2 pb-1 select-none touch-none"
>
<div className="w-10 h-1 bg-muted-foreground/40 rounded-full" />
{title && (
<div className="mt-1 text-sm font-medium text-muted-foreground">{title}</div>
)}
</div>
<div className="flex-1 overflow-y-auto">{children}</div>
</div>
</>
);
}

View File

@@ -0,0 +1,90 @@
import { useState } from 'react';
import { AlertCircle } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import type { Message } from '@/api/types';
import { Button } from '@/components/ui/button';
interface Props {
message: Message;
// 1-indexed position among cap-hit sentinels in this chat. The first
// cap-hit is 1, second is 2, third is 3 (hard ceiling).
capHitPosition: number;
// Only the most recent sentinel shows the Continue button. Older ones
// render text-only — they've already been continued past.
isLatest: boolean;
}
// Hard ceiling = 3 cap-hits per chat ⇒ 2 continues max. Lives here in sync
// with insertCapHitSentinel's `canContinue = priorCount < 2` rule in
// services/inference.ts.
const MAX_CONTINUES = 2;
export function CapHitSentinel({ message, capHitPosition, isLatest }: Props) {
const meta = message.metadata;
// Defensive parse — if the row is somehow missing metadata we still render
// the bare text rather than crashing the chat.
const isCapHit =
meta !== null && typeof meta === 'object' && meta.kind === 'cap_hit';
const limit = isCapHit ? meta.limit : null;
const canContinue = isCapHit ? meta.can_continue : false;
const agentName = isCapHit ? meta.agent_name : null;
// `capHitPosition` is 1-indexed; `MAX_CONTINUES - (position - 1)` is the
// number of continues remaining including this one. Clamped to ≥0.
const remaining = Math.max(0, MAX_CONTINUES - (capHitPosition - 1));
const [continuing, setContinuing] = useState(false);
async function handleContinue() {
if (continuing || !canContinue || !isLatest) return;
setContinuing(true);
try {
await api.chats.continue(message.chat_id, message.id);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'continue failed');
} finally {
setContinuing(false);
}
}
// Tooltip wording from the v1.8.2 spec. Disabled state takes precedence —
// the spec text "Hard limit reached — start a new chat" matches what the
// server returns when canContinue is false.
const enabledTooltip = limit
? `Resumes with a fresh budget of ${limit} tool calls. ${remaining} continue${remaining === 1 ? '' : 's'} remaining on this chat.`
: undefined;
const disabledTooltip = 'Hard limit reached — start a new chat';
return (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 text-sm">
<div className="px-3 py-2 flex items-start gap-2">
<AlertCircle className="size-4 text-amber-500 shrink-0 mt-0.5" />
<div className="flex-1 min-w-0 space-y-1">
<div className="text-xs font-medium text-amber-700 dark:text-amber-300">
{isCapHit && limit !== null
? `Reached tool budget (${limit}/${limit})${agentName ? `${agentName}` : ''}.`
: 'Reached tool budget.'}
</div>
<div className="text-xs text-muted-foreground">
{message.content}
</div>
{isLatest && (
<div className="pt-1">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => void handleContinue()}
disabled={!canContinue || continuing}
title={canContinue ? enabledTooltip : disabledTooltip}
>
{continuing ? 'Continuing…' : 'Continue'}
</Button>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,42 +1,101 @@
import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from 'react';
import { Send } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type KeyboardEvent } from 'react';
import { Check, Plus, Send } from 'lucide-react';
import { toast } from 'sonner';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import { flattenToMessage, inferLanguage, type Attachment } from '@/lib/attachments';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
flattenToMessage,
inferLanguage,
looksBinary,
MAX_FILE_SIZE_BYTES,
PASTE_INLINE_MAX_LINES,
type Attachment,
} from '@/lib/attachments';
import { AttachmentChip } from '@/components/AttachmentChip';
import { AttachmentPreviewModal } from '@/components/AttachmentPreviewModal';
import { FileMentionPopover } from '@/components/FileMentionPopover';
import { DropOverlay } from '@/components/DropOverlay';
import { AgentPicker } from '@/components/AgentPicker';
import { SkillSlashCommand } from '@/components/SkillSlashCommand';
import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents';
import { chatInputsRegistry, sendToChat } from '@/lib/events';
import { useSkills } from '@/hooks/useSkills';
import { useViewport } from '@/hooks/useViewport';
const MAX_ATTACHMENTS = 10;
interface Props {
disabled?: boolean;
projectId: string;
// Batch 9: optional so callers that pre-date the agent picker still compile.
// When omitted, the toolbar row is hidden entirely.
agentId?: string | null;
onAgentChange?: (agentId: string | null) => void | Promise<void>;
// v1.9: when sessionId + webSearchEnabled are both provided, the + menu
// renders next to the AgentPicker with a single "Web search" toggle item.
// The check reflects the *stored* session value (not the effective one):
// null counts as unchecked. Clicking PATCHes session.web_search_enabled
// with the inverted boolean (null → true, true → false, false → true).
sessionId?: string;
webSearchEnabled?: boolean | null;
onSend: (content: string) => void | Promise<void>;
onForceSend?: (content: string) => void | Promise<void>;
// Batch 9.6: slash-command dispatch. When the input parses to a known skill,
// ChatInput calls this with the skill name + the post-name args (possibly
// empty). Callers wire this to api.chats.skillInvoke. Omitting the prop
// disables slash-command dispatch (input is sent as literal text).
onSlashCommand?: (skillName: string, userMessage: string) => void | Promise<void>;
// v1.10.4: send-to-chat reverse path. When chatId is provided, this input
// registers in chatInputsRegistry so the terminal floating menu can list
// it, and subscribes to sendToChat events scoped to this chatId. Receiving
// an event appends the text to the current draft (with a newline separator
// when non-empty) and focuses — no auto-send.
chatId?: string;
chatLabel?: string;
}
export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, onSlashCommand, chatId, chatLabel }: Props) {
const { isMobile } = useViewport();
const [value, setValue] = useState('');
const [busy, setBusy] = useState(false);
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [previewAttachment, setPreviewAttachment] = useState<Attachment | null>(null);
const [isDraggingOver, setIsDraggingOver] = useState(false);
const dropRootRef = useRef<HTMLDivElement | null>(null);
const pasteCounterRef = useRef(0);
const [mentionState, setMentionState] = useState<{
open: boolean;
query: string;
atIdx: number;
anchorRect: { top: number; left: number };
} | null>(null);
// Batch 9.6: slash-command dropdown. Opens when `/` is the first char of
// the input and stays open while the input is `/<word>` with no whitespace.
// Disabled entirely when the caller doesn't pass onSlashCommand.
const [slashState, setSlashState] = useState<{
query: string;
anchorRect: { top: number; left: number };
} | null>(null);
const { skills } = useSkills();
const skillsLookup = useMemo(() => {
const m = new Map<string, true>();
for (const s of skills) m.set(s.name, true);
return m;
}, [skills]);
const [fileIndex, setFileIndex] = useState<string[] | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
function addAttachment(a: Attachment) {
setAttachments(prev => {
if (prev.length >= 10) {
toast.error('Max 10 attachments per message');
if (prev.length >= MAX_ATTACHMENTS) {
toast.error(`Max ${MAX_ATTACHMENTS} attachments per message`);
return prev;
}
return [...prev, a];
@@ -56,6 +115,35 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
});
}, []);
// v1.10.4: register this input in the chat-input registry so the terminal
// pane's "Send to chat" menu can list it. Re-registers when chatLabel
// changes (e.g. rename) so the menu reflects the current name.
useEffect(() => {
if (!chatId) return;
return chatInputsRegistry.register(chatId, chatLabel ?? 'Chat', () => {
textareaRef.current?.focus();
});
}, [chatId, chatLabel]);
// v1.10.4: subscribe to send_to_chat events scoped by chatId. Appends the
// payload text to the current draft (with a newline separator if the
// draft is non-empty) and focuses the textarea. Does NOT auto-submit.
useEffect(() => {
if (!chatId) return;
return sendToChat.subscribe(({ chat_id, text }) => {
if (chat_id !== chatId) return;
setValue((prev) => (prev.length === 0 ? text : `${prev}\n${text}`));
requestAnimationFrame(() => {
const ta = textareaRef.current;
if (!ta) return;
ta.focus();
// Put caret at end so the user can keep typing immediately.
const end = ta.value.length;
ta.selectionStart = ta.selectionEnd = end;
});
});
}, [chatId]);
function removeAttachment(id: string) {
setAttachments(prev => prev.filter(a => a.id !== id));
}
@@ -64,6 +152,31 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
const text = value.trim();
if (!text && attachments.length === 0) return;
if (disabled || busy) return;
// Batch 9.6: slash-command dispatch. Only when no attachments and the
// input parses to a known skill. Falls through to onSend for unknown
// slash names (literal text) or when slash dispatch isn't wired.
if (onSlashCommand && attachments.length === 0 && text.startsWith('/')) {
const match = text.match(/^\/(\S+)\s*([\s\S]*)$/);
if (match && skillsLookup.has(match[1]!)) {
const skillName = match[1]!;
const args = (match[2] ?? '').trim();
setBusy(true);
try {
await onSlashCommand(skillName, args);
setValue('');
setAttachments([]);
setSlashState(null);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'skill invocation failed');
} finally {
setBusy(false);
}
return;
}
// Unknown skill name — fall through and send as literal text.
}
setBusy(true);
try {
const body = flattenToMessage(attachments, text);
@@ -77,6 +190,19 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
}
}
function handleSlashSelect(skillName: string) {
const next = `/${skillName} `;
setValue(next);
setSlashState(null);
requestAnimationFrame(() => {
const ta = textareaRef.current;
if (ta) {
ta.selectionStart = ta.selectionEnd = next.length;
ta.focus();
}
});
}
function getCaretCoords(textarea: HTMLTextAreaElement): { top: number; left: number } {
const mirror = document.createElement('div');
const style = window.getComputedStyle(textarea);
@@ -127,6 +253,23 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
const ta = e.target;
const pos = ta.selectionStart;
// Batch 9.6: slash-command trigger. Active while the input is a single
// slash-prefixed token with no whitespace (i.e. user is still typing the
// skill name). Hand off to args mode the moment a space appears or the
// slash leaves position 0.
if (onSlashCommand && /^\/[^\s]*$/.test(newValue)) {
const query = newValue.slice(1);
if (!slashState) {
const rect = ta.getBoundingClientRect();
setSlashState({ query, anchorRect: { top: rect.top, left: rect.left } });
} else if (slashState.query !== query) {
setSlashState({ ...slashState, query });
}
if (mentionState?.open) setMentionState(null);
return;
}
if (slashState) setSlashState(null);
// Check for @ trigger
if (pos > 0 && newValue[pos - 1] === '@') {
const charBefore = pos >= 2 ? newValue[pos - 2] : null;
@@ -185,8 +328,167 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
const closeMention = useCallback(() => setMentionState(null), []);
// ---- Drag & drop (F1 + F3 + F4) ----------------------------------------
// The drop zone is the outer ChatInput container (ref'd as dropRootRef).
// onDragLeave only clears the highlight when the cursor leaves the
// container, not when it crosses into a child element.
async function processDroppedFile(file: File) {
// Size gate
if (file.size > MAX_FILE_SIZE_BYTES) {
const mb = (file.size / (1024 * 1024)).toFixed(1);
toast.error(`File ${file.name} is too large (${mb} MB). Limit is 5 MB.`);
return;
}
// Read once as ArrayBuffer so we can do byte-level binary detection
// before deciding whether to decode as text.
let buf: ArrayBuffer;
try {
buf = await file.arrayBuffer();
} catch (err) {
toast.error(`Failed to read ${file.name}: ${err instanceof Error ? err.message : String(err)}`);
return;
}
if (looksBinary(buf)) {
toast.error(`${file.name} appears to be binary.`);
return;
}
const text = new TextDecoder('utf-8', { fatal: false }).decode(buf);
addAttachment({
id: crypto.randomUUID(),
kind: 'file',
filename: file.name,
language: inferLanguage(file.name),
content: text,
source: 'drop',
});
}
function isFolderItem(item: DataTransferItem | undefined): boolean {
if (!item) return false;
// webkitGetAsEntry is non-standard but supported in Chromium + Safari.
// If unavailable, we conservatively treat the entry as a file.
const entry =
typeof item.webkitGetAsEntry === 'function' ? item.webkitGetAsEntry() : null;
if (entry && entry.isDirectory) return true;
// Heuristic fallback: folders dragged from Finder have type === '' and
// a 0-byte File. The empty-type alone isn't reliable for files (some
// plaintext drops also lack a type), so we only flag when the entry
// explicitly says directory.
return false;
}
async function handleDroppedItems(dt: DataTransfer) {
// Snapshot items first because reading files inside the loop can
// detach the DataTransfer between awaits.
const itemsArray: { file: File | null; isFolder: boolean }[] = [];
if (dt.items && dt.items.length > 0) {
for (let i = 0; i < dt.items.length; i++) {
const it = dt.items[i];
if (!it || it.kind !== 'file') continue;
const folder = isFolderItem(it);
const file = folder ? null : it.getAsFile();
itemsArray.push({ file, isFolder: folder });
}
} else {
for (let i = 0; i < dt.files.length; i++) {
const f = dt.files[i];
if (f) itemsArray.push({ file: f, isFolder: false });
}
}
let remainingSlots = MAX_ATTACHMENTS - attachments.length;
let folderRejected = false;
for (const { file, isFolder } of itemsArray) {
if (isFolder) {
if (!folderRejected) {
toast.error('Folders are not supported');
folderRejected = true;
}
continue;
}
if (!file) continue;
if (remainingSlots <= 0) {
toast.error(`Attachment limit reached (${MAX_ATTACHMENTS}).`);
return;
}
await processDroppedFile(file);
remainingSlots -= 1;
}
}
function onDragEnter(e: DragEvent<HTMLDivElement>) {
if (disabled || busy) return;
e.preventDefault();
setIsDraggingOver(true);
}
function onDragOver(e: DragEvent<HTMLDivElement>) {
if (disabled || busy) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
}
function onDragLeave(e: DragEvent<HTMLDivElement>) {
// Only clear when the cursor actually leaves the root container.
// relatedTarget is the element being entered; if it's inside the root,
// ignore — we're just crossing into a child.
const root = dropRootRef.current;
if (!root) return;
const related = e.relatedTarget as Node | null;
if (related && root.contains(related)) return;
setIsDraggingOver(false);
}
function onDrop(e: DragEvent<HTMLDivElement>) {
e.preventDefault();
setIsDraggingOver(false);
if (disabled || busy) return;
void handleDroppedItems(e.dataTransfer);
}
// ---- end Drag & drop -----------------------------------------------------
// ---- Paste-as-attachment (F2) -------------------------------------------
// Pasting >PASTE_INLINE_MAX_LINES lines of text becomes a chip rather than
// inline content. Image pastes are rejected with a toast. If both text and
// image are present (e.g. screenshot tool that sets both), prefer text.
function onPaste(e: React.ClipboardEvent<HTMLTextAreaElement>) {
const cd = e.clipboardData;
if (!cd) return;
const text = cd.getData('text/plain');
const hasImage = Array.from(cd.items ?? []).some((it) =>
it.type.startsWith('image/'),
);
if (text) {
const lineCount = text.split('\n').length;
if (lineCount > PASTE_INLINE_MAX_LINES) {
e.preventDefault();
pasteCounterRef.current += 1;
addAttachment({
id: crypto.randomUUID(),
kind: 'paste',
filename: `pasted-${pasteCounterRef.current}.txt`,
language: 'plaintext',
content: text,
source: 'paste',
});
}
// <= threshold: let default paste insert inline.
return;
}
if (hasImage) {
e.preventDefault();
toast.error('Image paste is not supported. Drop a file or paste text.');
}
}
// ---- end Paste-as-attachment --------------------------------------------
function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
if (mentionState?.open) return;
// SkillSlashCommand owns Arrow/Enter/Tab/Esc via a document listener; let
// it consume them so the textarea doesn't also submit on Enter.
if (slashState) return;
// IME safety: never act on Enter while an IME composition is in flight
// (CJK input methods commit composition via Enter). Without this, the
// first Enter of a Japanese/Chinese/Korean composition would submit
@@ -228,7 +530,16 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
}
return (
<div className="border-t" style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}>
<div
ref={dropRootRef}
className="border-t relative"
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
onDragEnter={onDragEnter}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
>
<DropOverlay visible={isDraggingOver} />
<div className="max-w-[1000px] mx-auto w-full">
{attachments.length > 0 && (
<div className="flex flex-wrap gap-1.5 px-4 pt-3">
@@ -242,12 +553,60 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
))}
</div>
)}
{/* Batch 9 toolbar — agent picker. v1.9 adds the icon-only + menu next
to it for quick toggles (currently: Web search). When omitted at the
callsite the row stays collapsed so nothing else has to change. */}
{(onAgentChange || sessionId) && (
<div className="px-4 pt-2 flex items-center gap-1.5">
{onAgentChange && (
<AgentPicker
projectId={projectId}
value={agentId ?? null}
onChange={onAgentChange}
/>
)}
{sessionId && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label="Quick toggles"
title="Quick toggles"
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
>
<Plus className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
onSelect={async () => {
// v1.9: tri-state collapses to two on the wire when toggled
// here. null (inherit) treated as off; click flips to true.
// To restore "inherit" the user opens SettingsPane.
const next = webSearchEnabled === true ? false : true;
try {
await api.sessions.update(sessionId, { web_search_enabled: next });
} catch (err) {
toast.error(err instanceof Error ? err.message : 'failed to toggle web search');
}
}}
className="text-xs"
>
<Check className={`size-3 ${webSearchEnabled === true ? 'opacity-100' : 'opacity-0'}`} />
Web search
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
)}
<div className="px-4 py-3 flex items-end gap-2">
<Textarea
ref={textareaRef}
value={value}
onChange={handleChange}
onKeyDown={onKeyDown}
onPaste={onPaste}
placeholder={
isMobile
? 'Ask about this project. Tap send to submit.'
@@ -280,6 +639,15 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
onClose={closeMention}
/>
)}
{slashState && (
<SkillSlashCommand
query={slashState.query}
skills={skills}
anchorRect={slashState.anchorRect}
onSelect={handleSlashSelect}
onClose={() => setSlashState(null)}
/>
)}
</div>
);
}

View File

@@ -1,6 +1,7 @@
import { useState } from 'react';
import { History, MessageSquare, Plus, X } from 'lucide-react';
import { Bot, History, MessageSquare, Plus, Terminal, X } from 'lucide-react';
import type { Chat, WorkspacePane } from '@/api/types';
import { StatusDot } from '@/components/StatusDot';
import {
ContextMenu,
ContextMenuContent,
@@ -8,6 +9,12 @@ import {
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useLongPress } from '@/hooks/useLongPress';
import { cn } from '@/lib/utils';
@@ -19,7 +26,7 @@ interface Props {
onCloseOthers: (chatId: string) => void;
onCloseToRight: (chatId: string) => void;
onCloseAll: () => void;
onNewChat: () => void;
onAddPane: (kind: 'chat' | 'terminal' | 'agent') => void;
onShowHistory: () => void;
onRename: (chatId: string, name: string) => Promise<void>;
onRemovePane?: () => void;
@@ -33,7 +40,7 @@ export function ChatTabBar({
onCloseOthers,
onCloseToRight,
onCloseAll,
onNewChat,
onAddPane,
onShowHistory,
onRename,
onRemovePane,
@@ -66,7 +73,7 @@ export function ChatTabBar({
}
return (
<div className="flex items-center border-b border-border bg-muted/20 h-8 shrink-0 overflow-x-auto">
<div className="flex items-center border-b border-border bg-muted/20 h-8 shrink-0 overflow-x-auto max-md:hidden">
{tabs.map((chat, tabIdx) => {
const isActive = tabIdx === pane.activeChatIdx;
const isLast = tabIdx === tabs.length - 1;
@@ -91,6 +98,7 @@ export function ChatTabBar({
)}
>
<MessageSquare size={12} className="shrink-0" />
<StatusDot chatId={chat.id} />
{renamingId === chat.id ? (
<input
autoFocus
@@ -123,7 +131,7 @@ export function ChatTabBar({
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={() => onNewChat()}>
<ContextMenuItem onSelect={() => onAddPane('chat')}>
New chat
</ContextMenuItem>
<ContextMenuSeparator />
@@ -162,15 +170,29 @@ export function ChatTabBar({
)}
<div className="flex items-center ml-auto gap-0.5 px-1 shrink-0">
<button
type="button"
onClick={onNewChat}
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="New chat"
title="New chat"
>
<Plus size={12} />
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="New pane"
title="New pane"
>
<Plus size={12} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-40">
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
<MessageSquare size={14} /> New chat
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onAddPane('terminal')}>
<Terminal size={14} /> New terminal
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onAddPane('agent')}>
<Bot size={14} /> New agent
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<button
type="button"
onClick={onShowHistory}

View File

@@ -0,0 +1,86 @@
import type { Message } from '@/api/types';
interface Props {
messages: Message[];
}
// v1.11.2: persistent context-usage indicator above MessageList. Mirrors the
// server-side compaction.usable() formula — color thresholds are computed
// against (max - 20k buffer), not raw max, so the bar turns amber/orange
// /red at the same boundaries auto-compaction will fire. The popover above
// the input (ChatContextPopover) uses raw-% thresholds and is intentionally
// kept separate (it's a different surface and a different signal).
const COMPACTION_BUFFER = 20_000;
// Walk newest-first; first message with both ctx_used and ctx_max non-null
// AND ctx_max > 0 wins. Older messages may have ctx_used but missing ctx_max
// (early v1 before llama-swap's n_ctx capture worked) — skip them and keep
// walking. If nothing usable in the chat, caller renders null.
function latestPair(messages: Message[]): { used: number; max: number } | null {
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i]!;
if (m.ctx_used == null || m.ctx_max == null) continue;
if (m.ctx_max <= 0) continue;
return { used: m.ctx_used, max: m.ctx_max };
}
return null;
}
interface ColorTier {
// Tailwind utility for the label / numbers. Uses literal palette names
// rather than design tokens because we want three distinct severities
// (amber → orange → red) and BooCode only defines one warning token
// (`destructive`). Literal classes keep the gradation explicit.
text: string;
bar: string;
}
function tierFor(usablePct: number): ColorTier {
if (usablePct >= 0.95) return { text: 'text-red-600 dark:text-red-400', bar: 'bg-red-500' };
if (usablePct >= 0.80) return { text: 'text-orange-600 dark:text-orange-400', bar: 'bg-orange-500' };
if (usablePct >= 0.60) return { text: 'text-amber-600 dark:text-amber-400', bar: 'bg-amber-500' };
return { text: 'text-muted-foreground', bar: 'bg-muted-foreground/40' };
}
export function ContextBar({ messages }: Props) {
const pair = latestPair(messages);
if (!pair) return null;
const { used, max } = pair;
const usable = Math.max(0, max - COMPACTION_BUFFER);
const pct = used / max;
const usablePct = usable > 0 ? used / usable : 0;
const tier = tierFor(usablePct);
// Bar fill is clamped to [0, 100] — over-budget cases (usable < used) still
// show the bar at 100% red rather than overflowing the track visually.
const fillPct = Math.min(100, Math.max(0, pct * 100));
const compactionThresholdPct = max > 0 ? Math.round((usable / max) * 100) : 0;
return (
<div className="border-b px-4 py-1 shrink-0">
<div className="max-w-[1000px] mx-auto w-full">
<div className="flex items-baseline justify-between text-[10px] font-mono leading-tight">
{/* "Context" on >=sm, "Ctx" on phones to save horizontal space. */}
<span className={tier.text}>
<span className="hidden sm:inline">Context</span>
<span className="sm:hidden">Ctx</span>
</span>
<span
className={tier.text}
title={`Auto-compaction at ~${compactionThresholdPct}%`}
>
{used.toLocaleString()} / {max.toLocaleString()}{' '}
<span className="max-[380px]:hidden">({Math.round(pct * 100)}%)</span>
</span>
</div>
<div className="mt-1 h-1 rounded-full bg-muted overflow-hidden">
<div
className={`h-full ${tier.bar} transition-[width] duration-300`}
style={{ width: `${fillPct}%` }}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,18 @@
interface Props {
visible: boolean;
}
// Visual cue layered over the ChatInput while a drag is in progress.
// Pointer-events: none so the underlying drop handler still receives the
// drop event. Renders nothing when not visible (cheap and out of layout).
export function DropOverlay({ visible }: Props) {
if (!visible) return null;
return (
<div
className="absolute inset-0 z-10 pointer-events-none flex items-center justify-center rounded border-2 border-dashed border-primary bg-background/85"
aria-hidden="true"
>
<div className="text-sm font-medium text-primary">Drop to attach</div>
</div>
);
}

View File

@@ -1,15 +1,25 @@
import { Children, cloneElement, isValidElement, useState } from 'react';
import { Children, cloneElement, isValidElement, useEffect, useState } from 'react';
import type { ReactElement, ReactNode } from 'react';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import type { Chat, Message } from '@/api/types';
import type { Chat, ErrorReason, Message } from '@/api/types';
import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents';
import { ToolCallCard } from './ToolCallCard';
import { sendToTerminal, terminalsRegistry, type TerminalRegistration } from '@/lib/events';
import { CapHitSentinel } from './CapHitSentinel';
import { CodeBlock } from './CodeBlock';
import { Button } from '@/components/ui/button';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from '@/components/ui/context-menu';
import {
Dialog,
DialogContent,
@@ -19,6 +29,66 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
// v1.10 booterm: tiny subscription hook for the mounted-terminals registry.
// Used by the right-click "Send to terminal" submenu so it always reflects
// currently-open terminal panes without prop drilling from Workspace.
function useTerminals(): TerminalRegistration[] {
const [list, setList] = useState(() => terminalsRegistry.list());
useEffect(() => terminalsRegistry.subscribe(() => setList(terminalsRegistry.list())), []);
return list;
}
// Wrap a message body with a right-click context menu offering "Send to
// terminal → <pane name>". The submenu is disabled when nothing is selected
// or no terminal panes are open; clicking a target emits a sendToTerminal
// event that TerminalPane subscribes to (filtered by pane_id).
function SendToTerminalMenu({ children }: { children: ReactNode }) {
const [selection, setSelection] = useState('');
const terminals = useTerminals();
const canSend = selection.length > 0 && terminals.length > 0;
return (
<ContextMenu
onOpenChange={(open) => {
if (open) {
const sel = typeof window !== 'undefined' ? window.getSelection()?.toString() ?? '' : '';
setSelection(sel);
}
}}
>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuSub>
<ContextMenuSubTrigger disabled={!canSend}>Send to terminal</ContextMenuSubTrigger>
<ContextMenuSubContent>
{terminals.length === 0 ? (
<ContextMenuItem disabled>No terminal panes open</ContextMenuItem>
) : (
terminals.map((t) => (
<ContextMenuItem
key={t.paneId}
onSelect={() => sendToTerminal.emit({ pane_id: t.paneId, text: selection })}
>
{t.label}
</ContextMenuItem>
))
)}
</ContextMenuSubContent>
</ContextMenuSub>
</ContextMenuContent>
</ContextMenu>
);
}
// v1.8.2: human labels for the machine-readable error reasons that ride on
// failed assistant messages via metadata.kind === 'error'. Kept short so the
// inline render under "message failed" stays a single muted line.
const ERROR_REASON_LABELS: Record<ErrorReason, string> = {
llm_provider_error: 'LLM provider error',
tool_execution_failed: 'Tool execution failed',
summary_after_cap_failed: 'Summary after tool budget hit failed',
};
// Match path-shaped substrings ending in `.ext`. Additionally require a `/`
// in the match to reduce false positives in prose (e.g. plain `foo.ts` won't
// match, but `src/foo.ts` will). False positives at the edges are accepted
@@ -94,6 +164,9 @@ function linkifyChildren(children: ReactNode, keyPrefix = 'l'): ReactNode {
interface Props {
message: Message;
sessionChats?: Chat[];
// v1.8.2: passed by MessageList's render-item pass for cap-hit sentinels.
// Only the most recent sentinel shows the Continue button.
capHitInfo?: { position: number; isLatest: boolean };
}
function MarkdownBody({ content }: { content: string }) {
@@ -464,21 +537,105 @@ function CompactCard({ message, sessionChats }: { message: Message; sessionChats
);
}
export function MessageBubble({ message, sessionChats }: Props) {
// v1.11 anchored rolling summary. Inserted by services/compaction.ts as a
// role='assistant', summary=true row. Distinct from legacy CompactCard
// (which renders the kind='compact' system rows produced by v1.10 /compact).
// Collapsed by default; header shows the timestamp; body renders the
// summary markdown when expanded. Copy button matches CompactCard's affordance.
function SummaryCard({ message }: { message: Message }) {
const [expanded, setExpanded] = useState(false);
const [copied, setCopied] = useState(false);
// Use finished_at when available (that's when the summary actually landed);
// fall back to created_at for any row missing it. Both are ISO strings.
const ts = message.finished_at ?? message.created_at;
const headerTs = ts ? new Date(ts).toLocaleString() : '';
async function handleCopy() {
try {
await navigator.clipboard.writeText(message.content);
setCopied(true);
setTimeout(() => setCopied(false), 1200);
toast.success('Summary copied to clipboard');
} catch {
toast.error('Copy failed');
}
}
return (
<div className="rounded-lg border border-primary/30 bg-primary/5 text-sm">
<div className="flex items-center gap-2 px-3 py-2">
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-1.5 flex-1 min-w-0 text-left text-muted-foreground hover:text-foreground"
>
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<span className="text-xs font-medium truncate">
Compacted summary {headerTs}
</span>
</button>
<button
type="button"
onClick={() => void handleCopy()}
className="p-1 rounded hover:bg-muted text-muted-foreground"
aria-label="Copy summary"
title="Copy summary"
>
{copied ? <Check size={12} /> : <Copy size={12} />}
</button>
</div>
{expanded && (
<div className="px-3 pb-3 text-xs leading-relaxed border-t pt-2">
<MarkdownBody content={message.content} />
</div>
)}
</div>
);
}
export function MessageBubble({ message, sessionChats, capHitInfo }: Props) {
// v1.11: anchored rolling summary row. Checked BEFORE the kind==='compact'
// branch because summary=true never coexists with kind='compact' (new
// compactions emit role='assistant' rows with kind='message'+summary=true).
if (message.summary) {
return <SummaryCard message={message} />;
}
if (message.kind === 'compact') {
return <CompactCard message={message} sessionChats={sessionChats} />;
}
if (message.role === 'tool') {
return <ToolCallCard message={message} />;
// v1.8.2: cap-hit sentinels render as a distinct system bubble with a
// Continue button. MessageList's pre-render pass tags each sentinel with
// its position; only the latest gets the actionable button.
if (
message.role === 'system' &&
message.metadata?.kind === 'cap_hit' &&
capHitInfo
) {
return (
<CapHitSentinel
message={message}
capHitPosition={capHitInfo.position}
isLatest={capHitInfo.isLatest}
/>
);
}
// v1.8.2: tool messages and assistant tool_calls are now rendered by
// MessageList via ToolCallLine / ToolCallGroup. Tool-role messages reach
// this point only if MessageList didn't consume them (shouldn't happen,
// but guard against it by rendering nothing rather than a stale card).
if (message.role === 'tool') return null;
if (message.role === 'user') {
return (
<div className="group flex flex-col items-end gap-1">
<div className="max-w-[80%] rounded-lg bg-primary text-primary-foreground px-3 py-2 text-sm whitespace-pre-wrap break-words min-w-0">
{message.content}
</div>
<SendToTerminalMenu>
<div className="max-w-[80%] rounded-lg bg-primary text-primary-foreground px-3 py-2 text-sm whitespace-pre-wrap break-words min-w-0">
{message.content}
</div>
</SendToTerminalMenu>
<ActionRow message={message} />
</div>
);
@@ -487,28 +644,39 @@ export function MessageBubble({ message, sessionChats }: Props) {
const isStreaming = message.status === 'streaming';
const failed = message.status === 'failed';
const hasContent = message.content.length > 0;
const hasToolCalls = (message.tool_calls?.length ?? 0) > 0;
// v1.8.2: if metadata stamps an error reason, surface it inline under the
// generic "message failed" line. Keeps the user's eye where it already is
// rather than introducing a separate banner.
const errorMeta =
message.metadata !== null && message.metadata.kind === 'error'
? message.metadata
: null;
return (
<div className="group flex flex-col gap-2">
{message.tool_calls?.map((tc) => (
<ToolCallCard key={tc.id} toolCall={tc} />
))}
{(hasContent || (!hasToolCalls && isStreaming)) && (
<div className="max-w-[90%] text-sm leading-relaxed space-y-2 break-words min-w-0">
{hasContent ? <MarkdownBody content={message.content} /> : null}
{isStreaming && (
<span className="inline-block w-1.5 h-3.5 align-baseline bg-muted-foreground/60 animate-pulse" />
{(hasContent || isStreaming) && (
<SendToTerminalMenu>
<div className="max-w-[90%] text-sm leading-relaxed space-y-2 break-words min-w-0">
{hasContent ? <MarkdownBody content={message.content} /> : null}
{isStreaming && (
<span className="inline-block w-1.5 h-3.5 align-baseline bg-muted-foreground/60 animate-pulse" />
)}
</div>
</SendToTerminalMenu>
)}
{failed && (
<div className="text-xs text-destructive">
message failed
{errorMeta && (
<span className="block text-muted-foreground mt-0.5">
{ERROR_REASON_LABELS[errorMeta.error_reason]}
{errorMeta.error_text ? `${errorMeta.error_text}` : ''}
</span>
)}
</div>
)}
{failed && (
<div className="text-xs text-destructive">message failed</div>
)}
{!isStreaming && <StatsLine message={message} />}
{!isStreaming && (hasContent || hasToolCalls) && (
<ActionRow message={message} />
)}
{!isStreaming && hasContent && <ActionRow message={message} />}
</div>
);
}

View File

@@ -1,15 +1,144 @@
import { useEffect, useRef } from 'react';
import { useEffect, useMemo, useRef } from 'react';
import type { Chat, Message } from '@/api/types';
import { MessageBubble } from './MessageBubble';
import { ToolCallGroup } from './ToolCallGroup';
import { ToolCallLine, type ToolRun } from './ToolCallLine';
import { AskUserInputCard } from './AskUserInputCard';
interface Props {
messages: Message[];
sessionChats?: Chat[];
}
// v1.8.2: pre-render units. The single linear `messages` array gets walked
// into a render-time list where each tool_call is a first-class item and
// tool_result messages are folded onto their matching tool_run by id.
// Batch 9.7: tool_run carries chat_id so AskUserInputCard can post the
// answer without threading the chat id through MessageList's parent.
type RenderItem =
| { kind: 'message'; message: Message; capHitInfo?: { position: number; isLatest: boolean } }
| { kind: 'tool_run'; run: ToolRun; key: string; chatId: string }
| { kind: 'tool_group'; runs: ToolRun[]; key: string };
const GROUP_THRESHOLD = 3;
function isCapHitSentinel(m: Message): boolean {
return m.role === 'system' && m.metadata?.kind === 'cap_hit';
}
// First pass: walk messages chronologically, expanding assistant tool_calls
// into per-call run items and folding tool_result messages onto their
// matching runs. Tool messages themselves never produce a render item.
// Assistant messages produce a text render item only when they have text;
// pure tool-call messages are "transparent" so consecutive tool runs can
// still group across them.
function flatten(messages: Message[]): RenderItem[] {
const items: RenderItem[] = [];
const runsByCallId = new Map<string, ToolRun>();
for (const m of messages) {
if (m.role === 'tool') {
if (m.tool_results) {
const run = runsByCallId.get(m.tool_results.tool_call_id);
if (run) run.result = m.tool_results;
}
continue;
}
const hasToolCalls = m.tool_calls != null && m.tool_calls.length > 0;
const hasText = m.content.length > 0;
if (m.role === 'assistant' && hasToolCalls) {
if (hasText || m.status === 'streaming') {
items.push({ kind: 'message', message: m });
}
for (const tc of m.tool_calls!) {
const run: ToolRun = { call: tc, result: null };
runsByCallId.set(tc.id, run);
items.push({ kind: 'tool_run', run, key: tc.id, chatId: m.chat_id });
}
continue;
}
items.push({ kind: 'message', message: m });
}
return items;
}
// Second pass: collapse runs of >=GROUP_THRESHOLD consecutive tool_run items
// of the same tool name into a single tool_group. Any other render item
// (text bubble, sentinel, user message) breaks the chain.
// Batch 9.7: ask_user_input never groups — each pause has its own card so
// grouping would render them as collapsed ToolCallLines which can't surface
// the interactive form.
function group(items: RenderItem[]): RenderItem[] {
const out: RenderItem[] = [];
let i = 0;
while (i < items.length) {
const item = items[i]!;
if (item.kind !== 'tool_run') {
out.push(item);
i += 1;
continue;
}
const name = item.run.call.name;
if (name === 'ask_user_input') {
out.push(item);
i += 1;
continue;
}
let j = i + 1;
while (
j < items.length &&
items[j]!.kind === 'tool_run' &&
(items[j] as { kind: 'tool_run'; run: ToolRun }).run.call.name === name
) {
j += 1;
}
const run = items.slice(i, j) as Array<{
kind: 'tool_run';
run: ToolRun;
key: string;
chatId: string;
}>;
if (run.length >= GROUP_THRESHOLD) {
out.push({
kind: 'tool_group',
runs: run.map((r) => r.run),
key: `group-${run[0]!.key}`,
});
} else {
for (const r of run) out.push(r);
}
i = j;
}
return out;
}
// Third pass: number cap-hit sentinels (1-indexed) and mark the latest.
// CapHitSentinel uses position to compute the "N continues remaining"
// tooltip, and isLatest to gate the Continue button (only the most recent
// sentinel is actionable).
function stampCapHits(items: RenderItem[]): RenderItem[] {
const totalCapHits = items.reduce(
(n, it) => n + (it.kind === 'message' && isCapHitSentinel(it.message) ? 1 : 0),
0,
);
if (totalCapHits === 0) return items;
let index = 0;
return items.map((it) => {
if (it.kind !== 'message' || !isCapHitSentinel(it.message)) return it;
index += 1;
return {
...it,
capHitInfo: { position: index, isLatest: index === totalCapHits },
};
});
}
export function MessageList({ messages, sessionChats }: Props) {
const endRef = useRef<HTMLDivElement>(null);
const renderItems = useMemo(() => stampCapHits(group(flatten(messages))), [messages]);
useEffect(() => {
endRef.current?.scrollIntoView({ block: 'end' });
}, [messages]);
@@ -25,9 +154,32 @@ export function MessageList({ messages, sessionChats }: Props) {
return (
<div className="flex-1 overflow-y-auto">
<div className="max-w-[1000px] mx-auto w-full px-6 py-4 space-y-4">
{messages.map((m) => (
<MessageBubble key={m.id} message={m} sessionChats={sessionChats} />
))}
{renderItems.map((item) => {
if (item.kind === 'message') {
return (
<MessageBubble
key={item.message.id}
message={item.message}
sessionChats={sessionChats}
capHitInfo={item.capHitInfo}
/>
);
}
if (item.kind === 'tool_run') {
if (item.run.call.name === 'ask_user_input') {
return (
<AskUserInputCard
key={item.key}
toolCall={item.run.call}
toolResult={item.run.result}
chatId={item.chatId}
/>
);
}
return <ToolCallLine key={item.key} run={item.run} />;
}
return <ToolCallGroup key={item.key} runs={item.runs} />;
})}
<div ref={endRef} />
</div>
</div>

View File

@@ -0,0 +1,296 @@
import { useRef, useState } from 'react';
import {
Bot,
ChevronDown,
Edit2,
MessageSquare,
MoreHorizontal,
Settings as SettingsIcon,
Terminal,
X,
} from 'lucide-react';
import { toast } from 'sonner';
import type { Chat, WorkspacePane } from '@/api/types';
import { BottomSheet } from '@/components/BottomSheet';
import { StatusDot } from '@/components/StatusDot';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useLongPress } from '@/hooks/useLongPress';
import { cn } from '@/lib/utils';
interface Props {
panes: WorkspacePane[];
activePaneIdx: number;
chats: Chat[];
onSwitchPane: (idx: number) => void;
onRemovePane: (idx: number) => void;
onRenameChat: (chatId: string, name: string) => Promise<void>;
}
// v1.10.4: swipe-left-to-close on the pane pill. Threshold matches the spec
// (80px). Vertical bail-out at 30px because the pill sits inside a vertical
// scrollable header — diagonal-ish swipes shouldn't accidentally close panes.
const SWIPE_CLOSE_PX = 80;
const SWIPE_VERTICAL_BAIL_PX = 30;
// Visual cap: pill translates left up to this much. Past this, dragX stays
// pinned so the user has a clear "release to close" indicator.
const SWIPE_VISUAL_CAP = 120;
function paneIcon(kind: WorkspacePane['kind']) {
if (kind === 'terminal') return <Terminal size={14} />;
if (kind === 'agent') return <Bot size={14} />;
if (kind === 'settings') return <SettingsIcon size={14} />;
return <MessageSquare size={14} />;
}
function paneActiveChatId(pane: WorkspacePane | undefined): string | null {
if (!pane) return null;
if (pane.chatId) return pane.chatId;
const idx = pane.activeChatIdx;
if (idx < 0 || idx >= pane.chatIds.length) return null;
return pane.chatIds[idx] ?? null;
}
function paneLabel(pane: WorkspacePane, chats: Chat[]): string {
const cid = paneActiveChatId(pane);
if (cid) {
const c = chats.find((x) => x.id === cid);
if (c) return c.name ?? 'New chat';
}
if (pane.kind === 'chat') return 'Chat';
if (pane.kind === 'terminal') return 'Terminal';
if (pane.kind === 'agent') return 'Agent';
if (pane.kind === 'settings') return 'Settings';
return 'Empty';
}
export function MobileTabSwitcher({
panes,
activePaneIdx,
chats,
onSwitchPane,
onRemovePane,
onRenameChat,
}: Props) {
const [open, setOpen] = useState(false);
const [renamingChatId, setRenamingChatId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
// v1.10.4: swipe-left state. dragX is the (clamped, negative) drag offset
// in px. suppressClick latches when a swipe completes so the trailing click
// doesn't pop open the BottomSheet on the just-closed pane.
const [dragX, setDragX] = useState(0);
const swipeStart = useRef<{ x: number; y: number } | null>(null);
const swipeBailed = useRef(false);
const suppressClick = useRef(false);
const active = panes[activePaneIdx];
const activeLabel = active ? paneLabel(active, chats) : 'Empty';
const activeChatId = paneActiveChatId(active);
function onPillTouchStart(e: React.TouchEvent<HTMLDivElement>): void {
if (e.touches.length !== 1) return;
const t = e.touches[0]!;
swipeStart.current = { x: t.clientX, y: t.clientY };
swipeBailed.current = false;
setDragX(0);
}
function onPillTouchMove(e: React.TouchEvent<HTMLDivElement>): void {
if (!swipeStart.current || swipeBailed.current) return;
if (e.touches.length !== 1) return;
const t = e.touches[0]!;
const dx = t.clientX - swipeStart.current.x;
const dy = t.clientY - swipeStart.current.y;
// Bail to scroll if vertical motion dominates before horizontal.
if (Math.abs(dy) > SWIPE_VERTICAL_BAIL_PX && Math.abs(dy) > Math.abs(dx)) {
swipeBailed.current = true;
setDragX(0);
return;
}
// Only allow leftward drag (negative). Cap visual displacement.
const clamped = Math.max(-SWIPE_VISUAL_CAP, Math.min(0, dx));
setDragX(clamped);
}
function onPillTouchEnd(): void {
const finalDx = dragX;
swipeStart.current = null;
if (swipeBailed.current) {
setDragX(0);
return;
}
if (finalDx <= -SWIPE_CLOSE_PX && panes.length > 1) {
suppressClick.current = true;
// Reset dragX after the close so subsequent re-renders look right.
setDragX(0);
onRemovePane(activePaneIdx);
return;
}
setDragX(0);
}
function onPillClick(): void {
if (suppressClick.current) {
suppressClick.current = false;
return;
}
setOpen(true);
}
const swipeProgress = Math.min(1, Math.abs(dragX) / SWIPE_CLOSE_PX);
// Long-press mirrors ChatTabBar: synthesize a contextmenu event on the row
// so the trailing kebab's Radix DropdownMenu opens at the touch point.
const longPress = useLongPress(({ clientX, clientY, target }) => {
if (!target || !(target instanceof Element)) return;
const row = target.closest('[data-pane-id]') as HTMLElement | null;
if (!row) return;
const trigger = row.querySelector('[data-pane-kebab]') as HTMLElement | null;
if (trigger) {
trigger.click();
return;
}
row.dispatchEvent(
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX, clientY }),
);
});
function startRename(chatId: string, currentName: string | null) {
setRenamingChatId(chatId);
setRenameValue(currentName ?? '');
}
async function finishRename() {
if (renamingChatId && renameValue.trim()) {
try {
await onRenameChat(renamingChatId, renameValue.trim());
} catch (err) {
toast.error(err instanceof Error ? err.message : 'rename failed');
}
}
setRenamingChatId(null);
}
function handleSwitchPane(idx: number) {
onSwitchPane(idx);
setOpen(false);
}
return (
<>
<div
className="flex-1 relative min-w-0"
onTouchStart={onPillTouchStart}
onTouchMove={onPillTouchMove}
onTouchEnd={onPillTouchEnd}
onTouchCancel={onPillTouchEnd}
>
{/* v1.10.4: red "Close" hint behind the pill. Opacity tracks the
swipe progress (0 at rest, 1 at the close threshold). aria-hidden
because the actionable affordance is the swipe, not this label. */}
<div
aria-hidden="true"
className="absolute inset-0 flex items-center justify-end pr-4 rounded-full bg-destructive/80 text-destructive-foreground text-xs font-medium"
style={{ opacity: swipeProgress, pointerEvents: 'none' }}
>
Close
</div>
<button
type="button"
onClick={onPillClick}
className="flex-1 w-full inline-flex items-center gap-1.5 min-h-[44px] px-3 text-sm rounded-full bg-muted/40 hover:bg-muted/70 text-foreground min-w-0 relative"
aria-label="Switch pane"
style={{
transform: `translateX(${dragX}px)`,
transition: dragX === 0 ? 'transform 180ms ease-out' : 'none',
}}
>
<span className="shrink-0 text-muted-foreground">{paneIcon(active?.kind ?? 'chat')}</span>
<StatusDot chatId={activeChatId} />
<span className="truncate flex-1 text-left">{activeLabel}</span>
<ChevronDown size={14} className="opacity-60 shrink-0" />
</button>
</div>
<BottomSheet open={open} onClose={() => setOpen(false)} title="Panes">
<ul className="px-2 py-2 space-y-1">
{panes.map((pane, idx) => {
const isActive = idx === activePaneIdx;
const cid = paneActiveChatId(pane);
const chat = cid ? chats.find((c) => c.id === cid) ?? null : null;
const label = paneLabel(pane, chats);
return (
<li
key={pane.id}
data-pane-id={pane.id}
onTouchStart={longPress.onTouchStart}
onTouchMove={longPress.onTouchMove}
onTouchEnd={longPress.onTouchEnd}
onTouchCancel={longPress.onTouchCancel}
onClick={() => handleSwitchPane(idx)}
style={{ WebkitTouchCallout: 'none' }}
className={cn(
'flex items-center gap-2 px-3 py-2 rounded min-h-[48px] cursor-default select-none',
isActive
? 'bg-accent/40 border-l-2 border-primary'
: 'hover:bg-muted/50',
)}
>
<span className="shrink-0 text-muted-foreground">{paneIcon(pane.kind)}</span>
<StatusDot chatId={cid ?? null} />
{renamingChatId === cid && cid ? (
<input
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={() => void finishRename()}
onKeyDown={(e) => {
if (e.key === 'Enter') void finishRename();
if (e.key === 'Escape') setRenamingChatId(null);
}}
onClick={(e) => e.stopPropagation()}
className="bg-transparent border-b border-border text-sm outline-none flex-1 min-w-0"
/>
) : (
<span className="truncate flex-1 text-sm">{label}</span>
)}
{isActive && (
<span aria-hidden="true" className="text-primary text-xs shrink-0">
</span>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
data-pane-kebab
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground min-h-[44px] min-w-[44px]"
aria-label="Pane options"
>
<MoreHorizontal size={14} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{chat && (
<DropdownMenuItem onSelect={() => startRename(chat.id, chat.name)}>
<Edit2 size={14} /> Rename chat
</DropdownMenuItem>
)}
<DropdownMenuItem
disabled={panes.length <= 1}
onSelect={() => onRemovePane(idx)}
>
<X size={14} /> Close pane
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</li>
);
})}
</ul>
{/* v1.8: New-pane button moved out of the sheet to the header row 2
(see NewPaneMenu). Sheet is for switching only. */}
</BottomSheet>
</>
);
}

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { Check, ChevronDown } from 'lucide-react';
import { Check, ChevronDown, Cpu } from 'lucide-react';
import { api } from '@/api/client';
import type { ModelInfo } from '@/api/types';
import {
@@ -8,26 +8,94 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { BottomSheet } from '@/components/BottomSheet';
import { useViewport } from '@/hooks/useViewport';
interface Props {
value: string;
onChange: (model: string) => void | Promise<void>;
}
// v1.9: shared list rendered inside both shells. Lazy-fetches /api/models on
// first open so the picker doesn't pay for a request when it's never shown.
function ModelList({
models,
error,
value,
onPick,
}: {
models: ModelInfo[] | null;
error: string | null;
value: string;
onPick: (id: string) => void;
}) {
if (error) {
return <div className="px-2 py-1.5 text-xs text-destructive">{error}</div>;
}
if (models === null) {
return <div className="px-2 py-1.5 text-xs text-muted-foreground">Loading</div>;
}
return (
<>
{models.map((m) => (
<button
key={m.id}
type="button"
onClick={() => onPick(m.id)}
className="w-full text-left flex items-center gap-2 font-mono text-xs px-2 py-1.5 hover:bg-accent rounded"
>
<Check className={`size-3 ${m.id === value ? 'opacity-100' : 'opacity-0'}`} />
<span className="truncate">{m.id}</span>
</button>
))}
</>
);
}
export function ModelPicker({ value, onChange }: Props) {
const { isMobile } = useViewport();
const [models, setModels] = useState<ModelInfo[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [open, setOpen] = useState(false);
useEffect(() => {
if (!open || models !== null) return;
api.models()
api
.models()
.then(setModels)
.catch((err) =>
setError(err instanceof Error ? err.message : 'failed to load models')
setError(err instanceof Error ? err.message : 'failed to load models'),
);
}, [open, models]);
function handlePick(id: string) {
setOpen(false);
void onChange(id);
}
// v1.9: mobile = icon-only trigger + bottom-sheet shell. Desktop = labeled
// trigger (model name + chevron) + dropdown. Same ModelList under the hood.
if (isMobile) {
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
aria-label={`Model: ${value}`}
title={value}
className="inline-flex items-center justify-center min-h-[44px] min-w-[44px] rounded text-muted-foreground hover:text-foreground"
>
<Cpu className="size-4" />
</button>
<BottomSheet open={open} onClose={() => setOpen(false)} title="Model">
<div className="px-2 py-2 space-y-1">
<ModelList models={models} error={error} value={value} onPick={handlePick} />
</div>
</BottomSheet>
</>
);
}
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
@@ -49,7 +117,7 @@ export function ModelPicker({ value, onChange }: Props) {
{models?.map((m) => (
<DropdownMenuItem
key={m.id}
onSelect={() => void onChange(m.id)}
onSelect={() => handlePick(m.id)}
className="font-mono text-xs"
>
<Check

View File

@@ -0,0 +1,44 @@
import { Bot, MessageSquare, Plus, Terminal } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
interface Props {
onAddPane: (kind: 'chat' | 'terminal' | 'agent') => void;
disabled?: boolean;
}
// v1.8 row-2 right cluster: mirrors the desktop Workspace.tsx Split dropdown.
// Terminal and Agent items pass through to addSplitPane which already shows
// "coming soon" toasts; rendering them here matches the Batch 3 workspace
// model so the UI is forward-compatible with BooTerm/BooCoder.
export function NewPaneMenu({ onAddPane, disabled }: Props) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={disabled}
className="inline-flex items-center justify-center min-h-[44px] min-w-[44px] rounded-full bg-muted/40 hover:bg-muted/70 text-foreground disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
aria-label="New pane"
>
<Plus size={16} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
<MessageSquare size={14} /> New chat
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onAddPane('terminal')}>
<Terminal size={14} /> New terminal
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onAddPane('agent')}>
<Bot size={14} /> New agent
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -1,8 +1,9 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
import { ChevronRight, ExternalLink, Folder, MessageSquare, Plus } from 'lucide-react';
import { ChevronRight, ExternalLink, Folder, MessageSquare, Plus, Settings as SettingsIcon } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { sessionEvents } from '@/hooks/sessionEvents';
import {
ContextMenu,
ContextMenuContent,
@@ -198,7 +199,7 @@ export function ProjectSidebar() {
const rowCls = (active: boolean) =>
active ? 'bg-sidebar-accent text-sidebar-accent-foreground' : 'hover:bg-sidebar-accent/60';
const { open: drawerOpen } = useSidebarDrawer();
const { open: drawerOpen, setOpen: setDrawerOpen } = useSidebarDrawer();
const { isMobile } = useViewport();
const pull = usePullToRefresh(() => retry(), { enabled: isMobile });
@@ -412,6 +413,30 @@ export function ProjectSidebar() {
})}
</nav>
{/* v1.9: bottom-pinned Settings button. In a session, opens/focuses the
workspace settings pane via the sessionEvents bus (Session.tsx owns
the panesHook). Outside a session there's no workspace to mount the
pane in, so we navigate to /settings (themes page) instead. */}
<div className="border-t shrink-0 p-2">
<button
type="button"
onClick={() => {
if (activeSession) {
sessionEvents.emit({ type: 'open_settings_pane' });
if (isMobile) setDrawerOpen(false);
} else {
navigate('/settings');
if (isMobile) setDrawerOpen(false);
}
}}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm hover:bg-sidebar-accent/60 text-sidebar-foreground"
aria-label="Settings"
>
<SettingsIcon className="size-3.5 shrink-0 opacity-70" />
<span className="flex-1 text-left">Settings</span>
</button>
</div>
<AddProjectModal open={addOpen} onOpenChange={setAddOpen} onAdded={() => {}} />
<Dialog open={archiveProjectConfirm !== null} onOpenChange={(open) => { if (!open) setArchiveProjectConfirm(null); }}>

View File

@@ -3,7 +3,6 @@ import { Archive, MessageSquare, Send, ChevronDown, ChevronRight, RotateCcw, Tra
import type { Chat } from '@/api/types';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Input } from '@/components/ui/input';
import {
ContextMenu,
ContextMenuContent,
@@ -165,7 +164,6 @@ export function SessionLandingPage({
const [renameValue, setRenameValue] = useState('');
const [archiveConfirm, setArchiveConfirm] = useState<Chat | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<Chat | null>(null);
const [deleteInput, setDeleteInput] = useState('');
const openChats = chats
.filter((c) => c.status === 'open')
@@ -193,9 +191,6 @@ export function SessionLandingPage({
setRenamingId(null);
}
const deleteExpected = deleteConfirm?.name ?? '';
const deleteEnabled = deleteConfirm !== null && deleteInput === deleteExpected && deleteExpected.length > 0;
// TODO: Landing page chat counts are a snapshot at mount. New messages in
// visible chats won't update the per-row stats until next mount/navigation.
return (
@@ -217,7 +212,7 @@ export function SessionLandingPage({
onCancelRename={() => setRenamingId(null)}
onContextStartRename={() => startRename(chat)}
onContextArchive={() => setArchiveConfirm(chat)}
onContextDelete={() => { setDeleteConfirm(chat); setDeleteInput(''); }}
onContextDelete={() => setDeleteConfirm(chat)}
showContextMenu
actions={
<>
@@ -242,7 +237,6 @@ export function SessionLandingPage({
onClick={(e) => {
e.stopPropagation();
setDeleteConfirm(chat);
setDeleteInput('');
}}
>
<Trash2 size={14} />
@@ -352,36 +346,25 @@ export function SessionLandingPage({
</DialogContent>
</Dialog>
<Dialog open={deleteConfirm !== null} onOpenChange={(open) => { if (!open) { setDeleteConfirm(null); setDeleteInput(''); } }}>
<Dialog open={deleteConfirm !== null} onOpenChange={(open) => { if (!open) setDeleteConfirm(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete chat?</DialogTitle>
<DialogDescription>
Type the chat name to confirm:
{' '}
<span className="font-mono font-medium text-foreground">{deleteExpected || '(unnamed — cannot type-confirm)'}</span>
Permanently delete{' '}
<span className="font-mono font-medium text-foreground">{deleteConfirm?.name || '(unnamed)'}</span>
{' '}and all its messages. This cannot be undone.
</DialogDescription>
</DialogHeader>
<Input
value={deleteInput}
onChange={(e) => setDeleteInput(e.target.value)}
placeholder={deleteExpected}
disabled={!deleteExpected}
/>
<div className="text-xs text-muted-foreground">
This will permanently delete this chat and all its messages. This cannot be undone.
</div>
<div className="flex gap-2 justify-end pt-2">
<Button variant="outline" onClick={() => { setDeleteConfirm(null); setDeleteInput(''); }}>
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
Cancel
</Button>
<Button
variant="destructive"
disabled={!deleteEnabled}
onClick={() => {
if (deleteConfirm && deleteEnabled) void onDeleteChat(deleteConfirm.id);
if (deleteConfirm) void onDeleteChat(deleteConfirm.id);
setDeleteConfirm(null);
setDeleteInput('');
}}
>
Delete

View File

@@ -0,0 +1,137 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { cn } from '@/lib/utils';
import type { Skill } from '@/api/types';
interface Props {
query: string;
skills: Skill[];
anchorRect: { top: number; left: number };
onSelect: (skillName: string) => void;
onClose: () => void;
}
// Batch 9.6: slash-command dropdown. Models FileMentionPopover's pattern —
// fixed-positioned popover, keyboard nav, click-outside-to-close. shadcn
// `Command` (cmdk) isn't installed in this project; per the addendum we use
// a plain div + Tailwind instead of pulling a new primitive autonomously.
// Case-insensitive prefix match on `name` only. Description is display-only
// in v1 (substring search across description is deferred to a polish batch).
function filterByPrefix(skills: Skill[], query: string): Skill[] {
const q = query.toLowerCase();
const filtered = q
? skills.filter((s) => s.name.toLowerCase().startsWith(q))
: skills;
// Stable alphabetical ordering matches the server's cache order (skills.ts
// sorts on name asc) but we re-sort here so a stale client cache doesn't
// surprise the user.
return [...filtered].sort((a, b) => a.name.localeCompare(b.name));
}
export function SkillSlashCommand({ query, skills, anchorRect, onSelect, onClose }: Props) {
const [highlightIndex, setHighlightIndex] = useState(0);
const popoverRef = useRef<HTMLDivElement>(null);
const filtered = useMemo(() => filterByPrefix(skills, query), [skills, query]);
useEffect(() => { setHighlightIndex(0); }, [query]);
// Arrow / Enter / Tab / Escape. Bound on document so keystrokes from the
// textarea reach the popover even though focus stays in the textarea.
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setHighlightIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : 0));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setHighlightIndex((prev) => (prev > 0 ? prev - 1 : filtered.length - 1));
} else if (e.key === 'Enter' || e.key === 'Tab') {
if (filtered.length === 0) return;
e.preventDefault();
const target = filtered[highlightIndex] ?? filtered[0];
if (target) onSelect(target.name);
} else if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
}
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [filtered, highlightIndex, onSelect, onClose]);
useEffect(() => {
function handleMouseDown(e: MouseEvent) {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener('mousedown', handleMouseDown);
return () => document.removeEventListener('mousedown', handleMouseDown);
}, [onClose]);
useEffect(() => {
const el = popoverRef.current?.querySelector('[data-highlighted="true"]');
if (el) el.scrollIntoView({ block: 'nearest' });
}, [highlightIndex]);
// Anchor sits above the input — translate(-100%) on Y so the dropdown
// expands upward from the anchor point rather than over the textarea.
const style = {
top: anchorRect.top,
left: anchorRect.left,
transform: 'translateY(-100%)',
} as const;
if (filtered.length === 0) {
return (
<div
ref={popoverRef}
className="fixed z-50 bg-popover border border-border rounded-md shadow min-w-[320px] p-2"
style={style}
>
<div className="text-xs text-muted-foreground px-2 py-1">
{query ? `No skill starts with "/${query}"` : 'No skills available'}
</div>
</div>
);
}
return (
<div
ref={popoverRef}
className="fixed z-50 bg-popover border border-border rounded-md shadow min-w-[320px] max-w-[420px] max-h-[320px] overflow-y-auto"
style={style}
>
{filtered.map((skill, i) => (
<button
key={skill.name}
type="button"
data-highlighted={i === highlightIndex}
className={cn(
'w-full text-left px-2.5 py-2 cursor-pointer block',
i === highlightIndex && 'bg-muted',
)}
onMouseEnter={() => setHighlightIndex(i)}
onMouseDown={(e) => {
// mousedown not click — click runs after blur/focus shuffles which
// can race with the textarea's onBlur close path.
e.preventDefault();
onSelect(skill.name);
}}
>
<div className="font-mono text-xs font-bold text-foreground">/{skill.name}</div>
<div
className="text-xs text-muted-foreground overflow-hidden"
style={{
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{skill.description}
</div>
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,36 @@
import { useChatStatus, type DerivedStatus } from '@/hooks/useChatStatus';
import { cn } from '@/lib/utils';
interface Props {
chatId: string | null | undefined;
className?: string;
}
const STATUS_CLASS: Record<DerivedStatus, string> = {
working: 'bg-amber-500 animate-pulse',
idle_warm: 'bg-emerald-500',
idle_cold: 'bg-muted-foreground/40',
error: 'bg-destructive',
};
const STATUS_LABEL: Record<DerivedStatus, string> = {
working: 'working',
idle_warm: 'idle',
idle_cold: 'idle',
error: 'error',
};
export function StatusDot({ chatId, className }: Props) {
const status = useChatStatus(chatId);
return (
<span
aria-label={`Status: ${STATUS_LABEL[status]}`}
title={STATUS_LABEL[status]}
className={cn(
'inline-block w-1.5 h-1.5 rounded-full shrink-0',
STATUS_CLASS[status],
className,
)}
/>
);
}

View File

@@ -0,0 +1,122 @@
import { useState } from 'react';
import { Check } from 'lucide-react';
import { toast } from 'sonner';
import { Card } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { THEMES, setTheme, useTheme, type ThemeId, type ThemeMode } from '@/lib/theme';
import { cn } from '@/lib/utils';
// v1.9: lifted out of pages/Settings.tsx so the SettingsPane Theme tab and
// the standalone /settings route render the same picker. Theme is global —
// not per-project, not per-session — so no contextual props are needed.
const MODES: { value: ThemeMode; label: string; hint: string }[] = [
{ value: 'dark', label: 'Dark', hint: 'Use the dark variant.' },
{ value: 'light', label: 'Light', hint: 'Use the light variant.' },
{ value: 'system', label: 'System', hint: 'Follow OS preference.' },
];
export function ThemePicker() {
const { id: currentId, mode: currentMode } = useTheme();
// Track the most recent in-flight pick so the picker can show a subtle
// "applying…" state on the targeted card while the PATCH is in flight.
const [pending, setPending] = useState<
{ kind: 'theme'; id: ThemeId } | { kind: 'mode'; mode: ThemeMode } | null
>(null);
async function pickTheme(id: ThemeId) {
if (id === currentId || pending) return;
setPending({ kind: 'theme', id });
try {
await setTheme(id, currentMode);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'failed to apply theme');
} finally {
setPending(null);
}
}
async function pickMode(mode: ThemeMode) {
if (mode === currentMode || pending) return;
setPending({ kind: 'mode', mode });
try {
await setTheme(currentId, mode);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'failed to apply mode');
} finally {
setPending(null);
}
}
return (
<div className="space-y-8">
<section className="space-y-3">
<h2 className="text-sm font-medium">Mode</h2>
<RadioGroup
value={currentMode}
onValueChange={(v) => void pickMode(v as ThemeMode)}
className="flex flex-wrap gap-4"
>
{MODES.map((m) => (
<div key={m.value} className="flex items-center gap-2">
<RadioGroupItem id={`mode-${m.value}`} value={m.value} />
<Label htmlFor={`mode-${m.value}`} className="cursor-pointer">
<span className="font-medium">{m.label}</span>
<span className="ml-2 text-xs text-muted-foreground">{m.hint}</span>
</Label>
</div>
))}
</RadioGroup>
</section>
<section className="space-y-3">
<h2 className="text-sm font-medium">Theme</h2>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{THEMES.map((t) => {
const isActive = t.id === currentId;
const isPending = pending?.kind === 'theme' && pending.id === t.id;
const isLightOnly = !t.supportsDark;
return (
<Card
key={t.id}
onClick={() => void pickTheme(t.id)}
className={cn(
'p-3 cursor-pointer transition-colors',
'hover:bg-accent/10',
isActive && 'ring-2 ring-ring',
isPending && 'opacity-60',
)}
>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="font-mono text-sm truncate">{t.name}</div>
<div className="text-xs text-muted-foreground">{t.family}</div>
</div>
{isActive && (
<span className="inline-flex items-center gap-1 text-xs text-primary shrink-0">
<Check className="size-3" /> Selected
</span>
)}
</div>
<div className="flex mt-2 rounded overflow-hidden border border-border/40">
{t.anchors.map((hex, i) => (
<div
key={i}
className="flex-1 h-6"
style={{ backgroundColor: hex }}
aria-hidden="true"
/>
))}
</div>
{isLightOnly && (
<div className="mt-2 text-xs text-muted-foreground italic">Light only</div>
)}
</Card>
);
})}
</div>
</section>
</div>
);
}

View File

@@ -1,102 +0,0 @@
import { useState } from 'react';
import type { ReactNode } from 'react';
import { ChevronRight, Wrench } from 'lucide-react';
import type { Message, ToolCall } from '@/api/types';
import { sessionEvents } from '@/hooks/sessionEvents';
interface Props {
message?: Message;
toolCall?: ToolCall;
}
// Same regex/heuristic as MessageBubble: paths ending in `.ext` with at
// least one `/`. Linkifies file paths emitted by tools like grep / find_files
// so they're clickable.
const PATH_REGEX = /([a-zA-Z0-9._/-]+\.[a-zA-Z0-9]+)/g;
function linkifyOutput(text: string): ReactNode[] {
const out: ReactNode[] = [];
let lastIdx = 0;
let idx = 0;
for (const match of text.matchAll(PATH_REGEX)) {
const matchedText = match[0];
const start = match.index ?? 0;
if (!matchedText.includes('/')) continue;
if (start > lastIdx) out.push(text.slice(lastIdx, start));
out.push(
<button
key={idx}
type="button"
onClick={() =>
sessionEvents.emit({
type: 'open_file_in_browser',
path: matchedText,
})
}
className="text-primary underline cursor-pointer hover:text-primary/80"
>
{matchedText}
</button>
);
lastIdx = start + matchedText.length;
idx += 1;
}
if (lastIdx < text.length) out.push(text.slice(lastIdx));
return out.length > 0 ? out : [text];
}
export function ToolCallCard({ message, toolCall }: Props) {
const [open, setOpen] = useState(false);
const tc = toolCall ?? message?.tool_calls?.[0];
const result = message?.tool_results;
const name = tc?.name ?? 'tool';
const args = tc?.args ?? {};
const error = result?.error;
const output = result?.output;
const truncated = result?.truncated;
return (
<div className="rounded-md border border-border bg-muted/30 text-sm overflow-hidden">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center gap-2 px-2.5 py-1.5 hover:bg-muted/60 text-left"
>
<ChevronRight
className={`size-3.5 transition-transform ${open ? 'rotate-90' : ''}`}
/>
<Wrench className="size-3.5 opacity-70" />
<span className="font-mono font-medium">{name}</span>
<span className="font-mono text-xs text-muted-foreground truncate min-w-0 flex-1">
{JSON.stringify(args)}
</span>
{error && (
<span className="text-xs text-destructive font-medium ml-2">error</span>
)}
{truncated && (
<span className="text-xs text-muted-foreground ml-2">truncated</span>
)}
</button>
{open && (
<div className="px-2.5 py-2 border-t bg-background/40">
{error ? (
<pre className="text-xs text-destructive font-mono whitespace-pre-wrap">
{error}
</pre>
) : output !== undefined ? (
<pre className="text-xs font-mono whitespace-pre-wrap overflow-x-auto max-h-72 overflow-y-auto">
{linkifyOutput(
typeof output === 'string'
? output
: JSON.stringify(output, null, 2)
)}
</pre>
) : (
<div className="text-xs text-muted-foreground">no result yet</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,64 @@
import { useState } from 'react';
import { ChevronRight } from 'lucide-react';
import { ToolCallLine, runStatus, type ToolRun } from './ToolCallLine';
interface Props {
// All runs must share the same tool name. Caller (MessageList grouping
// pass) enforces that invariant.
runs: ToolRun[];
}
export function ToolCallGroup({ runs }: Props) {
const [open, setOpen] = useState(false);
if (runs.length === 0) return null;
const toolName = runs[0]!.call.name;
const count = runs.length;
// Group-level status: pending if any are still running, error if any
// finished with an error, otherwise success. Matches the visual the user
// gets when scanning a long run of greps / view_files.
let pending = 0;
let errored = 0;
for (const r of runs) {
const s = runStatus(r);
if (s === 'pending') pending += 1;
else if (s === 'error') errored += 1;
}
const summaryParts: string[] = [];
if (pending > 0) summaryParts.push(`${pending} running`);
if (errored > 0) summaryParts.push(`${errored} failed`);
const summary = summaryParts.length > 0 ? ` (${summaryParts.join(', ')})` : '';
return (
<div className="rounded border border-border/60 bg-muted/20 text-xs">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center gap-1.5 px-2 py-1 hover:bg-muted/40 text-left"
>
<ChevronRight
className={`size-3 text-muted-foreground/60 shrink-0 transition-transform ${open ? 'rotate-90' : ''}`}
/>
<span className="text-muted-foreground/60 select-none shrink-0"></span>
<span className="font-mono text-foreground/90">
{count} {toolName} call{count === 1 ? '' : 's'}
</span>
{summary && (
<span className="text-muted-foreground truncate">{summary}</span>
)}
<span className="ml-auto text-muted-foreground/60 shrink-0">tap</span>
</button>
{open && (
<div className="border-t border-border/40 px-2 py-1 space-y-0.5">
{runs.map((run, i) => (
<ToolCallLine
key={`${run.call.id}-${i}`}
run={run}
insideGroup
/>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,167 @@
import { useState } from 'react';
import type { ReactNode } from 'react';
import { Check, ChevronRight, Loader2, X } from 'lucide-react';
import type { ToolCall, ToolResult } from '@/api/types';
import { sessionEvents } from '@/hooks/sessionEvents';
// v1.8.2: cap on the inline arg-summary length. Expanded view shows full
// args + full result, so this is purely a single-line render budget.
const ARG_SUMMARY_MAX = 60;
export interface ToolRun {
call: ToolCall;
// null while the call is in flight or the matching tool result hasn't
// arrived yet on the WS stream.
result: ToolResult | null;
}
function truncate(s: string, n: number): string {
return s.length > n ? s.slice(0, n - 1) + '…' : s;
}
// Per-tool argument summary mapping from the v1.8.2 spec. Goal is a single
// scannable line that surfaces the *what* (path / pattern) without
// overwhelming the chat with full JSON.
export function formatToolArgs(name: string, args: Record<string, unknown>): string {
if (name === 'view_file') {
const path = String(args.path ?? '');
const start = args.start_line;
const end = args.end_line;
if (typeof start === 'number' && typeof end === 'number') {
return truncate(`${path}:${start}-${end}`, ARG_SUMMARY_MAX);
}
if (typeof start === 'number') {
return truncate(`${path}:${start}`, ARG_SUMMARY_MAX);
}
return truncate(path, ARG_SUMMARY_MAX);
}
if (name === 'list_dir') {
return truncate(String(args.path ?? '.'), ARG_SUMMARY_MAX);
}
if (name === 'grep') {
const pattern = String(args.pattern ?? '');
const path = args.path ? ` ${String(args.path)}` : '';
return truncate(`"${pattern}"${path}`, ARG_SUMMARY_MAX);
}
if (name === 'find_files') {
return truncate(String(args.pattern ?? ''), ARG_SUMMARY_MAX);
}
if (name === 'git_status') {
return '';
}
// Unknown tool — surface first arg value or the literal {} so the user can
// see something happened. Forward-compatible with future tools.
const keys = Object.keys(args);
if (keys.length === 0) return '{}';
const first = keys[0]!;
return truncate(`${first}: ${String(args[first])}`, ARG_SUMMARY_MAX);
}
export function runStatus(run: ToolRun): 'pending' | 'success' | 'error' {
if (run.result === null) return 'pending';
if (run.result.error) return 'error';
return 'success';
}
// Path-shaped paths in tool output text get a click handler so users can
// jump to the file. Same heuristic as MessageBubble.linkifyPaths.
const PATH_REGEX = /([a-zA-Z0-9._/-]+\.[a-zA-Z0-9]+)/g;
function linkifyOutput(text: string): ReactNode[] {
const out: ReactNode[] = [];
let lastIdx = 0;
let idx = 0;
for (const match of text.matchAll(PATH_REGEX)) {
const matchedText = match[0];
const start = match.index ?? 0;
if (!matchedText.includes('/')) continue;
if (start > lastIdx) out.push(text.slice(lastIdx, start));
out.push(
<button
key={idx}
type="button"
onClick={() =>
sessionEvents.emit({ type: 'open_file_in_browser', path: matchedText })
}
className="text-primary underline cursor-pointer hover:text-primary/80"
>
{matchedText}
</button>
);
lastIdx = start + matchedText.length;
idx += 1;
}
if (lastIdx < text.length) out.push(text.slice(lastIdx));
return out.length > 0 ? out : [text];
}
interface Props {
run: ToolRun;
// When rendered inside a ToolCallGroup the line is already nested under a
// shared header, so the leading arrow is dropped to avoid double indent.
insideGroup?: boolean;
}
export function ToolCallLine({ run, insideGroup }: Props) {
const [open, setOpen] = useState(false);
const status = runStatus(run);
const args = run.call.args ?? {};
const summary = formatToolArgs(run.call.name, args);
return (
<div className="text-xs">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex items-center gap-1.5 w-full text-left hover:bg-muted/40 rounded px-1 py-0.5 -mx-1"
>
{!insideGroup && (
<span className="text-muted-foreground/60 select-none shrink-0"></span>
)}
<ChevronRight
className={`size-3 text-muted-foreground/60 shrink-0 transition-transform ${open ? 'rotate-90' : ''}`}
/>
<span className="font-mono text-foreground/90 shrink-0">{run.call.name}</span>
{summary && (
<span className="font-mono text-muted-foreground truncate min-w-0 flex-1">
{summary}
</span>
)}
{!summary && <span className="flex-1" />}
<span className="shrink-0 ml-1">
{status === 'pending' && (
<Loader2 className="size-3 text-muted-foreground animate-spin" aria-label="running" />
)}
{status === 'success' && (
<Check className="size-3 text-emerald-500" aria-label="success" />
)}
{status === 'error' && (
<X className="size-3 text-destructive" aria-label="error" />
)}
</span>
</button>
{open && (
<div className="ml-5 mt-1 mb-1 space-y-1">
<pre className="text-[10px] text-muted-foreground font-mono whitespace-pre-wrap break-all bg-muted/30 rounded px-2 py-1">
{JSON.stringify(args, null, 2)}
</pre>
{run.result && (
<pre className="text-[11px] font-mono whitespace-pre-wrap bg-muted/30 rounded px-2 py-1 max-h-72 overflow-y-auto">
{run.result.error ? (
<span className="text-destructive">{run.result.error}</span>
) : (
linkifyOutput(
typeof run.result.output === 'string'
? run.result.output
: JSON.stringify(run.result.output, null, 2)
)
)}
{run.result.truncated && (
<div className="text-muted-foreground/60 mt-1"> output truncated </div>
)}
</pre>
)}
</div>
)}
</div>
);
}

View File

@@ -1,14 +1,15 @@
import { useCallback, useEffect } from 'react';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import { PanelRight, MessageSquare, Terminal, Bot } from 'lucide-react';
import type { Chat, WorkspacePane } from '@/api/types';
import { useWorkspacePanes, MAX_PANES } from '@/hooks/useWorkspacePanes';
import { useSessionChats } from '@/hooks/useSessionChats';
import { useEffect, useMemo, useState } from 'react';
import { PanelRight, MessageSquare, Terminal, Bot, Clipboard, Plus, X } from 'lucide-react';
import type { Chat, Project, Session, WorkspacePane } from '@/api/types';
import { MAX_PANES, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes';
import type { UseSessionChatsResult } from '@/hooks/useSessionChats';
import { useViewport } from '@/hooks/useViewport';
import { terminalsRegistry } from '@/lib/events';
import { ChatPane } from '@/components/panes/ChatPane';
import { SettingsPane } from '@/components/panes/SettingsPane';
import { TerminalPane } from '@/components/panes/TerminalPane';
import { ChatTabBar } from '@/components/ChatTabBar';
import { SessionLandingPage } from '@/components/SessionLandingPage';
import { SwipeablePaneTab } from '@/components/SwipeablePaneTab';
import {
DropdownMenu,
DropdownMenuContent,
@@ -20,14 +21,32 @@ import { cn } from '@/lib/utils';
interface Props {
sessionId: string;
projectId: string;
// Batch 9: threaded down to ChatPane → ChatInput → AgentPicker.
agentId?: string | null;
onAgentChange?: (agentId: string | null) => void | Promise<void>;
// v1.8: panes + chats hoisted into Session.tsx so the mobile header pill
// (MobileTabSwitcher) can share state with the pane grid.
panesHook: UseWorkspacePanesResult;
chatsHook: UseSessionChatsResult;
// v1.9: passed through to SettingsPane when one is mounted in the grid.
session: Session;
project: Project | null;
}
export function Workspace({ sessionId, projectId }: Props) {
export function Workspace({
sessionId,
projectId,
agentId,
onAgentChange,
panesHook,
chatsHook,
session,
project,
}: Props) {
const {
panes,
activePaneIdx,
setActivePaneIdx,
activePaneIdxRef,
openChatInPane,
switchTab,
removeTab,
@@ -37,8 +56,6 @@ export function Workspace({ sessionId, projectId }: Props) {
showLandingPage,
addSplitPane,
removePane,
removeChatFromPanes,
initializeFirstChatIfEmpty,
handlePaneDragStart,
handlePaneDragOver,
handlePaneDragLeave,
@@ -46,15 +63,7 @@ export function Workspace({ sessionId, projectId }: Props) {
handlePaneDragEnd,
dragOverIdx,
draggingIdxRef,
} = useWorkspacePanes(sessionId);
// Thin wrapper so useSessionChats can route open_chat_in_active_pane events
// without knowing about pane indexing.
const openChatInActivePane = useCallback(
(chatId: string) => openChatInPane(activePaneIdxRef.current, chatId),
[openChatInPane, activePaneIdxRef],
);
} = panesHook;
const {
chats,
createChat,
@@ -63,47 +72,44 @@ export function Workspace({ sessionId, projectId }: Props) {
deleteChat,
renameChat,
handleLandingSend,
} = useSessionChats(sessionId, {
removeChatFromPanes,
openChatInPane,
openChatInActivePane,
initializeFirstChatIfEmpty,
});
} = chatsHook;
const { isMobile } = useViewport();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const location = useLocation();
// URL -> state (mobile only). Handles deep-link arrival and Back button
// history pops. On a bare URL (no ?pane), reset to first pane so Back
// from a ?pane URL returns the user to a sensible default.
// v1.9: workspace-level maximize state for the settings pane. CSS-only:
// sibling panes get display:none, the maximized pane fills the grid cell.
// ESC listener only mounted while maximized. Mobile is always full-width
// for a single pane so maximize doesn't apply.
const [maximized, setMaximized] = useState(false);
const settingsIdx = panes.findIndex((p) => p.kind === 'settings');
// Esc semantics: maximized → restore; otherwise → close settings pane (only
// when it's the active pane). Bail when the user is typing in a field or
// inside an open dialog so we don't eat their cancel keystroke.
useEffect(() => {
if (!isMobile || panes.length === 0) return;
const paneId = searchParams.get('pane');
if (!paneId) {
if (activePaneIdx !== 0) setActivePaneIdx(0);
return;
}
const idx = panes.findIndex((p) => p.id === paneId);
if (idx >= 0 && idx !== activePaneIdx) setActivePaneIdx(idx);
}, [isMobile, searchParams, panes, activePaneIdx, setActivePaneIdx]);
// Switch active pane and push URL (mobile only). User-initiated only;
// never called from URL-sync effect.
const switchActivePane = useCallback(
(idx: number) => {
setActivePaneIdx(idx);
if (isMobile) {
const pane = panes[idx];
if (!pane) return;
const params = new URLSearchParams(location.search);
params.set('pane', pane.id);
navigate(`${location.pathname}?${params.toString()}`);
if (settingsIdx < 0) return;
function onKey(e: KeyboardEvent) {
if (e.key !== 'Escape') return;
const t = e.target;
if (t instanceof HTMLElement) {
if (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable) return;
if (t.closest('[role="dialog"]')) return;
}
},
[setActivePaneIdx, isMobile, panes, navigate, location.pathname, location.search],
);
if (maximized) {
setMaximized(false);
} else if (activePaneIdx === settingsIdx) {
removePane(settingsIdx);
}
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [maximized, settingsIdx, activePaneIdx, removePane]);
// If the settings pane was closed (no longer in panes) while maximized,
// clear the maximize state so the grid renders normally.
useEffect(() => {
if (maximized && settingsIdx < 0) setMaximized(false);
}, [maximized, settingsIdx]);
function chatsForPane(pane: WorkspacePane): Chat[] {
return pane.chatIds
@@ -111,17 +117,19 @@ export function Workspace({ sessionId, projectId }: Props) {
.filter((c): c is Chat => c !== undefined);
}
function paneLabel(pane: WorkspacePane): string {
const activeChatId = pane.chatId;
if (activeChatId) {
const chat = chats.find((c) => c.id === activeChatId);
if (chat) return chat.name ?? 'New chat';
// v1.10 booterm: per-terminal label used by the registry that powers the
// MessageBubble "Send to terminal" submenu. Numbered in workspace order.
const terminalLabels = useMemo(() => {
const out = new Map<string, string>();
let n = 0;
for (const p of panes) {
if (p.kind === 'terminal') {
n += 1;
out.set(p.id, `Terminal ${n}`);
}
}
if (pane.kind === 'chat') return 'Chat';
if (pane.kind === 'terminal') return 'Terminal';
if (pane.kind === 'agent') return 'Agent';
return 'Empty';
}
return out;
}, [panes]);
return (
<div className="flex flex-col h-full min-h-0">
@@ -131,10 +139,12 @@ export function Workspace({ sessionId, projectId }: Props) {
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={panes.length >= MAX_PANES}
// v1.9: settings panes excluded from the MAX cap (decision c).
disabled={panes.filter((p) => p.kind !== 'settings').length >= MAX_PANES}
className={cn(
'flex items-center gap-1 text-xs px-2 py-1 rounded hover:bg-muted',
panes.length >= MAX_PANES && 'opacity-40 cursor-not-allowed hover:bg-transparent'
panes.filter((p) => p.kind !== 'settings').length >= MAX_PANES &&
'opacity-40 cursor-not-allowed hover:bg-transparent'
)}
>
<PanelRight size={14} />
@@ -156,32 +166,36 @@ export function Workspace({ sessionId, projectId }: Props) {
</div>
)}
{isMobile && panes.length > 1 && (
<div className="flex items-center gap-1 overflow-x-auto border-b border-border bg-muted/10 px-2 py-1 shrink-0">
{panes.map((pane, idx) => (
<SwipeablePaneTab
key={pane.id}
label={paneLabel(pane)}
isActive={idx === activePaneIdx}
onTap={() => switchActivePane(idx)}
onClose={() => removePane(idx)}
canClose={panes.length > 1}
/>
))}
</div>
)}
{/* v1.8: mobile multi-pane SwipeablePaneTab strip removed; the header
pill (MobileTabSwitcher) is the mobile pane switcher. */}
<div
className={cn('flex-1 min-h-0', isMobile ? 'flex' : 'grid')}
style={
isMobile
? undefined
: { gridTemplateColumns: `repeat(${panes.length}, minmax(0, 1fr))` }
: maximized && settingsIdx >= 0
? { gridTemplateColumns: 'minmax(0, 1fr)' }
: { gridTemplateColumns: `repeat(${panes.length}, minmax(0, 1fr))` }
}
>
{panes.map((pane, idx) => {
const visible = !isMobile || idx === activePaneIdx;
if (!visible) return null;
const isSettings = pane.kind === 'settings';
const isTerminal = pane.kind === 'terminal';
// v1.9: when maximized, hide every pane except the settings one.
// display:none keeps the React tree mounted so streams / drafts
// survive the toggle without re-mount cost.
const hiddenForMaximize = !isMobile && maximized && idx !== settingsIdx;
const visible = (!isMobile || idx === activePaneIdx) && !hiddenForMaximize;
if (!visible) {
if (hiddenForMaximize) {
return <div key={pane.id} className="hidden" />;
}
return null;
}
// Terminal panes own their tab strip (no chats, no ChatTabBar) and
// are not drag-reorderable for now — keeps the layout grid simple.
const isChromeless = isSettings || isTerminal;
return (
<div
key={pane.id}
@@ -193,33 +207,127 @@ export function Workspace({ sessionId, projectId }: Props) {
'before:absolute before:inset-y-0 before:left-0 before:w-0.5 before:bg-primary before:z-10'
)}
onClick={() => setActivePaneIdx(idx)}
onDragOver={!isMobile && panes.length > 1 ? handlePaneDragOver(idx) : undefined}
onDragLeave={!isMobile && panes.length > 1 ? handlePaneDragLeave : undefined}
onDrop={!isMobile && panes.length > 1 ? handlePaneDrop(idx) : undefined}
onDragOver={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragOver(idx) : undefined}
onDragLeave={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragLeave : undefined}
onDrop={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDrop(idx) : undefined}
>
<div
draggable={!isMobile && panes.length > 1}
onDragStart={!isMobile && panes.length > 1 ? handlePaneDragStart(idx) : undefined}
onDragEnd={!isMobile && panes.length > 1 ? handlePaneDragEnd : undefined}
draggable={!isMobile && !isChromeless && panes.length > 1}
onDragStart={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragStart(idx) : undefined}
onDragEnd={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragEnd : undefined}
>
<ChatTabBar
pane={pane}
tabs={chatsForPane(pane)}
onSwitchTab={(tabIdx) => switchTab(idx, tabIdx)}
onRemoveTab={(chatId) => removeTab(idx, chatId)}
onCloseOthers={(chatId) => closeOtherTabs(idx, chatId)}
onCloseToRight={(chatId) => closeTabsToRight(idx, chatId)}
onCloseAll={() => closeAllTabs(idx)}
onNewChat={() => void createChat(idx)}
onShowHistory={() => showLandingPage(idx)}
onRename={renameChat}
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
/>
{/* Hidden on mobile per v1.8; settings + terminal panes own
their own header (no chats, so no ChatTabBar). */}
{!isMobile && !isChromeless && (
<ChatTabBar
pane={pane}
tabs={chatsForPane(pane)}
onSwitchTab={(tabIdx) => switchTab(idx, tabIdx)}
onRemoveTab={(chatId) => removeTab(idx, chatId)}
onCloseOthers={(chatId) => closeOtherTabs(idx, chatId)}
onCloseToRight={(chatId) => closeTabsToRight(idx, chatId)}
onCloseAll={() => closeAllTabs(idx)}
onAddPane={(kind) => {
if (kind === 'chat') void createChat(idx);
else addSplitPane(kind);
}}
onShowHistory={() => showLandingPage(idx)}
onRename={renameChat}
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
/>
)}
{isTerminal && (
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-2 py-1 shrink-0">
<Terminal size={12} className="text-muted-foreground" />
<span className="text-xs text-muted-foreground">
{terminalLabels.get(pane.id) ?? 'Terminal'}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
className="ml-auto inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:size-7"
aria-label="New pane"
title="New pane"
>
<Plus size={12} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-40">
<DropdownMenuItem onSelect={() => addSplitPane('chat')}>
<MessageSquare size={14} /> New chat
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => addSplitPane('terminal')}>
<Terminal size={14} /> New terminal
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => addSplitPane('agent')}>
<Bot size={14} /> New agent
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* v1.10.4: iOS Safari restricts navigator.clipboard.readText
outside direct user gestures. A real button click IS a
gesture, so this works where keystroke-driven paste may
not on iOS. The action lives in TerminalPane behind the
registry's paste() callback. */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
terminalsRegistry.get(pane.id)?.paste();
}}
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:size-7"
aria-label="Paste from clipboard"
title="Paste from clipboard"
>
<Clipboard size={12} />
</button>
{panes.length > 1 && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
removePane(idx);
}}
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:size-7"
aria-label="Close terminal pane"
title="Close terminal pane"
>
<X size={12} />
</button>
)}
</div>
)}
</div>
<div className="flex-1 min-h-0 overflow-hidden">
{pane.kind === 'chat' && pane.chatId ? (
<ChatPane sessionId={sessionId} chatId={pane.chatId} projectId={projectId} sessionChats={chats} />
{isSettings && project ? (
<SettingsPane
session={session}
project={project}
maximized={maximized}
onToggleMaximize={() => setMaximized((v) => !v)}
onClose={() => removePane(idx)}
isMobile={isMobile}
/>
) : isTerminal ? (
<TerminalPane
sessionId={sessionId}
paneId={pane.id}
label={terminalLabels.get(pane.id) ?? 'Terminal'}
active={idx === activePaneIdx}
/>
) : pane.kind === 'chat' && pane.chatId ? (
<ChatPane
sessionId={sessionId}
chatId={pane.chatId}
projectId={projectId}
agentId={agentId}
onAgentChange={onAgentChange}
sessionChats={chats}
webSearchEnabled={session.web_search_enabled}
/>
) : (
<SessionLandingPage
sessionId={sessionId}

View File

@@ -7,6 +7,7 @@ import { useChatContextStats } from '@/hooks/useChatContextStats';
import { MessageList } from '@/components/MessageList';
import { ChatInput } from '@/components/ChatInput';
import { ChatContextPopover } from '@/components/ChatContextPopover';
import { ContextBar } from '@/components/ContextBar';
import {
DropdownMenu,
DropdownMenuContent,
@@ -18,10 +19,17 @@ interface Props {
sessionId: string;
chatId: string;
projectId: string;
// Batch 9: optional, threaded down to ChatInput's agent picker.
agentId?: string | null;
onAgentChange?: (agentId: string | null) => void | Promise<void>;
sessionChats?: import('@/api/types').Chat[];
// v1.9: threaded down to ChatInput's + menu (Web search quick toggle).
// null means "inherit project default" — ChatInput PATCHes with the
// opposite of the effective value.
webSearchEnabled?: boolean | null;
}
export function ChatPane({ sessionId, chatId, projectId, sessionChats }: Props) {
export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange, sessionChats, webSearchEnabled }: Props) {
const stream = useSessionStream(sessionId);
const lastErrorRef = useRef<string | null>(null);
const [queue, setQueue] = useState<string[]>([]);
@@ -89,6 +97,18 @@ export function ChatPane({ sessionId, chatId, projectId, sessionChats }: Props)
}
}, [chatId]);
// Batch 9.6: slash-command dispatch. Sent regardless of streaming state —
// matches the existing /compact precedent (which also fires immediately).
// Empty args go to the server as null; the server fills in a default user
// message ("Apply this skill.") so the model has something to act on.
const handleSlashCommand = useCallback(async (skillName: string, userMessage: string) => {
try {
await api.chats.skillInvoke(chatId, skillName, userMessage.length > 0 ? userMessage : null);
} catch (err) {
toast.error(err instanceof Error ? err.message : `/${skillName} failed`);
}
}, [chatId]);
function removeQueued(idx: number) {
setQueue((prev) => prev.filter((_, i) => i !== idx));
}
@@ -106,6 +126,10 @@ export function ChatPane({ sessionId, chatId, projectId, sessionChats }: Props)
return (
<div className="flex flex-col h-full min-h-0">
{/* v1.11.2: persistent context-usage indicator. Renders null when there
are no assistant messages yet (fresh chat). shrink-0 keeps it out of
the MessageList scroll region — bar stays pinned, list scrolls. */}
<ContextBar messages={chatMessages} />
<MessageList messages={chatMessages} sessionChats={sessionChats} />
{/* Queued messages */}
@@ -167,7 +191,19 @@ export function ChatPane({ sessionId, chatId, projectId, sessionChats }: Props)
<div className="relative">
<ChatContextPopover stats={contextStats} />
<ChatInput disabled={false} projectId={projectId} onSend={handleSend} onForceSend={streaming ? handleForceSend : undefined} />
<ChatInput
disabled={false}
projectId={projectId}
sessionId={sessionId}
agentId={agentId}
onAgentChange={onAgentChange}
webSearchEnabled={webSearchEnabled}
onSend={handleSend}
onForceSend={streaming ? handleForceSend : undefined}
onSlashCommand={handleSlashCommand}
chatId={chatId}
chatLabel={sessionChats?.find((c) => c.id === chatId)?.name ?? 'Chat'}
/>
</div>
</div>
);

View File

@@ -0,0 +1,530 @@
import { useEffect, useState } from 'react';
import { Archive, Maximize2, Minimize2, X } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import type { Project, Session } from '@/api/types';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { ModelPicker } from '@/components/ModelPicker';
import { ThemePicker } from '@/components/ThemePicker';
import { cn } from '@/lib/utils';
type Section = 'session' | 'project' | 'theme';
interface Props {
session: Session;
project: Project;
maximized: boolean;
onToggleMaximize: () => void;
onClose: () => void;
isMobile: boolean;
}
// v1.9: hand-rolled Switch primitive. No shadcn switch in the existing
// ui/ set and the dispatch said don't pnpm dlx for v1.9 either. Single
// purpose — clicking flips aria-checked + calls onCheckedChange.
function Switch({
checked,
onCheckedChange,
disabled,
id,
}: {
checked: boolean;
onCheckedChange: (v: boolean) => void;
disabled?: boolean;
id?: string;
}) {
return (
<button
id={id}
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => onCheckedChange(!checked)}
className={cn(
'relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full transition-colors',
checked ? 'bg-primary' : 'bg-muted',
disabled && 'opacity-50 cursor-not-allowed',
)}
>
<span
className={cn(
'inline-block h-4 w-4 transform rounded-full bg-background transition-transform',
checked ? 'translate-x-[1.125rem]' : 'translate-x-0.5',
)}
/>
</button>
);
}
export function SettingsPane({ session, project, maximized, onToggleMaximize, onClose, isMobile }: Props) {
const [activeSection, setActiveSection] = useState<Section>('session');
return (
<div className="flex flex-col h-full min-h-0">
<div className="flex items-center gap-2 border-b border-border bg-muted/20 px-3 py-1.5 shrink-0">
<div className="flex items-center gap-1 flex-1 min-w-0">
{(['session', 'project', 'theme'] as const).map((s) => (
<button
key={s}
type="button"
onClick={() => setActiveSection(s)}
className={cn(
'text-xs px-2 py-1 rounded capitalize',
activeSection === s
? 'bg-background text-foreground'
: 'text-muted-foreground hover:bg-muted',
)}
>
{s}
</button>
))}
</div>
{!isMobile && (
<button
type="button"
onClick={onToggleMaximize}
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label={maximized ? 'Restore' : 'Maximize'}
title={maximized ? 'Restore (Esc)' : 'Maximize'}
>
{maximized ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
</button>
)}
<button
type="button"
onClick={onClose}
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Close settings"
title="Close (Esc)"
>
<X size={14} />
</button>
</div>
<div className="flex-1 overflow-y-auto">
<div className="max-w-[720px] mx-auto w-full px-4 py-4 space-y-6">
{activeSection === 'session' && <SessionSection session={session} project={project} />}
{activeSection === 'project' && <ProjectSection project={project} />}
{activeSection === 'theme' && <ThemePicker />}
</div>
</div>
</div>
);
}
function SessionSection({ session, project }: { session: Session; project: Project }) {
const [name, setName] = useState(session.name);
const [systemPrompt, setSystemPrompt] = useState(session.system_prompt);
// v1.9: tri-state on the wire (null = inherit). UI surfaces a 3-way toggle
// via "Inherit project default" checkbox plus the override switch.
const [webSearch, setWebSearch] = useState<boolean | null>(session.web_search_enabled);
const [saving, setSaving] = useState(false);
// v1.9: bulk-archive chats. Two-step: openChatsCount → confirm dialog →
// archiveAllChats. Server publishes one chat_archived frame per id so
// useSidebar / chat lists update incrementally.
const [archiveOpen, setArchiveOpen] = useState(false);
const [archiveCount, setArchiveCount] = useState(0);
const [archiving, setArchiving] = useState(false);
useEffect(() => {
setName(session.name);
setSystemPrompt(session.system_prompt);
setWebSearch(session.web_search_enabled);
}, [session.id, session.name, session.system_prompt, session.web_search_enabled]);
const dirty =
name !== session.name ||
systemPrompt !== session.system_prompt ||
webSearch !== session.web_search_enabled;
const effectiveWebSearch = webSearch ?? project.default_web_search_enabled;
const projectPreview = project.default_system_prompt.trim().slice(0, 200);
async function save() {
if (saving) return;
setSaving(true);
try {
await api.sessions.update(session.id, {
name: name.trim() || session.name,
system_prompt: systemPrompt,
web_search_enabled: webSearch,
});
toast.success('Session saved');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'save failed');
} finally {
setSaving(false);
}
}
async function resetSystemPrompt() {
if (saving) return;
setSaving(true);
try {
await api.sessions.update(session.id, { system_prompt: '' });
toast.success('Reset to project default');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'reset failed');
} finally {
setSaving(false);
}
}
async function openArchiveDialog() {
if (archiving) return;
try {
const { count } = await api.sessions.openChatsCount(session.id);
if (count === 0) {
toast('No open chats to archive.');
return;
}
setArchiveCount(count);
setArchiveOpen(true);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'failed to count chats');
}
}
async function confirmArchive() {
if (archiving) return;
setArchiving(true);
try {
const { archived } = await api.sessions.archiveAllChats(session.id);
toast.success(`Archived ${archived} chat${archived === 1 ? '' : 's'}`);
setArchiveOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'archive failed');
} finally {
setArchiving(false);
}
}
return (
<div className="space-y-6">
<div className="space-y-1.5">
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Session name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full bg-background border border-border rounded px-2 py-1.5 text-sm outline-none focus:border-ring"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Model
</label>
<div className="inline-flex items-center rounded-full bg-muted/40 hover:bg-muted/70 px-1">
<ModelPicker
value={session.model}
onChange={async (model) => {
try {
await api.sessions.update(session.id, { model });
toast.success('Model updated');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'failed to set model');
}
}}
/>
</div>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-3">
<label htmlFor="session-web-search" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Web search
</label>
<Switch
id="session-web-search"
checked={effectiveWebSearch}
onCheckedChange={(v) => setWebSearch(v)}
/>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<input
type="checkbox"
id="session-web-search-inherit"
checked={webSearch === null}
onChange={(e) => setWebSearch(e.target.checked ? null : project.default_web_search_enabled)}
/>
<label htmlFor="session-web-search-inherit" className="cursor-pointer">
Inherit project default ({project.default_web_search_enabled ? 'on' : 'off'})
</label>
</div>
<p className="text-xs text-muted-foreground italic">
Plumbed for Batch 8 (web_search tool). No effect yet.
</p>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-3">
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
System prompt
</label>
<button
type="button"
onClick={() => void resetSystemPrompt()}
disabled={saving || session.system_prompt === ''}
className="text-xs text-muted-foreground hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed"
>
Reset to project default
</button>
</div>
<Textarea
value={systemPrompt}
onChange={(e) => setSystemPrompt(e.target.value)}
rows={6}
className="resize-y min-h-[120px] max-h-[60vh]"
placeholder="Per-session override (optional). Empty = inherit project default."
/>
{systemPrompt.trim().length === 0 && projectPreview.length > 0 && (
<p className="text-xs text-muted-foreground">
Falls back to project default: <span className="italic">{projectPreview}{projectPreview.length === 200 ? '…' : ''}</span>
</p>
)}
</div>
<div className="flex justify-end gap-2">
<Button onClick={() => void save()} disabled={!dirty || saving}>
{saving ? 'Saving…' : 'Save'}
</Button>
</div>
<div className="border-t pt-4">
<Button
variant="outline"
onClick={() => void openArchiveDialog()}
disabled={archiving}
className="gap-1.5"
>
<Archive size={14} /> Archive all chats
</Button>
</div>
<Dialog open={archiveOpen} onOpenChange={(open) => { if (!archiving) setArchiveOpen(open); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Archive all chats?</DialogTitle>
<DialogDescription>
Archive {archiveCount} open chat{archiveCount === 1 ? '' : 's'} in this session?
Archived chats stay accessible via the archive view.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setArchiveOpen(false)} disabled={archiving}>
Cancel
</Button>
<Button onClick={() => void confirmArchive()} disabled={archiving}>
{archiving ? 'Archiving…' : `Archive ${archiveCount}`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function ProjectSection({ project }: { project: Project }) {
const [name, setName] = useState(project.name);
const [defaultPrompt, setDefaultPrompt] = useState(project.default_system_prompt);
const [defaultWebSearch, setDefaultWebSearch] = useState(project.default_web_search_enabled);
const [saving, setSaving] = useState(false);
// v1.9: bulk-archive sessions. Same shape as the chats-archive flow in
// SessionSection — count, confirm, fire.
const [archiveOpen, setArchiveOpen] = useState(false);
const [archiveCount, setArchiveCount] = useState(0);
const [archiving, setArchiving] = useState(false);
useEffect(() => {
setName(project.name);
setDefaultPrompt(project.default_system_prompt);
setDefaultWebSearch(project.default_web_search_enabled);
}, [
project.id,
project.name,
project.default_system_prompt,
project.default_web_search_enabled,
]);
const dirty =
name !== project.name ||
defaultPrompt !== project.default_system_prompt ||
defaultWebSearch !== project.default_web_search_enabled;
async function save() {
if (saving) return;
setSaving(true);
try {
await api.projects.update(project.id, {
name: name.trim() || project.name,
default_system_prompt: defaultPrompt,
default_web_search_enabled: defaultWebSearch,
});
toast.success('Project saved');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'save failed');
} finally {
setSaving(false);
}
}
async function clearDefaultPrompt() {
if (saving) return;
setSaving(true);
try {
await api.projects.update(project.id, { default_system_prompt: '' });
toast.success('Cleared');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'clear failed');
} finally {
setSaving(false);
}
}
async function openArchiveDialog() {
if (archiving) return;
try {
const { count } = await api.projects.openSessionsCount(project.id);
if (count === 0) {
toast('No open sessions to archive.');
return;
}
setArchiveCount(count);
setArchiveOpen(true);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'failed to count sessions');
}
}
async function confirmArchive() {
if (archiving) return;
setArchiving(true);
try {
const { archived } = await api.projects.archiveAllSessions(project.id);
toast.success(`Archived ${archived} session${archived === 1 ? '' : 's'}`);
setArchiveOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'archive failed');
} finally {
setArchiving(false);
}
}
return (
<div className="space-y-6">
<div className="space-y-1.5">
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Project name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full bg-background border border-border rounded px-2 py-1.5 text-sm outline-none focus:border-ring"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Root path
</label>
<div className="font-mono text-xs text-muted-foreground bg-muted/40 rounded px-2 py-1.5 select-all">
{project.path}
</div>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-3">
<label htmlFor="project-default-web-search" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Default web search
</label>
<Switch
id="project-default-web-search"
checked={defaultWebSearch}
onCheckedChange={setDefaultWebSearch}
/>
</div>
<p className="text-xs text-muted-foreground italic">
Applies to new sessions only. Plumbed for Batch 8.
</p>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-3">
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Default system prompt
</label>
<button
type="button"
onClick={() => void clearDefaultPrompt()}
disabled={saving || project.default_system_prompt === ''}
className="text-xs text-muted-foreground hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed"
>
Clear
</button>
</div>
<Textarea
value={defaultPrompt}
onChange={(e) => setDefaultPrompt(e.target.value)}
rows={6}
className="resize-y min-h-[120px] max-h-[60vh]"
placeholder="Prepended to every new session's system prompt (when its own is empty). Empty = no project default."
/>
</div>
<p className="text-xs text-muted-foreground">
Existing sessions are not affected by changes here.
</p>
<div className="flex justify-end gap-2">
<Button onClick={() => void save()} disabled={!dirty || saving}>
{saving ? 'Saving…' : 'Save'}
</Button>
</div>
<div className="border-t pt-4">
<Button
variant="outline"
onClick={() => void openArchiveDialog()}
disabled={archiving}
className="gap-1.5"
>
<Archive size={14} /> Archive all sessions
</Button>
</div>
<Dialog open={archiveOpen} onOpenChange={(open) => { if (!archiving) setArchiveOpen(open); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Archive all sessions?</DialogTitle>
<DialogDescription>
Archive {archiveCount} open session{archiveCount === 1 ? '' : 's'} in this project?
Archived sessions stay accessible via the archive view.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setArchiveOpen(false)} disabled={archiving}>
Cancel
</Button>
<Button onClick={() => void confirmArchive()} disabled={archiving}>
{archiving ? 'Archiving…' : `Archive ${archiveCount}`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,42 @@
import * as React from "react"
import { RadioGroup as RadioGroupPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid w-full gap-2", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"group/radio-group-item peer relative flex aspect-square size-4 shrink-0 rounded-full border border-input outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="flex size-4 items-center justify-center"
>
<span className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary-foreground" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }

View File

@@ -2,7 +2,7 @@
// across hooks (e.g. AI rename arriving via WS in the session view needs to
// also refresh the sidebar's session list).
import type { Chat, Project, Session } from '@/api/types';
import type { Chat, ErrorReason, Project, Session } from '@/api/types';
import type { Attachment } from '@/lib/attachments';
export interface SessionRenamedEvent {
@@ -62,6 +62,14 @@ export interface OpenChatInActivePaneEvent {
chat_id: string;
}
// Client-side event fired by the sidebar Settings button when a session is
// currently mounted. Session.tsx subscribes and calls
// panesHook.toggleSettingsPane() (open on first click, close on second).
// Sidebar handles the no-session case by navigating to /settings directly.
export interface OpenSettingsPaneEvent {
type: 'open_settings_pane';
}
export interface SessionArchivedEvent {
type: 'session_archived';
session_id: string;
@@ -115,6 +123,19 @@ export interface ProjectUpdatedEvent {
name: string;
}
// v1.8 mobile-tabs: broadcast on user channel from inference.ts so any device
// subscribed sees a chat working/idle/error. Frontend stores per-chat; panes
// derive their dot from pane.activeChatId.
// v1.8.2: optional `reason` carries a machine-readable code when status is
// 'error'. UI prefers reason for inline error rendering.
export interface ChatStatusEvent {
type: 'chat_status';
chat_id: string;
status: 'working' | 'idle' | 'error';
at: string;
reason?: ErrorReason;
}
export type SessionEvent =
| SessionRenamedEvent
| ProjectCreatedEvent
@@ -126,6 +147,7 @@ export type SessionEvent =
| OpenFileInBrowserEvent
| AttachChatFileEvent
| OpenChatInActivePaneEvent
| OpenSettingsPaneEvent
| SessionArchivedEvent
| ChatCreatedEvent
| ChatUpdatedEvent
@@ -134,7 +156,8 @@ export type SessionEvent =
| ChatDeletedEvent
| ProjectArchivedEvent
| ProjectUnarchivedEvent
| ProjectUpdatedEvent;
| ProjectUpdatedEvent
| ChatStatusEvent;
type Listener = (event: SessionEvent) => void;
const listeners = new Set<Listener>();

View File

@@ -0,0 +1,71 @@
import { useEffect, useState } from 'react';
import { sessionEvents } from './sessionEvents';
export type RawStatus = 'working' | 'idle' | 'error';
export type DerivedStatus = 'working' | 'idle_warm' | 'idle_cold' | 'error';
// Window during which an idle dot stays green; after this, it fades to gray.
const WARM_WINDOW_MS = 30_000;
const TICK_MS = 5_000;
interface Entry {
status: RawStatus;
at: string;
}
// Module-scope shared state so every StatusDot in the app shares one map
// (mirrors useSidebar's singleton pattern). The map is ephemeral — cleared on
// page reload; WS reconnect repopulates as new frames arrive.
const statuses = new Map<string, Entry>();
const subscribers = new Set<() => void>();
function notify(): void {
for (const s of subscribers) {
try { s(); } catch { /* swallow */ }
}
}
// Guard against duplicate listeners during Vite HMR.
const G = globalThis as Record<string, unknown>;
if (!G.__boocode_chat_status_subscribed) {
G.__boocode_chat_status_subscribed = true;
sessionEvents.subscribe((ev) => {
if (ev.type !== 'chat_status') return;
statuses.set(ev.chat_id, { status: ev.status, at: ev.at });
notify();
});
// Single shared ticker: re-notify so any green dot whose 30s window just
// expired re-renders as gray. We only notify if there's something warm —
// avoids waking sleeping components for nothing.
setInterval(() => {
const now = Date.now();
for (const entry of statuses.values()) {
if (entry.status === 'idle') {
const age = now - new Date(entry.at).getTime();
if (age < WARM_WINDOW_MS + TICK_MS) {
notify();
return;
}
}
}
}, TICK_MS);
}
function derive(entry: Entry | undefined): DerivedStatus {
if (!entry) return 'idle_cold';
if (entry.status === 'working') return 'working';
if (entry.status === 'error') return 'error';
const age = Date.now() - new Date(entry.at).getTime();
return age < WARM_WINDOW_MS ? 'idle_warm' : 'idle_cold';
}
export function useChatStatus(chatId: string | null | undefined): DerivedStatus {
const [, force] = useState({});
useEffect(() => {
const sub = () => force({});
subscribers.add(sub);
return () => { subscribers.delete(sub); };
}, []);
if (!chatId) return 'idle_cold';
return derive(statuses.get(chatId));
}

View File

@@ -0,0 +1,41 @@
import { useEffect, useState } from 'react';
import { api } from '@/api/client';
import type { GitMeta } from '@/api/types';
const POLL_INTERVAL_MS = 30_000;
// Live-ish git meta for the project header indicator. Backed by the server's
// 30s cache, so a 30s client poll plus the cache TTL bounds total staleness
// to ~60s in the worst case. Returns null while the first fetch is in flight
// or if the request failed.
export function useProjectGit(projectId: string | null | undefined): GitMeta | null {
const [meta, setMeta] = useState<GitMeta | null>(null);
useEffect(() => {
if (!projectId) {
setMeta(null);
return;
}
let cancelled = false;
const fetchOnce = () => {
api.projects
.git(projectId)
.then((m) => {
if (!cancelled) setMeta(m);
})
.catch(() => {
if (!cancelled) setMeta(null);
});
};
fetchOnce();
const t = setInterval(fetchOnce, POLL_INTERVAL_MS);
return () => {
cancelled = true;
clearInterval(t);
};
}, [projectId]);
return meta;
}

View File

@@ -1,5 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
import type { Message, WsFrame } from '@/api/types';
import { api } from '@/api/client';
import { sessionEvents } from './sessionEvents';
// session_renamed frame removed from WsFrame — it was declared but never
@@ -29,7 +31,9 @@ function applyFrame(state: State, frame: WsFrame): State {
kind: 'message',
tool_calls: null,
tool_results: null,
status: 'streaming',
// v1.8.2: cap-hit sentinels arrive role='system' and are static, so
// skipping the streaming dot for them keeps the UI accurate.
status: frame.role === 'system' ? 'complete' : 'streaming',
last_seq: 0,
tokens_used: null,
ctx_used: null,
@@ -37,6 +41,7 @@ function applyFrame(state: State, frame: WsFrame): State {
started_at: null,
finished_at: null,
created_at: new Date().toISOString(),
metadata: null,
};
return { ...state, messages: [...state.messages, newMsg] };
}
@@ -96,6 +101,7 @@ function applyFrame(state: State, frame: WsFrame): State {
started_at: null,
finished_at: null,
created_at: new Date().toISOString(),
metadata: null,
};
return { ...state, messages: [...state.messages, newMsg] };
}
@@ -110,6 +116,10 @@ function applyFrame(state: State, frame: WsFrame): State {
...(frame.ctx_max !== undefined ? { ctx_max: frame.ctx_max } : {}),
...(frame.started_at !== undefined ? { started_at: frame.started_at } : {}),
...(frame.finished_at !== undefined ? { finished_at: frame.finished_at } : {}),
// v1.8.2: cap-hit sentinels (and future stamped metadata) ride
// in on this terminal frame so the reducer can attach it
// without waiting for a refetch.
...(frame.metadata !== undefined ? { metadata: frame.metadata } : {}),
}
: m
);
@@ -133,16 +143,40 @@ function applyFrame(state: State, frame: WsFrame): State {
return state;
}
case 'error': {
// v1.8.2: when the frame carries a structured reason, stamp it onto the
// failed message's metadata so the bubble can render specifics inline
// (the WS error frame is one-shot; refresh-safe rendering needs the
// value persisted on the message).
const errorMeta = frame.reason
? { kind: 'error' as const, error_reason: frame.reason, error_text: frame.error }
: null;
const next = frame.message_id
? state.messages.map((m) =>
m.id === frame.message_id ? { ...m, status: 'failed' as const } : m
m.id === frame.message_id
? {
...m,
status: 'failed' as const,
...(errorMeta ? { metadata: errorMeta } : {}),
}
: m
)
: state.messages;
return { ...state, messages: next, error: frame.error };
}
case 'compacted': {
// v1.11: side effects (refetch + toast) live in ws.onmessage; the
// reducer just no-ops so TS exhaustiveness is satisfied without
// duplicating async work inside a synchronous reducer.
return state;
}
}
}
// Matches useUserEvents — exponential backoff with the same ceiling so the
// two channels reconnect on the same cadence after a network handoff.
const RECONNECT_INITIAL_MS = 1000;
const RECONNECT_MAX_MS = 30_000;
export function useSessionStream(sessionId: string | undefined) {
const [state, setState] = useState<State>({ messages: [], connected: false, error: null });
const wsRef = useRef<WebSocket | null>(null);
@@ -152,32 +186,71 @@ export function useSessionStream(sessionId: string | undefined) {
setState({ messages: [], connected: false, error: null });
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
const url = `${proto}://${window.location.host}/api/ws/sessions/${sessionId}`;
const ws = new WebSocket(url);
wsRef.current = ws;
let unmounted = false;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let reconnectDelay = RECONNECT_INITIAL_MS;
ws.onopen = () => {
setState((s) => ({ ...s, connected: true, error: null }));
};
ws.onmessage = (ev) => {
try {
const frame = JSON.parse(typeof ev.data === 'string' ? ev.data : '') as WsFrame;
setState((s) => applyFrame(s, frame));
} catch (err) {
console.warn('bad ws frame', err);
}
};
ws.onerror = () => {
setState((s) => ({ ...s, error: 'websocket error' }));
};
ws.onclose = () => {
setState((s) => ({ ...s, connected: false }));
const connect = () => {
if (unmounted) return;
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
const url = `${proto}://${window.location.host}/api/ws/sessions/${sessionId}`;
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onopen = () => {
reconnectDelay = RECONNECT_INITIAL_MS;
setState((s) => ({ ...s, connected: true, error: null }));
};
ws.onmessage = (ev) => {
try {
const frame = JSON.parse(typeof ev.data === 'string' ? ev.data : '') as WsFrame;
// v1.11: on a compaction completion, re-fetch the message list so
// the new summary row + the cohort of compacted_at-stamped older
// rows render correctly. We dispatch the fresh list as a synthetic
// 'snapshot' frame so the reducer's existing path handles state
// replacement (no need for a parallel "refetched" path).
// The toast is purely UX feedback; missing it would still leave
// the chat in a valid state.
if (frame.type === 'compacted') {
toast.success('Context compacted to free space');
void api.messages
.list(frame.session_id)
.then((messages) => {
setState((s) => applyFrame(s, { type: 'snapshot', messages }));
})
.catch((err: unknown) => {
console.warn('compacted refetch failed', err);
});
return;
}
setState((s) => applyFrame(s, frame));
} catch (err) {
console.warn('bad ws frame', err);
}
};
// v1.8.1: WS errors no longer surface as user-facing toasts here. The
// user-channel hook (useUserEvents) owns the debounced "reconnecting…"
// UI; this channel just reconnects silently on the same backoff.
ws.onerror = () => {
try { ws.close(); } catch {}
};
ws.onclose = () => {
if (unmounted) return;
setState((s) => ({ ...s, connected: false }));
const delay = reconnectDelay;
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
reconnectTimer = setTimeout(connect, delay);
};
};
connect();
return () => {
unmounted = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
const ws = wsRef.current;
wsRef.current = null;
ws.close();
if (ws) try { ws.close(); } catch {}
};
}, [sessionId]);

View File

@@ -151,6 +151,10 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
case 'open_chat_in_active_pane':
// Consumed by Workspace; sidebar has no business with pane state.
return prev;
case 'open_settings_pane':
// Consumed by Session.tsx (calls toggleSettingsPane on its panesHook).
// Sidebar data is untouched.
return prev;
case 'session_archived': {
let changed = false;
const projects = prev.projects.map((p) => {
@@ -171,6 +175,7 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
case 'chat_archived':
case 'chat_unarchived':
case 'chat_deleted':
case 'chat_status':
return prev;
case 'project_archived': {
const next = prev.projects.filter((p) => p.id !== event.project_id);

View File

@@ -0,0 +1,43 @@
import { useEffect, useState } from 'react';
import { api } from '@/api/client';
import type { Skill } from '@/api/types';
// Batch 9.6: shared in-memory cache for the slash-command dropdown. One fetch
// per process; subsequent mounts of useSkills() return the cached list and
// don't re-hit /api/skills. Matches the useSidebar / useChatStatus module-
// singleton pattern so the dropdown stays cheap even with many ChatInputs
// mounted at once.
let cachedSkills: Skill[] | null = null;
let inflight: Promise<Skill[]> | null = null;
const subscribers = new Set<(s: Skill[]) => void>();
async function loadSkills(): Promise<Skill[]> {
if (inflight) return inflight;
inflight = api.skills
.list()
.then((r) => {
cachedSkills = r.skills;
for (const sub of subscribers) {
try { sub(cachedSkills); } catch { /* swallow */ }
}
return cachedSkills;
})
.finally(() => { inflight = null; });
return inflight;
}
export function useSkills(): { skills: Skill[]; loaded: boolean } {
const [skills, setSkills] = useState<Skill[]>(cachedSkills ?? []);
const [loaded, setLoaded] = useState<boolean>(cachedSkills !== null);
useEffect(() => {
subscribers.add(setSkills);
if (cachedSkills === null) {
void loadSkills().then(() => setLoaded(true)).catch(() => setLoaded(true));
}
return () => { subscribers.delete(setSkills); };
}, []);
return { skills, loaded };
}

View File

@@ -1,5 +1,6 @@
import { useEffect } from 'react';
import { sessionEvents } from './sessionEvents';
import { createWsReconnectToast } from './wsReconnectToast';
const RECONNECT_INITIAL_MS = 1000;
const RECONNECT_MAX_MS = 30000;
@@ -11,6 +12,20 @@ export function useUserEvents(): void {
let reconnectDelay = RECONNECT_INITIAL_MS;
let unmounted = false;
// v1.8.1: silent on the first disconnect; gray "reconnecting…" after 3
// fails / 15 s; red "connection lost" with a Retry Now action after 60 s.
const reconnectToast = createWsReconnectToast({
label: 'Live updates',
onRetryNow: () => {
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
reconnectDelay = RECONNECT_INITIAL_MS;
connect();
}
},
});
const connect = () => {
if (unmounted) return;
const url = new URL('/api/ws/user', window.location.href);
@@ -19,6 +34,7 @@ export function useUserEvents(): void {
ws.onopen = () => {
reconnectDelay = RECONNECT_INITIAL_MS;
reconnectToast.onConnected();
};
ws.onmessage = (ev) => {
@@ -34,6 +50,7 @@ export function useUserEvents(): void {
ws.onclose = () => {
if (unmounted) return;
reconnectToast.onFailure();
const delay = reconnectDelay;
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
reconnectTimer = setTimeout(connect, delay);
@@ -50,8 +67,8 @@ export function useUserEvents(): void {
return () => {
unmounted = true;
reconnectToast.dispose();
if (reconnectTimer) clearTimeout(reconnectTimer);
// best-effort cleanup; ignore failure because the socket may already be closed
if (ws) try { ws.close(); } catch {}
};
}, []);

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { DragEvent } from 'react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import type { WorkspacePane } from '@/api/types';
import { setActivePaneInfo, clearActivePane } from '@/hooks/useActivePane';
@@ -11,14 +12,45 @@ function generateId(): string {
return crypto.randomUUID();
}
function emptyPane(): WorkspacePane {
return { id: generateId(), kind: 'empty', chatIds: [], activeChatIdx: -1 };
// v1.10.3: optional id arg lets addSplitPane lift id generation out of the
// setPanes updater so the new pane's id can be returned synchronously to the
// caller (needed for mobile URL state).
function emptyPane(id: string = generateId()): WorkspacePane {
return { id, kind: 'empty', chatIds: [], activeChatIdx: -1 };
}
function chatPane(chatId: string): WorkspacePane {
return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 };
}
// v1.10 booterm: terminal panes carry no chats. Their `id` is used as the
// tmux window key on booterm — see apps/booterm/src/pty/manager.ts. They
// persist in localStorage along with chat panes so a refresh resumes the
// same tmux window via the idempotent start endpoint.
function terminalPane(id: string = generateId()): WorkspacePane {
return { id, kind: 'terminal', chatIds: [], activeChatIdx: -1 };
}
// v1.9: settings pane factory. No chats, no state beyond identity — the
// SettingsPane component renders Session/Project sections from the
// surrounding session/project.
function settingsPane(): WorkspacePane {
return { id: generateId(), kind: 'settings', chatIds: [], activeChatIdx: -1 };
}
// v1.9: settings panes are ephemeral. Filter them out before persisting so a
// page reload always returns to a clean workspace; the user re-opens via the
// sidebar Settings button when needed.
function persistablePanes(panes: WorkspacePane[]): WorkspacePane[] {
return panes.filter((p) => p.kind !== 'settings');
}
// v1.9: per recon decision (c), settings panes don't count toward MAX_PANES.
// Helper used at every pane-insertion site so the rule lives in one place.
function nonSettingsCount(panes: WorkspacePane[]): number {
return panes.reduce((n, p) => n + (p.kind === 'settings' ? 0 : 1), 0);
}
function loadPanes(sessionId: string): WorkspacePane[] | null {
try {
const raw = localStorage.getItem(`${STORAGE_KEY}.${sessionId}`);
@@ -33,7 +65,10 @@ function loadPanes(sessionId: string): WorkspacePane[] | null {
function savePanes(sessionId: string, panes: WorkspacePane[]): void {
try {
localStorage.setItem(`${STORAGE_KEY}.${sessionId}`, JSON.stringify(panes));
localStorage.setItem(
`${STORAGE_KEY}.${sessionId}`,
JSON.stringify(persistablePanes(panes)),
);
} catch { /* quota or disabled */ }
}
@@ -49,7 +84,15 @@ export interface UseWorkspacePanesResult {
closeTabsToRight: (paneIdx: number, pivotChatId: string) => void;
closeAllTabs: (paneIdx: number) => void;
showLandingPage: (paneIdx: number) => void;
addSplitPane: (kind: 'chat' | 'terminal' | 'agent') => void;
// v1.10.3: returns the new pane's id (or null if the operation was a no-op:
// 'agent' kind is a toast stub, or max panes reached). Callers can use the
// id to update mobile URL state so the URL-sync effect doesn't fight the
// freshly-set activePaneIdx.
addSplitPane: (kind: 'chat' | 'terminal' | 'agent') => string | null;
// Open-on-first-click, close-on-second-click. Singleton — settings panes
// don't count toward MAX_PANES. Closing the only remaining pane (edge case)
// falls back to an empty pane to preserve the "always one pane" invariant.
toggleSettingsPane: () => void;
removePane: (idx: number) => void;
removeChatFromPanes: (chatId: string) => void;
initializeFirstChatIfEmpty: (chatId: string) => void;
@@ -206,34 +249,73 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
});
}, []);
const addSplitPane = useCallback((kind: 'chat' | 'terminal' | 'agent') => {
if (kind === 'terminal') {
toast('Terminal panes coming in BooTerm');
return;
}
const addSplitPane = useCallback((kind: 'chat' | 'terminal' | 'agent'): string | null => {
if (kind === 'agent') {
toast('Agent panes coming in BooCoder');
return;
return null;
}
// Generate the id outside the updater so we can return it deterministically.
// setPanes's updater can be invoked twice in strict mode; using a fixed id
// ensures both invocations agree and the returned id matches what landed.
const newPaneId = generateId();
let success = false;
setPanes((prev) => {
if (prev.length >= MAX_PANES) {
// v1.9: settings panes are excluded from the MAX cap (decision c).
if (nonSettingsCount(prev) >= MAX_PANES) {
toast.error(`Maximum ${MAX_PANES} panes`);
return prev;
}
const next = [...prev, emptyPane()];
const newPane = kind === 'terminal' ? terminalPane(newPaneId) : emptyPane(newPaneId);
const next = [...prev, newPane];
setActivePaneIdx(next.length - 1);
success = true;
return next;
});
return success ? newPaneId : null;
}, []);
const toggleSettingsPane = useCallback(() => {
setPanes((prev) => {
const existingIdx = prev.findIndex((p) => p.kind === 'settings');
if (existingIdx < 0) {
const next = [...prev, settingsPane()];
setActivePaneIdx(next.length - 1);
return next;
}
if (prev.length <= 1) {
setActivePaneIdx(0);
return [emptyPane()];
}
const next = prev.filter((_, i) => i !== existingIdx);
setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
return next;
});
}, []);
const removePane = useCallback((idx: number) => {
setPanes((prev) => {
if (prev.length <= 1) return prev;
if (prev.length <= 1) {
// Settings is the only kind that can be the last pane and still need
// closing (X / Esc / sidebar toggle). Fall back to empty.
if (prev[idx]?.kind === 'settings') {
setActivePaneIdx(0);
return [emptyPane()];
}
return prev;
}
// v1.10.8c: with per-pane tmux sessions, an unkilled session leaks until
// the next `tmux kill-server`. Fire-and-forget /kill on terminal removal.
// The endpoint is idempotent (404 on missing session) so a strict-mode
// double-invoke of the updater is safe.
const removed = prev[idx];
if (removed?.kind === 'terminal') {
api.terminals.kill(sessionId, removed.id).catch(() => { /* non-fatal */ });
}
const next = prev.filter((_, i) => i !== idx);
setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
return next;
});
}, []);
}, [sessionId]);
// Replaces a single empty default pane with a chat pane. Used by the initial
// chat fetch to land on the most-recent open chat if no saved pane state.
@@ -325,6 +407,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
closeAllTabs,
showLandingPage,
addSplitPane,
toggleSettingsPane,
removePane,
removeChatFromPanes,
initializeFirstChatIfEmpty,

View File

@@ -0,0 +1,95 @@
import { toast } from 'sonner';
// v1.8.1 thresholds. First disconnect is silent — mobile Authelia idle timeouts
// and tab suspensions trip reconnects constantly and the old red "websocket
// error" toast made the app feel broken. Only escalate once the failure is
// sustained.
const TOAST_AFTER_FAILS = 3;
const TOAST_AFTER_MS = 15_000;
const PERSISTENT_AFTER_MS = 60_000;
export interface WsReconnectToast {
onFailure(): void;
onConnected(): void;
dispose(): void;
}
interface Options {
label: string; // shown in the toast (e.g. "Live updates")
onRetryNow: () => void; // user clicked the "Retry now" action
}
// Per-connection toast wrapper. Caller drives it from the WS lifecycle:
// onFailure — after each failed connection attempt
// onConnected — after a successful onopen
// dispose — on hook unmount
// The wrapper itself runs no timers and does not change the caller's reconnect
// cadence; it only decides when to show / dismiss the toast.
export function createWsReconnectToast(opts: Options): WsReconnectToast {
let firstFailureAt: number | null = null;
let failureCount = 0;
let reconnectingId: string | number | null = null;
let persistentId: string | number | null = null;
function dismissReconnecting(): void {
if (reconnectingId !== null) {
toast.dismiss(reconnectingId);
reconnectingId = null;
}
}
function dismissPersistent(): void {
if (persistentId !== null) {
toast.dismiss(persistentId);
persistentId = null;
}
}
return {
onFailure() {
if (firstFailureAt === null) firstFailureAt = Date.now();
failureCount += 1;
const elapsed = Date.now() - firstFailureAt;
// Escalate to red error + Retry button after PERSISTENT_AFTER_MS. Replaces
// the gray toast if it's still showing.
if (persistentId === null && elapsed >= PERSISTENT_AFTER_MS) {
dismissReconnecting();
persistentId = toast.error(`${opts.label}: connection lost`, {
duration: Infinity,
action: {
label: 'Retry now',
onClick: () => {
dismissReconnecting();
dismissPersistent();
opts.onRetryNow();
},
},
});
return;
}
// Gray "reconnecting…" toast once we've crossed either threshold.
if (
reconnectingId === null &&
persistentId === null &&
(failureCount >= TOAST_AFTER_FAILS || elapsed >= TOAST_AFTER_MS)
) {
reconnectingId = toast.warning(`${opts.label}: reconnecting…`, {
duration: Infinity,
});
}
},
onConnected() {
firstFailureAt = null;
failureCount = 0;
dismissReconnecting();
dismissPersistent();
},
dispose() {
firstFailureAt = null;
failureCount = 0;
dismissReconnecting();
dismissPersistent();
},
};
}

View File

@@ -8,6 +8,34 @@ export type Attachment = {
source: '@' | 'line-select' | 'drop' | 'paste';
};
// v1.7: caps shared between drag-drop and paste-as-attachment so both paths
// reject the same way. Match the existing 10-attachment cap in
// ChatInput.addAttachment.
export const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB
export const PASTE_INLINE_MAX_LINES = 8;
// First-8KB null-byte scan. Returns true if the content looks binary.
// Accepts a string (post-decode), an ArrayBuffer (pre-decode), or a Uint8Array.
// For binary files like PNG, scanning bytes is more reliable than scanning
// post-UTF-8-decode strings because invalid sequences may be replaced rather
// than preserved.
export function looksBinary(content: string | ArrayBuffer | Uint8Array): boolean {
const SCAN_BYTES = 8192;
if (typeof content === 'string') {
const max = Math.min(content.length, SCAN_BYTES);
for (let i = 0; i < max; i++) {
if (content.charCodeAt(i) === 0) return true;
}
return false;
}
const bytes = content instanceof Uint8Array ? content : new Uint8Array(content);
const max = Math.min(bytes.length, SCAN_BYTES);
for (let i = 0; i < max; i++) {
if (bytes[i] === 0) return true;
}
return false;
}
export const LANG_MAP: Record<string, string> = {
ts: 'typescript', tsx: 'tsx', js: 'javascript', jsx: 'jsx',
mjs: 'javascript', cjs: 'javascript',

151
apps/web/src/lib/events.ts Normal file
View File

@@ -0,0 +1,151 @@
// Minimal pub/sub for ephemeral UI events that don't belong on the sessionEvents
// bus (sessionEvents is for DB-state changes; this file is for UI-only signals
// like "user clicked send-to-terminal on selected text").
//
// Also exposes a tiny registry of currently-mounted terminal panes so the
// MessageBubble context menu can list them. TerminalPane registers on mount,
// unregisters on unmount. v1.10.4 adds a parallel ChatInput registry used by
// the terminal floating menu's "Send to chat" submenu.
type Listener<T> = (payload: T) => void;
interface EventBus<T> {
emit(payload: T): void;
subscribe(listener: Listener<T>): () => void;
}
function createEvent<T>(): EventBus<T> {
const listeners = new Set<Listener<T>>();
return {
emit(payload) {
for (const l of listeners) {
try {
l(payload);
} catch {
/* one bad listener shouldn't break others */
}
}
},
subscribe(listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};
}
export interface SendToTerminalPayload {
pane_id: string;
text: string;
}
export const sendToTerminal = createEvent<SendToTerminalPayload>();
// v1.10.4: reverse direction. Terminal floating menu "Send to chat" emits this
// with the target chat's chat_id; ChatInput subscribes and appends to its draft.
export interface SendToChatPayload {
chat_id: string;
text: string;
}
export const sendToChat = createEvent<SendToChatPayload>();
export interface TerminalRegistration {
paneId: string;
label: string;
// v1.10.3 kbd-shortcuts: Cmd+` needs to focus the active terminal's xterm
// input layer. TerminalPane binds this to term.focus().
focus: () => void;
// v1.10.4: Cmd+F opens the search bar over the active terminal. Workspace
// also binds a "Paste" button in the terminal pane header to paste().
openSearch: () => void;
paste: () => void;
}
const terminalRegistry = new Map<string, TerminalRegistration>();
const registryListeners = new Set<Listener<void>>();
function notifyRegistry(): void {
for (const l of registryListeners) {
try {
l();
} catch {
/* ignore */
}
}
}
export const terminalsRegistry = {
register(
paneId: string,
label: string,
focus: () => void,
openSearch: () => void,
paste: () => void,
): () => void {
terminalRegistry.set(paneId, { paneId, label, focus, openSearch, paste });
notifyRegistry();
return () => {
terminalRegistry.delete(paneId);
notifyRegistry();
};
},
list(): TerminalRegistration[] {
return Array.from(terminalRegistry.values());
},
get(paneId: string): TerminalRegistration | undefined {
return terminalRegistry.get(paneId);
},
subscribe(listener: Listener<void>): () => void {
registryListeners.add(listener);
return () => {
registryListeners.delete(listener);
};
},
};
// v1.10.4: parallel registry of mounted ChatInput components so the terminal
// floating menu's "Send to chat" submenu can list open chats. Mirrors
// terminalsRegistry exactly; same subscriber pattern.
export interface ChatInputRegistration {
chatId: string;
label: string;
focus: () => void;
}
const chatInputRegistry = new Map<string, ChatInputRegistration>();
const chatInputListeners = new Set<Listener<void>>();
function notifyChatInputs(): void {
for (const l of chatInputListeners) {
try {
l();
} catch {
/* ignore */
}
}
}
export const chatInputsRegistry = {
register(chatId: string, label: string, focus: () => void): () => void {
chatInputRegistry.set(chatId, { chatId, label, focus });
notifyChatInputs();
return () => {
chatInputRegistry.delete(chatId);
notifyChatInputs();
};
},
list(): ChatInputRegistration[] {
return Array.from(chatInputRegistry.values());
},
get(chatId: string): ChatInputRegistration | undefined {
return chatInputRegistry.get(chatId);
},
subscribe(listener: Listener<void>): () => void {
chatInputListeners.add(listener);
return () => {
chatInputListeners.delete(listener);
};
},
};

226
apps/web/src/lib/theme.ts Normal file
View File

@@ -0,0 +1,226 @@
import { useEffect, useState } from 'react';
import { api } from '@/api/client';
// themes-v1: source of truth for the 18 presets. id and name are surfaced in
// the picker; family groups visually; supportsDark/supportsLight reflect
// whether the corresponding selector exists in styles/themes/<id>.css; anchors
// are the 5 dark swatches (or the light palette for the two light-only themes)
// used in the picker preview strip.
export type ThemeId =
| 'obsidian'
| 'gunmetal'
| 'espresso'
| 'volcanic-brown'
| 'copper'
| 'gold'
| 'oxblood'
| 'crimson'
| 'elderflower'
| 'plum'
| 'steel-pink'
| 'fuchsia-noir'
| 'matrix'
| 'sage'
| 'ivory'
| 'chalk'
| 'cobalt'
| 'midnight-sapphire';
export type ThemeMode = 'dark' | 'light' | 'system';
export interface ThemeMeta {
id: ThemeId;
name: string;
family: string;
supportsDark: boolean;
supportsLight: boolean;
anchors: [string, string, string, string, string];
}
export const THEMES: readonly ThemeMeta[] = [
{ id: 'obsidian', name: 'Obsidian', family: 'Charcoal', supportsDark: true, supportsLight: true,
anchors: ['#0c0c0e', '#15151a', '#1f1f23', '#6b6b75', '#8b5cf6'] },
{ id: 'gunmetal', name: 'Gunmetal', family: 'Charcoal', supportsDark: true, supportsLight: true,
anchors: ['#0d1117', '#161b22', '#21262d', '#7d8590', '#388bfd'] },
{ id: 'espresso', name: 'Espresso', family: 'Brown', supportsDark: true, supportsLight: true,
anchors: ['#1c1410', '#241a14', '#2e2218', '#8a7058', '#c8a880'] },
{ id: 'volcanic-brown', name: 'Volcanic Brown', family: 'Brown', supportsDark: true, supportsLight: true,
anchors: ['#140906', '#1e0e0a', '#2e1610', '#7a4030', '#cc4a1a'] },
{ id: 'copper', name: 'Copper', family: 'Amber', supportsDark: true, supportsLight: true,
anchors: ['#100800', '#1c1408', '#2e1f0a', '#8a6040', '#b87333'] },
{ id: 'gold', name: 'Gold', family: 'Amber', supportsDark: true, supportsLight: true,
anchors: ['#0e0800', '#1a1200', '#2a1f00', '#a07c30', '#d4af37'] },
{ id: 'oxblood', name: 'Oxblood', family: 'Crimson', supportsDark: true, supportsLight: true,
anchors: ['#0a0303', '#180606', '#2a0808', '#7a3028', '#8b1a1a'] },
{ id: 'crimson', name: 'Crimson', family: 'Crimson', supportsDark: true, supportsLight: true,
anchors: ['#0e0404', '#1a0808', '#2e0a0a', '#8a3030', '#dc143c'] },
{ id: 'elderflower', name: 'Elderflower', family: 'Violet', supportsDark: true, supportsLight: true,
anchors: ['#100818', '#1c1024', '#2c1830', '#8a78a0', '#b89cd8'] },
{ id: 'plum', name: 'Plum', family: 'Violet', supportsDark: true, supportsLight: true,
anchors: ['#0c0814', '#180e20', '#241830', '#7a4878', '#8e4585'] },
{ id: 'steel-pink', name: 'Steel Pink', family: 'Magenta', supportsDark: true, supportsLight: true,
anchors: ['#0e0408', '#1a080e', '#2e0c1a', '#9a4070', '#cc33aa'] },
{ id: 'fuchsia-noir', name: 'Fuchsia Noir', family: 'Magenta', supportsDark: true, supportsLight: true,
anchors: ['#0a0610', '#14081a', '#2a0c2e', '#8a3878', '#ff1493'] },
{ id: 'matrix', name: 'Matrix', family: 'Green', supportsDark: true, supportsLight: true,
anchors: ['#000a00', '#031403', '#0a200a', '#208030', '#00ff41'] },
{ id: 'sage', name: 'Sage', family: 'Green', supportsDark: true, supportsLight: true,
anchors: ['#0a0e08', '#141a10', '#1e2e1a', '#7a8870', '#9caf88'] },
{ id: 'ivory', name: 'Ivory', family: 'Light', supportsDark: false, supportsLight: true,
anchors: ['#fdfcf8', '#f5f2e8', '#e8e4d8', '#8a8478', '#3a3328'] },
{ id: 'chalk', name: 'Chalk', family: 'Light', supportsDark: false, supportsLight: true,
anchors: ['#fafaf7', '#f0f0ec', '#e5e5e0', '#75756e', '#2a2a28'] },
{ id: 'cobalt', name: 'Cobalt', family: 'Blue', supportsDark: true, supportsLight: true,
anchors: ['#020817', '#061434', '#0c2244', '#3060a0', '#0047ab'] },
{ id: 'midnight-sapphire', name: 'Midnight Sapphire', family: 'Blue', supportsDark: true, supportsLight: true,
anchors: ['#02050e', '#060c1f', '#0e1a36', '#4a6088', '#1e3a8a'] },
] as const;
export const DEFAULT_THEME_ID: ThemeId = 'obsidian';
export const DEFAULT_THEME_MODE: ThemeMode = 'dark';
export const STORAGE_KEY = 'boocode.theme';
const THEME_IDS_SET: ReadonlySet<string> = new Set(THEMES.map((t) => t.id));
export function isThemeId(s: string): s is ThemeId {
return THEME_IDS_SET.has(s);
}
function resolvedMode(mode: ThemeMode): 'dark' | 'light' {
if (mode !== 'system') return mode;
if (typeof window === 'undefined') return 'dark';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
// Light-only themes (ivory, chalk) can't render dark — fall back to obsidian
// dark per spec §8 decision 1. Keeps the fallback explicit so the caller
// doesn't accidentally apply theme-ivory.dark (which has no rule block).
function effectiveThemeId(id: ThemeId, mode: 'dark' | 'light'): ThemeId {
if (mode === 'dark') {
const meta = THEMES.find((t) => t.id === id);
if (meta && !meta.supportsDark) return DEFAULT_THEME_ID;
}
return id;
}
export function applyTheme(id: ThemeId, mode: ThemeMode): void {
if (typeof document === 'undefined') return;
const resolved = resolvedMode(mode);
const effective = effectiveThemeId(id, resolved);
document.documentElement.className =
`theme-${effective}${resolved === 'dark' ? ' dark' : ''}`;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ id, mode }));
} catch {
// quota / disabled storage — apply still succeeds, persistence falls
// back to the next /api/settings round-trip.
}
}
interface ThemeState {
id: ThemeId;
mode: ThemeMode;
}
// Module-level singleton, mirrors the useChatStatus / useSidebar pattern.
// One shared state across every useTheme() consumer; setTheme() mutates it
// and notifies subscribers so the App-level hook (which owns the matchMedia
// listener) and the Settings picker stay in lockstep without prop drilling.
function readCache(): ThemeState | null {
if (typeof localStorage === 'undefined') return null;
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as { id?: unknown; mode?: unknown };
if (typeof parsed.id !== 'string' || !isThemeId(parsed.id)) return null;
const m = parsed.mode;
if (m !== 'dark' && m !== 'light' && m !== 'system') return null;
return { id: parsed.id, mode: m };
} catch {
return null;
}
}
let _state: ThemeState = readCache() ?? { id: DEFAULT_THEME_ID, mode: DEFAULT_THEME_MODE };
let _initialized = false;
const _subscribers = new Set<(s: ThemeState) => void>();
function notify(): void {
for (const sub of _subscribers) {
try {
sub(_state);
} catch {
// swallow — one bad subscriber shouldn't break others
}
}
}
// Optimistic update: applies immediately, PATCHes server, reverts on failure
// so the picker can show a toast without manual state juggling. Throws on
// failure so the caller can surface the error.
export async function setTheme(id: ThemeId, mode: ThemeMode): Promise<void> {
const prev = _state;
_state = { id, mode };
applyTheme(id, mode);
notify();
try {
await api.settings.patch({ theme_id: id, theme_mode: mode });
} catch (err) {
_state = prev;
applyTheme(prev.id, prev.mode);
notify();
throw err;
}
}
// useTheme — mounts as many times as needed across the tree. The first mount
// (initialized=false) triggers a single /api/settings fetch to reconcile the
// local cache with the server. Every mount installs the matchMedia listener
// when mode === 'system'; cleanup runs on unmount or when mode flips away.
export function useTheme(): ThemeState {
const [state, setState] = useState<ThemeState>(_state);
useEffect(() => {
_subscribers.add(setState);
// Ensure the DOM reflects current state on mount — the FOUC script in
// index.html runs before this hook, but we re-apply in case the cache
// was stale relative to a fresh fetch above.
applyTheme(_state.id, _state.mode);
if (!_initialized) {
_initialized = true;
api.settings
.get()
.then((s) => {
const rawId = s['theme_id'];
const rawMode = s['theme_mode'];
const id =
typeof rawId === 'string' && isThemeId(rawId) ? rawId : DEFAULT_THEME_ID;
const mode: ThemeMode =
rawMode === 'dark' || rawMode === 'light' || rawMode === 'system'
? rawMode
: DEFAULT_THEME_MODE;
_state = { id, mode };
applyTheme(id, mode);
notify();
})
.catch(() => {
// Settings fetch failed — keep whatever the FOUC script applied.
// The picker still works; PATCH will retry on next selection.
});
}
return () => {
_subscribers.delete(setState);
};
}, []);
useEffect(() => {
if (state.mode !== 'system') return;
if (typeof window === 'undefined') return;
const mql = window.matchMedia('(prefers-color-scheme: dark)');
const onChange = () => applyTheme(state.id, 'system');
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
}, [state.id, state.mode]);
return state;
}

View File

@@ -1,3 +1,8 @@
// Fonts imported as JS side-effect modules (boolab pattern, adapted for
// Tailwind v4 + Vite asset-pipeline URL rewriting). Must precede the React
// imports so the @font-face CSS lands before any component-tree render.
import '@fontsource-variable/inter';
import '@fontsource-variable/jetbrains-mono';
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { ChevronDown, ChevronRight, Folder, RotateCcw } from 'lucide-react';
import { ChevronDown, ChevronRight, Folder, FolderTree, Menu, RotateCcw } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { AddProjectModal } from '@/components/AddProjectModal';
@@ -8,6 +8,9 @@ import { api } from '@/api/client';
import type { Project } from '@/api/types';
import { sessionEvents } from '@/hooks/sessionEvents';
import { useSidebar } from '@/hooks/useSidebar';
import { useSidebarDrawer } from '@/hooks/useSidebarDrawer';
import { useRightRailDrawer } from '@/hooks/useRightRailDrawer';
import { useViewport } from '@/hooks/useViewport';
export function Home() {
const { data } = useSidebar();
@@ -15,6 +18,9 @@ export function Home() {
const [createOpen, setCreateOpen] = useState(false);
const [archived, setArchived] = useState<Project[] | null>(null);
const [showArchived, setShowArchived] = useState(false);
const { setOpen: setSidebarOpen } = useSidebarDrawer();
const { toggle: toggleRightRail } = useRightRailDrawer();
const { isMobile } = useViewport();
const empty = data ? data.projects.length === 0 : false;
@@ -41,6 +47,11 @@ export function Home() {
last_session_id: null,
status: 'archived' as const,
gitea_remote: fromSidebar.gitea_remote,
// v1.9: synthesized stub for an archived project that only the
// sidebar cache has — defaults match the schema NOT NULL DEFAULT
// values. The full row gets re-fetched on unarchive.
default_system_prompt: '',
default_web_search_enabled: false,
},
...prev,
];
@@ -70,8 +81,32 @@ export function Home() {
}
return (
<div className="flex-1 flex flex-col items-center px-6 py-12 overflow-y-auto">
<div className="w-full max-w-md space-y-6">
<div className="flex-1 flex flex-col min-h-0">
{isMobile && (
<header
className="border-b px-3 sm:px-4 py-2 flex items-center gap-1.5 shrink-0 text-sm"
style={{ paddingTop: 'max(0.5rem, env(safe-area-inset-top))' }}
>
<button
type="button"
onClick={() => setSidebarOpen(true)}
className="inline-flex items-center justify-center -ml-1 min-w-[44px] min-h-[44px] rounded text-muted-foreground hover:bg-muted hover:text-foreground shrink-0"
aria-label="Open sidebar"
>
<Menu className="size-5" />
</button>
<button
type="button"
onClick={toggleRightRail}
className="inline-flex items-center justify-center -mr-1 ml-auto min-w-[44px] min-h-[44px] rounded text-muted-foreground hover:bg-muted hover:text-foreground shrink-0"
aria-label="Toggle file browser"
>
<FolderTree className="size-5" />
</button>
</header>
)}
<div className="flex-1 flex flex-col items-center px-6 py-12 overflow-y-auto">
<div className="w-full max-w-md space-y-6">
<div className="text-center space-y-3">
{empty ? (
<>
@@ -127,9 +162,10 @@ export function Home() {
)}
</div>
)}
</div>
<AddProjectModal open={addOpen} onOpenChange={setAddOpen} onAdded={() => {}} />
<CreateProjectModal open={createOpen} onOpenChange={setCreateOpen} />
</div>
<AddProjectModal open={addOpen} onOpenChange={setAddOpen} onAdded={() => {}} />
<CreateProjectModal open={createOpen} onOpenChange={setCreateOpen} />
</div>
);
}

View File

@@ -1,19 +1,42 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { useCallback, useEffect, useState } from 'react';
import {
Link,
useLocation,
useNavigate,
useParams,
useSearchParams,
} from 'react-router-dom';
import { ChevronRight, FolderTree, Menu } from 'lucide-react';
import { api } from '@/api/client';
import type { Project, Session as SessionType } from '@/api/types';
import { sessionEvents } from '@/hooks/sessionEvents';
import { terminalsRegistry } from '@/lib/events';
import { useActivePane } from '@/hooks/useActivePane';
import { useSidebarDrawer } from '@/hooks/useSidebarDrawer';
import { useRightRailDrawer } from '@/hooks/useRightRailDrawer';
import { useViewport } from '@/hooks/useViewport';
import { useWorkspacePanes, MAX_PANES } from '@/hooks/useWorkspacePanes';
import { useSessionChats } from '@/hooks/useSessionChats';
import { useProjectGit } from '@/hooks/useProjectGit';
import { Workspace } from '@/components/Workspace';
import { ModelPicker } from '@/components/ModelPicker';
import { MobileTabSwitcher } from '@/components/MobileTabSwitcher';
import { NewPaneMenu } from '@/components/NewPaneMenu';
import { cn } from '@/lib/utils';
export function Session() {
const { id } = useParams<{ id: string }>();
if (!id) return null;
// v1.8: key on id so route navigation remounts SessionInner — the hoisted
// useWorkspacePanes + useSessionChats then reinitialize cleanly from the
// new sessionId instead of carrying stale state across sessions.
return <SessionInner key={id} sessionId={id} />;
}
function SessionInner({ sessionId }: { sessionId: string }) {
const navigate = useNavigate();
const location = useLocation();
const [searchParams] = useSearchParams();
const [session, setSession] = useState<SessionType | null>(null);
const [project, setProject] = useState<Project | null>(null);
const [name, setName] = useState('');
@@ -23,23 +46,53 @@ export function Session() {
const { toggle: toggleRightRail } = useRightRailDrawer();
const { isMobile } = useViewport();
// v1.8: pane + chat state hoisted into Session so the mobile header pill
// (MobileTabSwitcher) shares one source of truth with the pane grid below.
const panesHook = useWorkspacePanes(sessionId);
const {
panes,
activePaneIdx,
setActivePaneIdx,
openChatInPane,
activePaneIdxRef,
addSplitPane,
removePane,
removeChatFromPanes,
initializeFirstChatIfEmpty,
} = panesHook;
const openChatInActivePane = useCallback(
(chatId: string) => openChatInPane(activePaneIdxRef.current, chatId),
[openChatInPane, activePaneIdxRef],
);
const chatsHook = useSessionChats(sessionId, {
removeChatFromPanes,
openChatInPane,
openChatInActivePane,
initializeFirstChatIfEmpty,
});
const { chats, renameChat } = chatsHook;
// v1.8 Level 1: branch indicator. Polls every 30s; server caches the same
// span so back-to-back loads are cheap. Returns null until the first fetch
// resolves or if the project isn't a git repo.
const git = useProjectGit(project?.id);
useEffect(() => {
if (!id) return;
setSession(null);
setProject(null);
let cancelled = false;
api.sessions
.get(id)
.get(sessionId)
.then((s) => {
if (cancelled) return;
setSession(s);
setName(s.name);
sessionEvents.emit({
type: 'session_loaded',
session_id: id,
session_id: sessionId,
project_id: s.project_id,
});
// Load project for breadcrumb. Listing is fine — small N, cached by client.
api.projects.list().then((projects) => {
if (cancelled) return;
const p = projects.find((x) => x.id === s.project_id);
@@ -50,34 +103,199 @@ export function Session() {
return () => {
cancelled = true;
};
}, [id]);
}, [sessionId]);
useEffect(() => {
if (!id) return;
return sessionEvents.subscribe((event) => {
if (event.type === 'session_renamed' && event.session_id === id) {
if (event.type === 'session_renamed' && event.session_id === sessionId) {
setSession((prev) => (prev ? { ...prev, name: event.name } : prev));
setName((prev) => (editingName ? prev : event.name));
return;
}
if (
(event.type === 'session_deleted' || event.type === 'session_archived') &&
event.session_id === id
event.session_id === sessionId
) {
navigate(`/project/${event.project_id}`);
return;
}
// v1.9: any session_updated for this session triggers a full refetch so
// SettingsPane (mounted in a workspace pane) picks up system_prompt /
// web_search_enabled / model edits made from another tab.
if (event.type === 'session_updated' && event.session_id === sessionId) {
void api.sessions.get(sessionId).then((s) => {
setSession(s);
setName((prev) => (editingName ? prev : s.name));
}).catch(() => {});
return;
}
// v1.9: project_updated → refetch project so the Project section in
// SettingsPane reflects the new defaults.
if (event.type === 'project_updated' && project && event.project_id === project.id) {
void api.projects.get(project.id).then(setProject).catch(() => {});
return;
}
// Sidebar Settings button broadcasts this when a session is mounted;
// toggleSettingsPane opens on first click, closes on second.
if (event.type === 'open_settings_pane') {
panesHook.toggleSettingsPane();
}
});
}, [id, editingName, navigate]);
}, [sessionId, editingName, navigate, project, panesHook]);
// v1.8: URL ?pane= sync (mobile only). Lifted from Workspace.tsx so
// MobileTabSwitcher's onSwitchPane can push the same URL state and the
// browser Back button continues to walk pane history on mobile.
useEffect(() => {
if (!isMobile || panes.length === 0) return;
const paneId = searchParams.get('pane');
if (!paneId) {
if (activePaneIdx !== 0) setActivePaneIdx(0);
return;
}
const idx = panes.findIndex((p) => p.id === paneId);
if (idx >= 0 && idx !== activePaneIdx) setActivePaneIdx(idx);
}, [isMobile, searchParams, panes, activePaneIdx, setActivePaneIdx]);
const switchActivePane = useCallback(
(idx: number) => {
setActivePaneIdx(idx);
if (isMobile) {
const pane = panes[idx];
if (!pane) return;
const params = new URLSearchParams(location.search);
params.set('pane', pane.id);
navigate(`${location.pathname}?${params.toString()}`);
}
},
[setActivePaneIdx, isMobile, panes, navigate, location.pathname, location.search],
);
// v1.10.3 fix: addSplitPane sets activePaneIdx, but on mobile the URL-sync
// effect below sees a stale ?pane= and immediately resets the index. Push
// the new pane's id to the URL atomically so the effect's next pass sees a
// matching id and is a no-op. Desktop has no URL pane state — fall through.
const addPaneAndSwitch = useCallback(
(kind: 'chat' | 'terminal' | 'agent') => {
const newPaneId = addSplitPane(kind);
if (newPaneId === null) return;
if (isMobile) {
const params = new URLSearchParams(location.search);
params.set('pane', newPaneId);
navigate(`${location.pathname}?${params.toString()}`);
}
},
[addSplitPane, isMobile, navigate, location.pathname, location.search],
);
// v1.10.3 keyboard shortcuts. Window-level keydown so they fire from
// anywhere in the session view. Only Cmd/Ctrl-Shift-C defers to the xterm
// (which has its own copy binding for that combo); everything else fires
// regardless of focus. Cmd-W and Cmd-T are typically reserved by the
// browser — preventDefault() works in most browsers but not all.
useEffect(() => {
function onKey(e: KeyboardEvent): void {
const mod = e.ctrlKey || e.metaKey;
if (!mod) return;
const key = e.key.toLowerCase();
const target = e.target;
const inXterm = target instanceof Element && target.closest('.xterm') !== null;
// Cmd/Ctrl + ` — focus the active terminal or jump to the most recent
// terminal pane and focus it. No-op if there are no terminal panes.
if (key === '`') {
e.preventDefault();
const activePane = panes[activePaneIdx];
if (activePane?.kind === 'terminal') {
terminalsRegistry.get(activePane.id)?.focus();
return;
}
let lastTermIdx = -1;
for (let i = panes.length - 1; i >= 0; i--) {
if (panes[i]?.kind === 'terminal') {
lastTermIdx = i;
break;
}
}
if (lastTermIdx < 0) return;
const target = panes[lastTermIdx];
switchActivePane(lastTermIdx);
if (target) {
// The terminal may have just mounted on mobile (it was return-null
// before the switch). Defer focus until the new render commits.
setTimeout(() => terminalsRegistry.get(target.id)?.focus(), 80);
}
return;
}
// Cmd/Ctrl + Shift + T — new terminal pane and switch to it.
if (key === 't' && e.shiftKey) {
e.preventDefault();
addPaneAndSwitch('terminal');
return;
}
// Cmd/Ctrl + Shift + C — new chat pane and switch to it. The xterm's
// own Shift-C binding is "copy selection" — defer to it when in xterm.
if (key === 'c' && e.shiftKey) {
if (inXterm) return;
e.preventDefault();
addPaneAndSwitch('chat');
return;
}
// Cmd/Ctrl + W — close the active pane.
if (key === 'w' && !e.shiftKey) {
e.preventDefault();
removePane(activePaneIdx);
return;
}
// v1.10.4: Cmd/Ctrl + F — when the active pane is a terminal, open the
// scrollback search bar. When it isn't, fall through to the browser's
// native find (no preventDefault, no early return).
if (key === 'f' && !e.shiftKey) {
const activePane = panes[activePaneIdx];
if (activePane?.kind === 'terminal') {
e.preventDefault();
terminalsRegistry.get(activePane.id)?.openSearch();
}
return;
}
// Cmd/Ctrl + Tab / Shift+Tab — cycle through panes.
if (key === 'tab') {
if (panes.length <= 1) return;
e.preventDefault();
const dir = e.shiftKey ? -1 : 1;
const next = (activePaneIdx + dir + panes.length) % panes.length;
switchActivePane(next);
return;
}
// Cmd/Ctrl + 1..9 — direct jump to pane N.
if (/^[1-9]$/.test(key)) {
const idx = parseInt(key, 10) - 1;
if (idx < panes.length) {
e.preventDefault();
switchActivePane(idx);
}
return;
}
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [panes, activePaneIdx, switchActivePane, addPaneAndSwitch, removePane]);
async function saveName() {
if (!id || !session) return;
if (!session) return;
const trimmed = name.trim();
if (!trimmed || trimmed === session.name) {
setName(session.name);
setEditingName(false);
return;
}
const updated = await api.sessions.update(id, { name: trimmed });
const updated = await api.sessions.update(sessionId, { name: trimmed });
setSession(updated);
setEditingName(false);
// Server publishes session_renamed via broker.publishUser; no local emit needed.
@@ -85,115 +303,180 @@ export function Session() {
// Workspace only sets activeFile for file-browser panes; checking it alone
// suffices and is forward-compatible with future pane kinds.
const showActiveFile = active.sessionId === id && !!active.activeFile;
const showActiveFile = active.sessionId === sessionId && !!active.activeFile;
return (
<div className="flex-1 flex flex-col min-h-0">
<header
className="border-b px-3 sm:px-4 py-2 flex items-center gap-1.5 shrink-0 text-sm"
className={cn(
'border-b shrink-0 text-sm',
isMobile
? 'flex flex-col gap-1.5 px-3 py-2'
: 'flex items-center gap-1.5 px-3 sm:px-4 py-2',
)}
style={{ paddingTop: 'max(0.5rem, env(safe-area-inset-top))' }}
>
{isMobile && (
<button
type="button"
onClick={() => setDrawerOpen(true)}
className="inline-flex items-center justify-center -ml-1 min-w-[44px] min-h-[44px] rounded text-muted-foreground hover:bg-muted hover:text-foreground shrink-0"
aria-label="Open sidebar"
>
<Menu className="size-5" />
</button>
)}
{/* Breadcrumb — desktop only */}
<div className="hidden sm:flex items-center gap-1.5 min-w-0">
<Link to="/" className="text-muted-foreground hover:text-foreground shrink-0 text-xs">
Projects
</Link>
<ChevronRight className="size-3 text-muted-foreground/60 shrink-0" />
{project ? (
<Link
to={`/project/${project.id}`}
className="text-muted-foreground hover:text-foreground truncate max-w-[200px]"
title={project.name}
>
{project.name}
</Link>
) : (
<span className="text-muted-foreground/60"></span>
)}
<ChevronRight className="size-3 text-muted-foreground/60 shrink-0" />
</div>
{/* Session name — always visible, truncated, editable */}
{editingName ? (
<input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
onBlur={() => void saveName()}
onKeyDown={(e) => {
if (e.key === 'Enter') void saveName();
if (e.key === 'Escape') {
setName(session?.name ?? '');
setEditingName(false);
}
}}
className="bg-transparent border-b border-border px-1 py-0.5 text-sm font-medium outline-none focus:border-ring min-w-0"
/>
) : (
<button
type="button"
className="text-sm font-medium hover:underline truncate max-w-[140px] sm:max-w-[280px] min-w-0"
onClick={() => setEditingName(true)}
title={session?.name ?? ''}
>
{session?.name ?? '…'}
</button>
)}
{/* Active file — desktop only */}
{showActiveFile && active.activeFile && (
{isMobile ? (
<>
<span className="text-muted-foreground/40 mx-1 hidden sm:inline">·</span>
<span
className="text-xs font-mono text-muted-foreground truncate max-w-[200px] hidden sm:inline"
title={active.activeFile}
>
{active.activeFile}
</span>
</>
)}
{/* v1.8 mobile row 1: hamburger | repo+branch | ModelPicker | FolderTree.
Gear/kebab cluster lands in Batch 7; ModelPicker stays here until
then so mobile users keep model-switching access. */}
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => setDrawerOpen(true)}
className="inline-flex items-center justify-center -ml-1 min-w-[44px] min-h-[44px] rounded text-muted-foreground hover:bg-muted hover:text-foreground shrink-0"
aria-label="Open sidebar"
>
<Menu className="size-5" />
</button>
{/* Model picker — right-aligned */}
<div className="ml-auto shrink-0">
{session && (
<div className="inline-flex items-center rounded-full bg-muted/40 hover:bg-muted/70 px-1">
<ModelPicker
value={session.model}
onChange={async (model) => {
const updated = await api.sessions.update(session.id, { model });
setSession(updated);
}}
<div className="flex-1 min-w-0 flex items-center justify-center gap-1.5">
{project ? (
<span className="text-sm font-medium truncate" title={project.name}>
{project.name}
</span>
) : (
<span className="text-muted-foreground/60"></span>
)}
{git?.branch && (
<span
className="text-muted-foreground/80 text-xs truncate"
title={`branch: ${git.branch}${git.is_dirty ? ' (dirty)' : ''}`}
>
· {git.branch}
</span>
)}
</div>
{session && (
<ModelPicker
value={session.model}
onChange={async (model) => {
const updated = await api.sessions.update(session.id, { model });
setSession(updated);
}}
/>
)}
<button
type="button"
onClick={toggleRightRail}
className="inline-flex items-center justify-center -mr-1 min-w-[44px] min-h-[44px] rounded text-muted-foreground hover:bg-muted hover:text-foreground shrink-0"
aria-label="Toggle file browser"
>
<FolderTree className="size-5" />
</button>
</div>
{/* v1.8 mobile row 2: pane-switcher pill + new-pane menu. Pill
expands; NewPaneMenu is the trailing 44x44 trigger. */}
<div className="flex items-center gap-1.5">
<MobileTabSwitcher
panes={panes}
activePaneIdx={activePaneIdx}
chats={chats}
onSwitchPane={switchActivePane}
onRemovePane={removePane}
onRenameChat={renameChat}
/>
<NewPaneMenu
onAddPane={addPaneAndSwitch}
disabled={panes.length >= MAX_PANES}
/>
</div>
)}
</div>
</>
) : (
<>
{/* Desktop: unchanged single-row header. */}
<div className="hidden sm:flex items-center gap-1.5 min-w-0">
<Link to="/" className="text-muted-foreground hover:text-foreground shrink-0 text-xs">
Projects
</Link>
<ChevronRight className="size-3 text-muted-foreground/60 shrink-0" />
{project ? (
<Link
to={`/project/${project.id}`}
className="text-muted-foreground hover:text-foreground truncate max-w-[200px]"
title={project.name}
>
{project.name}
</Link>
) : (
<span className="text-muted-foreground/60"></span>
)}
<ChevronRight className="size-3 text-muted-foreground/60 shrink-0" />
</div>
{/* File browser toggle — mobile only */}
{isMobile && (
<button
type="button"
onClick={toggleRightRail}
className="inline-flex items-center justify-center -mr-1 min-w-[44px] min-h-[44px] rounded text-muted-foreground hover:bg-muted hover:text-foreground shrink-0"
aria-label="Toggle file browser"
>
<FolderTree className="size-5" />
</button>
{editingName ? (
<input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
onBlur={() => void saveName()}
onKeyDown={(e) => {
if (e.key === 'Enter') void saveName();
if (e.key === 'Escape') {
setName(session?.name ?? '');
setEditingName(false);
}
}}
className="bg-transparent border-b border-border px-1 py-0.5 text-sm font-medium outline-none focus:border-ring min-w-0"
/>
) : (
<button
type="button"
className="text-sm font-medium hover:underline truncate max-w-[280px] min-w-0"
onClick={() => setEditingName(true)}
title={session?.name ?? ''}
>
{session?.name ?? '…'}
</button>
)}
{showActiveFile && active.activeFile && (
<>
<span className="text-muted-foreground/40 mx-1">·</span>
<span
className="text-xs font-mono text-muted-foreground truncate max-w-[200px]"
title={active.activeFile}
>
{active.activeFile}
</span>
</>
)}
<div className="ml-auto shrink-0">
{session && (
<div className="inline-flex items-center rounded-full bg-muted/40 hover:bg-muted/70 px-1">
<ModelPicker
value={session.model}
onChange={async (model) => {
const updated = await api.sessions.update(session.id, { model });
setSession(updated);
}}
/>
</div>
)}
</div>
</>
)}
</header>
{id && session && (
<Workspace sessionId={id} projectId={session.project_id} />
{session && (
<Workspace
sessionId={sessionId}
projectId={session.project_id}
agentId={session.agent_id}
onAgentChange={async (agent_id) => {
const updated = await api.sessions.update(session.id, { agent_id });
setSession(updated);
}}
panesHook={panesHook}
chatsHook={chatsHook}
session={session}
project={project}
/>
)}
</div>
);

View File

@@ -0,0 +1,47 @@
import { ArrowLeft } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { ThemePicker } from '@/components/ThemePicker';
// v1.9: thin wrapper around <ThemePicker />. The picker itself moved to a
// reusable component (also rendered in the workspace SettingsPane Theme tab).
// This page-level shell adds the back affordance + heading chrome that's
// appropriate when the picker is the entire route.
export function Settings() {
const navigate = useNavigate();
function handleBack() {
// History-aware: jump back to where the user came from when possible.
// Direct loads of /settings (no history) land on Home so the button
// always does *something* useful.
if (window.history.length > 1) {
navigate(-1);
} else {
navigate('/');
}
}
return (
<div className="flex-1 overflow-y-auto">
<div className="max-w-[1000px] mx-auto w-full px-6 py-6 space-y-8">
<header className="space-y-2">
<button
type="button"
onClick={handleBack}
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground -ml-1 px-1 py-0.5 rounded"
aria-label="Back"
>
<ArrowLeft className="size-4" />
<span>Back</span>
</button>
<div>
<h1 className="text-xl font-semibold">Settings</h1>
<p className="text-sm text-muted-foreground mt-1">
Theme appearance. Saved on change, applies immediately.
</p>
</div>
</header>
<ThemePicker />
</div>
</div>
);
}

View File

@@ -1,8 +1,30 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/inter";
@import "@fontsource-variable/jetbrains-mono";
/* @fontsource-variable JBM + Inter imported from main.tsx as JS modules. */
/* themes-v1: 18 preset palettes. Order matches docs/themes_v1.md §1 with
obsidian first (default). Each file declares .theme-<id> for the light
variant and .theme-<id>.dark for the dark variant (except ivory/chalk
which are light-only). lib/theme.ts owns the class composition on <html>. */
@import "./themes/obsidian.css";
@import "./themes/gunmetal.css";
@import "./themes/espresso.css";
@import "./themes/volcanic-brown.css";
@import "./themes/copper.css";
@import "./themes/gold.css";
@import "./themes/oxblood.css";
@import "./themes/crimson.css";
@import "./themes/elderflower.css";
@import "./themes/plum.css";
@import "./themes/steel-pink.css";
@import "./themes/fuchsia-noir.css";
@import "./themes/matrix.css";
@import "./themes/sage.css";
@import "./themes/ivory.css";
@import "./themes/chalk.css";
@import "./themes/cobalt.css";
@import "./themes/midnight-sapphire.css";
@custom-variant dark (&:is(.dark *));
@@ -129,3 +151,96 @@
@apply font-sans;
}
}
/*
* iOS Safari auto-enlarges text in narrow viewports (anti-zoom). On its own
* that's fine for HTML chrome, but xterm.js measures its cell width from a
* hidden text-measure element — so when iOS up-sizes that element, xterm
* computes wider cells and the terminal ends up at fewer cols than it should.
* In opencode this surfaces as the small fragmented banner instead of the
* big chunky one (opencode picks the banner glyph set based on terminal
* width). 100% disables the auto-adjust and keeps boocode at the same
* effective cols as boolab on the same iPhone.
*/
html, body {
-webkit-text-size-adjust: 100% !important;
-ms-text-size-adjust: 100% !important;
text-size-adjust: 100% !important;
}
/* iOS Safari auto-zooms when a user taps an input/textarea whose font-size
* is under 16px. Pin every input/textarea/select to 16px (boolab pattern)
* to suppress the zoom — applies globally; specific components can override
* with `text-base` or inline if a smaller visual is intentional. */
input, textarea, select {
font-size: 16px !important;
}
/*
* xterm.js overrides (boolab pattern — see /opt/boolab/frontend/src/styles/globals.css).
*
* Why these live in a global stylesheet, not in an inline <style> inside the
* component: an inline <style> inserted at component-mount time races the
* upstream @xterm/xterm/css/xterm.css that ships with the addon. We saw the
* right-edge stripe persist on iOS even though the override was identical to
* boolab's — moving the rules here so they're parsed alongside index.css
* eliminates that race.
*/
.xterm,
.xterm *,
.xterm .xterm-rows,
.xterm .xterm-rows * {
font-family: 'JetBrains Mono Variable', 'JetBrains Mono', 'Fira Code', Menlo, monospace !important;
}
/* Fill the host node — xterm's only non-absolute sizing comes from the canvas,
* and fractional rounding would otherwise leave a phantom right-edge stripe.
*/
.xterm {
width: 100% !important;
height: 100% !important;
}
/* Lock cell metrics so block-element glyphs (U+2580..U+259F) tile without
* subpixel gaps. Any non-zero letter-spacing or line-height ≠ 1 leaves
* fractional space between cells that paints as a horizontal/vertical
* stripe through the opencode banner on iOS. Disabling ligatures
* (font-feature-settings + font-variant-ligatures) prevents the renderer
* from collapsing adjacent block chars into shaped glyphs at unpredictable
* widths.
*/
.xterm,
.xterm .xterm-rows {
letter-spacing: 0 !important;
line-height: 1 !important;
font-feature-settings: "liga" 0, "calt" 0 !important;
font-variant-ligatures: none !important;
}
.xterm .xterm-viewport {
overflow-y: hidden !important;
scrollbar-width: none !important;
-ms-overflow-style: none !important;
/*
* xterm.css ships `background-color: #000` on the viewport (kept for OS X
* scrollbar opacity in the upstream default). FitAddon rounds cols down
* to integer cells, so .xterm-screen is up to `cellWidth - 1` pixels
* narrower than .xterm-viewport — the strip between the canvas right
* edge and the viewport right edge then paints viewport's #000, which
* differs from the theme background (#0b0f14, set on the host wrapper in
* TerminalPane.tsx + via Terminal options.theme.background) and shows up
* as a visible right-edge gap.
*
* Setting viewport's background transparent lets the host wrapper's
* #0b0f14 show through, hiding the sub-cell remainder. Single source of
* truth for the bg color: the host.
*/
background-color: transparent !important;
}
.xterm .xterm-viewport::-webkit-scrollbar {
width: 0 !important;
height: 0 !important;
display: none !important;
}

View File

@@ -0,0 +1,33 @@
/* themes-v1: Chalk (family: light-only).
Anchors used directly as light palette: #fafaf7 #f0f0ec #e5e5e0 #75756e #2a2a28.
No .theme-chalk.dark — selecting dark mode on chalk falls back to obsidian
dark via lib/theme.ts. */
.theme-chalk {
--background: #fafaf7;
--foreground: #2a2a28;
--card: #f0f0ec;
--card-foreground: #2a2a28;
--popover: #f0f0ec;
--popover-foreground: #2a2a28;
--primary: #2a2a28;
--primary-foreground: #fafaf7;
--secondary: #e5e5e0;
--secondary-foreground: #2a2a28;
--muted: #e5e5e0;
--muted-foreground: #75756e;
--accent: #2a2a28;
--accent-foreground: #fafaf7;
--destructive: #b91c1c;
--destructive-foreground: #ffffff;
--border: #e5e5e0;
--input: #e5e5e0;
--ring: #2a2a28;
--sidebar: #f0f0ec;
--sidebar-foreground: #2a2a28;
--sidebar-primary: #2a2a28;
--sidebar-primary-foreground: #fafaf7;
--sidebar-accent: #e5e5e0;
--sidebar-accent-foreground: #2a2a28;
--sidebar-border: #e5e5e0;
--sidebar-ring: #2a2a28;
}

View File

@@ -0,0 +1,60 @@
/* themes-v1: Cobalt (family: blue).
Dark anchors: #020817 #061434 #0c2244 #3060a0 #0047ab. */
.theme-cobalt {
--background: #f4f8ff;
--foreground: #0a1428;
--card: #e8f0fc;
--card-foreground: #0a1428;
--popover: #e8f0fc;
--popover-foreground: #0a1428;
--primary: #003278;
--primary-foreground: #ffffff;
--secondary: #d4e0f4;
--secondary-foreground: #0a1428;
--muted: #d4e0f4;
--muted-foreground: #284878;
--accent: #003278;
--accent-foreground: #ffffff;
--destructive: #b91c1c;
--destructive-foreground: #ffffff;
--border: #d4e0f4;
--input: #d4e0f4;
--ring: #003278;
--sidebar: #e8f0fc;
--sidebar-foreground: #0a1428;
--sidebar-primary: #003278;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #d4e0f4;
--sidebar-accent-foreground: #0a1428;
--sidebar-border: #d4e0f4;
--sidebar-ring: #003278;
}
.theme-cobalt.dark {
--background: #020817;
--foreground: #dce4f0;
--card: #061434;
--card-foreground: #dce4f0;
--popover: #061434;
--popover-foreground: #dce4f0;
--primary: #0047ab;
--primary-foreground: #dce4f0;
--secondary: #0c2244;
--secondary-foreground: #dce4f0;
--muted: #0c2244;
--muted-foreground: #3060a0;
--accent: #0047ab;
--accent-foreground: #dce4f0;
--destructive: #dc2626;
--destructive-foreground: #ffffff;
--border: #0c2244;
--input: #0c2244;
--ring: #0047ab;
--sidebar: #061434;
--sidebar-foreground: #dce4f0;
--sidebar-primary: #0047ab;
--sidebar-primary-foreground: #dce4f0;
--sidebar-accent: #0047ab;
--sidebar-accent-foreground: #dce4f0;
--sidebar-border: #0c2244;
--sidebar-ring: #0047ab;
}

View File

@@ -0,0 +1,60 @@
/* themes-v1: Copper (family: orange/amber).
Dark anchors: #100800 #1c1408 #2e1f0a #8a6040 #b87333. */
.theme-copper {
--background: #fdf8f0;
--foreground: #2a1f0a;
--card: #faf0e0;
--card-foreground: #2a1f0a;
--popover: #faf0e0;
--popover-foreground: #2a1f0a;
--primary: #8a5424;
--primary-foreground: #ffffff;
--secondary: #f0e0c0;
--secondary-foreground: #2a1f0a;
--muted: #f0e0c0;
--muted-foreground: #6e4828;
--accent: #8a5424;
--accent-foreground: #ffffff;
--destructive: #b91c1c;
--destructive-foreground: #ffffff;
--border: #f0e0c0;
--input: #f0e0c0;
--ring: #8a5424;
--sidebar: #faf0e0;
--sidebar-foreground: #2a1f0a;
--sidebar-primary: #8a5424;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #f0e0c0;
--sidebar-accent-foreground: #2a1f0a;
--sidebar-border: #f0e0c0;
--sidebar-ring: #8a5424;
}
.theme-copper.dark {
--background: #100800;
--foreground: #f8e8c8;
--card: #1c1408;
--card-foreground: #f8e8c8;
--popover: #1c1408;
--popover-foreground: #f8e8c8;
--primary: #b87333;
--primary-foreground: #100800;
--secondary: #2e1f0a;
--secondary-foreground: #f8e8c8;
--muted: #2e1f0a;
--muted-foreground: #8a6040;
--accent: #b87333;
--accent-foreground: #100800;
--destructive: #dc2626;
--destructive-foreground: #ffffff;
--border: #2e1f0a;
--input: #2e1f0a;
--ring: #b87333;
--sidebar: #1c1408;
--sidebar-foreground: #f8e8c8;
--sidebar-primary: #b87333;
--sidebar-primary-foreground: #100800;
--sidebar-accent: #b87333;
--sidebar-accent-foreground: #100800;
--sidebar-border: #2e1f0a;
--sidebar-ring: #b87333;
}

View File

@@ -0,0 +1,60 @@
/* themes-v1: Crimson (family: red/crimson).
Dark anchors: #0e0404 #1a0808 #2e0a0a #8a3030 #dc143c. */
.theme-crimson {
--background: #fef4f6;
--foreground: #2a0a12;
--card: #fde6ea;
--card-foreground: #2a0a12;
--popover: #fde6ea;
--popover-foreground: #2a0a12;
--primary: #a40e2d;
--primary-foreground: #ffffff;
--secondary: #f4d0d8;
--secondary-foreground: #2a0a12;
--muted: #f4d0d8;
--muted-foreground: #6a2030;
--accent: #a40e2d;
--accent-foreground: #ffffff;
--destructive: #b91c1c;
--destructive-foreground: #ffffff;
--border: #f4d0d8;
--input: #f4d0d8;
--ring: #a40e2d;
--sidebar: #fde6ea;
--sidebar-foreground: #2a0a12;
--sidebar-primary: #a40e2d;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #f4d0d8;
--sidebar-accent-foreground: #2a0a12;
--sidebar-border: #f4d0d8;
--sidebar-ring: #a40e2d;
}
.theme-crimson.dark {
--background: #0e0404;
--foreground: #f0d4d8;
--card: #1a0808;
--card-foreground: #f0d4d8;
--popover: #1a0808;
--popover-foreground: #f0d4d8;
--primary: #dc143c;
--primary-foreground: #0e0404;
--secondary: #2e0a0a;
--secondary-foreground: #f0d4d8;
--muted: #2e0a0a;
--muted-foreground: #8a3030;
--accent: #dc143c;
--accent-foreground: #0e0404;
--destructive: #dc2626;
--destructive-foreground: #ffffff;
--border: #2e0a0a;
--input: #2e0a0a;
--ring: #dc143c;
--sidebar: #1a0808;
--sidebar-foreground: #f0d4d8;
--sidebar-primary: #dc143c;
--sidebar-primary-foreground: #0e0404;
--sidebar-accent: #dc143c;
--sidebar-accent-foreground: #0e0404;
--sidebar-border: #2e0a0a;
--sidebar-ring: #dc143c;
}

View File

@@ -0,0 +1,60 @@
/* themes-v1: Elderflower (family: purple/violet).
Dark anchors: #100818 #1c1024 #2c1830 #8a78a0 #b89cd8. */
.theme-elderflower {
--background: #faf8fd;
--foreground: #1f1428;
--card: #f4eef9;
--card-foreground: #1f1428;
--popover: #f4eef9;
--popover-foreground: #1f1428;
--primary: #8a70b4;
--primary-foreground: #ffffff;
--secondary: #e8def0;
--secondary-foreground: #1f1428;
--muted: #e8def0;
--muted-foreground: #6e5a82;
--accent: #8a70b4;
--accent-foreground: #ffffff;
--destructive: #b91c1c;
--destructive-foreground: #ffffff;
--border: #e8def0;
--input: #e8def0;
--ring: #8a70b4;
--sidebar: #f4eef9;
--sidebar-foreground: #1f1428;
--sidebar-primary: #8a70b4;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #e8def0;
--sidebar-accent-foreground: #1f1428;
--sidebar-border: #e8def0;
--sidebar-ring: #8a70b4;
}
.theme-elderflower.dark {
--background: #100818;
--foreground: #ece4f0;
--card: #1c1024;
--card-foreground: #ece4f0;
--popover: #1c1024;
--popover-foreground: #ece4f0;
--primary: #b89cd8;
--primary-foreground: #100818;
--secondary: #2c1830;
--secondary-foreground: #ece4f0;
--muted: #2c1830;
--muted-foreground: #8a78a0;
--accent: #b89cd8;
--accent-foreground: #100818;
--destructive: #dc2626;
--destructive-foreground: #ffffff;
--border: #2c1830;
--input: #2c1830;
--ring: #b89cd8;
--sidebar: #1c1024;
--sidebar-foreground: #ece4f0;
--sidebar-primary: #b89cd8;
--sidebar-primary-foreground: #100818;
--sidebar-accent: #b89cd8;
--sidebar-accent-foreground: #100818;
--sidebar-border: #2c1830;
--sidebar-ring: #b89cd8;
}

View File

@@ -0,0 +1,60 @@
/* themes-v1: Espresso (family: brown/earth).
Dark anchors: #1c1410 #241a14 #2e2218 #8a7058 #c8a880. */
.theme-espresso {
--background: #faf6f0;
--foreground: #2a1f15;
--card: #f3ece0;
--card-foreground: #2a1f15;
--popover: #f3ece0;
--popover-foreground: #2a1f15;
--primary: #9c7948;
--primary-foreground: #ffffff;
--secondary: #e6dccc;
--secondary-foreground: #2a1f15;
--muted: #e6dccc;
--muted-foreground: #6e5944;
--accent: #9c7948;
--accent-foreground: #ffffff;
--destructive: #b91c1c;
--destructive-foreground: #ffffff;
--border: #e6dccc;
--input: #e6dccc;
--ring: #9c7948;
--sidebar: #f3ece0;
--sidebar-foreground: #2a1f15;
--sidebar-primary: #9c7948;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #e6dccc;
--sidebar-accent-foreground: #2a1f15;
--sidebar-border: #e6dccc;
--sidebar-ring: #9c7948;
}
.theme-espresso.dark {
--background: #1c1410;
--foreground: #f0e8d8;
--card: #241a14;
--card-foreground: #f0e8d8;
--popover: #241a14;
--popover-foreground: #f0e8d8;
--primary: #c8a880;
--primary-foreground: #1c1410;
--secondary: #2e2218;
--secondary-foreground: #f0e8d8;
--muted: #2e2218;
--muted-foreground: #8a7058;
--accent: #c8a880;
--accent-foreground: #1c1410;
--destructive: #dc2626;
--destructive-foreground: #ffffff;
--border: #2e2218;
--input: #2e2218;
--ring: #c8a880;
--sidebar: #241a14;
--sidebar-foreground: #f0e8d8;
--sidebar-primary: #c8a880;
--sidebar-primary-foreground: #1c1410;
--sidebar-accent: #c8a880;
--sidebar-accent-foreground: #1c1410;
--sidebar-border: #2e2218;
--sidebar-ring: #c8a880;
}

View File

@@ -0,0 +1,60 @@
/* themes-v1: Fuchsia Noir (family: pink/magenta).
Dark anchors: #0a0610 #14081a #2a0c2e #8a3878 #ff1493. */
.theme-fuchsia-noir {
--background: #fdf4f8;
--foreground: #2a0a1c;
--card: #fbe6f0;
--card-foreground: #2a0a1c;
--popover: #fbe6f0;
--popover-foreground: #2a0a1c;
--primary: #c20070;
--primary-foreground: #ffffff;
--secondary: #f4d0e4;
--secondary-foreground: #2a0a1c;
--muted: #f4d0e4;
--muted-foreground: #7a2860;
--accent: #c20070;
--accent-foreground: #ffffff;
--destructive: #b91c1c;
--destructive-foreground: #ffffff;
--border: #f4d0e4;
--input: #f4d0e4;
--ring: #c20070;
--sidebar: #fbe6f0;
--sidebar-foreground: #2a0a1c;
--sidebar-primary: #c20070;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #f4d0e4;
--sidebar-accent-foreground: #2a0a1c;
--sidebar-border: #f4d0e4;
--sidebar-ring: #c20070;
}
.theme-fuchsia-noir.dark {
--background: #0a0610;
--foreground: #f0d8e8;
--card: #14081a;
--card-foreground: #f0d8e8;
--popover: #14081a;
--popover-foreground: #f0d8e8;
--primary: #ff1493;
--primary-foreground: #0a0610;
--secondary: #2a0c2e;
--secondary-foreground: #f0d8e8;
--muted: #2a0c2e;
--muted-foreground: #8a3878;
--accent: #ff1493;
--accent-foreground: #0a0610;
--destructive: #dc2626;
--destructive-foreground: #ffffff;
--border: #2a0c2e;
--input: #2a0c2e;
--ring: #ff1493;
--sidebar: #14081a;
--sidebar-foreground: #f0d8e8;
--sidebar-primary: #ff1493;
--sidebar-primary-foreground: #0a0610;
--sidebar-accent: #ff1493;
--sidebar-accent-foreground: #0a0610;
--sidebar-border: #2a0c2e;
--sidebar-ring: #ff1493;
}

View File

@@ -0,0 +1,60 @@
/* themes-v1: Gold (family: orange/amber).
Dark anchors: #0e0800 #1a1200 #2a1f00 #a07c30 #d4af37. */
.theme-gold {
--background: #fffbf0;
--foreground: #2a200a;
--card: #fdf3d0;
--card-foreground: #2a200a;
--popover: #fdf3d0;
--popover-foreground: #2a200a;
--primary: #a18229;
--primary-foreground: #ffffff;
--secondary: #f0e0a0;
--secondary-foreground: #2a200a;
--muted: #f0e0a0;
--muted-foreground: #786020;
--accent: #a18229;
--accent-foreground: #ffffff;
--destructive: #b91c1c;
--destructive-foreground: #ffffff;
--border: #f0e0a0;
--input: #f0e0a0;
--ring: #a18229;
--sidebar: #fdf3d0;
--sidebar-foreground: #2a200a;
--sidebar-primary: #a18229;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #f0e0a0;
--sidebar-accent-foreground: #2a200a;
--sidebar-border: #f0e0a0;
--sidebar-ring: #a18229;
}
.theme-gold.dark {
--background: #0e0800;
--foreground: #fff0d0;
--card: #1a1200;
--card-foreground: #fff0d0;
--popover: #1a1200;
--popover-foreground: #fff0d0;
--primary: #d4af37;
--primary-foreground: #0e0800;
--secondary: #2a1f00;
--secondary-foreground: #fff0d0;
--muted: #2a1f00;
--muted-foreground: #a07c30;
--accent: #d4af37;
--accent-foreground: #0e0800;
--destructive: #dc2626;
--destructive-foreground: #ffffff;
--border: #2a1f00;
--input: #2a1f00;
--ring: #d4af37;
--sidebar: #1a1200;
--sidebar-foreground: #fff0d0;
--sidebar-primary: #d4af37;
--sidebar-primary-foreground: #0e0800;
--sidebar-accent: #d4af37;
--sidebar-accent-foreground: #0e0800;
--sidebar-border: #2a1f00;
--sidebar-ring: #d4af37;
}

View File

@@ -0,0 +1,60 @@
/* themes-v1: Gunmetal (family: charcoal/black).
Dark anchors: #0d1117 #161b22 #21262d #7d8590 #388bfd. */
.theme-gunmetal {
--background: #fafafa;
--foreground: #14181f;
--card: #f1f3f6;
--card-foreground: #14181f;
--popover: #f1f3f6;
--popover-foreground: #14181f;
--primary: #0c6dd0;
--primary-foreground: #ffffff;
--secondary: #dde1e8;
--secondary-foreground: #14181f;
--muted: #dde1e8;
--muted-foreground: #5a6470;
--accent: #0c6dd0;
--accent-foreground: #ffffff;
--destructive: #b91c1c;
--destructive-foreground: #ffffff;
--border: #dde1e8;
--input: #dde1e8;
--ring: #0c6dd0;
--sidebar: #f1f3f6;
--sidebar-foreground: #14181f;
--sidebar-primary: #0c6dd0;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #dde1e8;
--sidebar-accent-foreground: #14181f;
--sidebar-border: #dde1e8;
--sidebar-ring: #0c6dd0;
}
.theme-gunmetal.dark {
--background: #0d1117;
--foreground: #e6ecf0;
--card: #161b22;
--card-foreground: #e6ecf0;
--popover: #161b22;
--popover-foreground: #e6ecf0;
--primary: #388bfd;
--primary-foreground: #0d1117;
--secondary: #21262d;
--secondary-foreground: #e6ecf0;
--muted: #21262d;
--muted-foreground: #7d8590;
--accent: #388bfd;
--accent-foreground: #0d1117;
--destructive: #dc2626;
--destructive-foreground: #ffffff;
--border: #21262d;
--input: #21262d;
--ring: #388bfd;
--sidebar: #161b22;
--sidebar-foreground: #e6ecf0;
--sidebar-primary: #388bfd;
--sidebar-primary-foreground: #0d1117;
--sidebar-accent: #388bfd;
--sidebar-accent-foreground: #0d1117;
--sidebar-border: #21262d;
--sidebar-ring: #388bfd;
}

View File

@@ -0,0 +1,33 @@
/* themes-v1: Ivory (family: light-only).
Anchors used directly as light palette: #fdfcf8 #f5f2e8 #e8e4d8 #8a8478 #3a3328.
No .theme-ivory.dark — selecting dark mode on ivory falls back to obsidian
dark via lib/theme.ts. */
.theme-ivory {
--background: #fdfcf8;
--foreground: #3a3328;
--card: #f5f2e8;
--card-foreground: #3a3328;
--popover: #f5f2e8;
--popover-foreground: #3a3328;
--primary: #3a3328;
--primary-foreground: #fdfcf8;
--secondary: #e8e4d8;
--secondary-foreground: #3a3328;
--muted: #e8e4d8;
--muted-foreground: #8a8478;
--accent: #3a3328;
--accent-foreground: #fdfcf8;
--destructive: #b91c1c;
--destructive-foreground: #ffffff;
--border: #e8e4d8;
--input: #e8e4d8;
--ring: #3a3328;
--sidebar: #f5f2e8;
--sidebar-foreground: #3a3328;
--sidebar-primary: #3a3328;
--sidebar-primary-foreground: #fdfcf8;
--sidebar-accent: #e8e4d8;
--sidebar-accent-foreground: #3a3328;
--sidebar-border: #e8e4d8;
--sidebar-ring: #3a3328;
}

View File

@@ -0,0 +1,60 @@
/* themes-v1: Matrix (family: green, neon).
Dark anchors: #000a00 #031403 #0a200a #208030 #00ff41. */
.theme-matrix {
--background: #f0fff4;
--foreground: #0a2a15;
--card: #e0f8e8;
--card-foreground: #0a2a15;
--popover: #e0f8e8;
--popover-foreground: #0a2a15;
--primary: #00b830;
--primary-foreground: #ffffff;
--secondary: #c0e8d0;
--secondary-foreground: #0a2a15;
--muted: #c0e8d0;
--muted-foreground: #208048;
--accent: #00b830;
--accent-foreground: #ffffff;
--destructive: #b91c1c;
--destructive-foreground: #ffffff;
--border: #c0e8d0;
--input: #c0e8d0;
--ring: #00b830;
--sidebar: #e0f8e8;
--sidebar-foreground: #0a2a15;
--sidebar-primary: #00b830;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #c0e8d0;
--sidebar-accent-foreground: #0a2a15;
--sidebar-border: #c0e8d0;
--sidebar-ring: #00b830;
}
.theme-matrix.dark {
--background: #000a00;
--foreground: #d8f8e0;
--card: #031403;
--card-foreground: #d8f8e0;
--popover: #031403;
--popover-foreground: #d8f8e0;
--primary: #00ff41;
--primary-foreground: #000a00;
--secondary: #0a200a;
--secondary-foreground: #d8f8e0;
--muted: #0a200a;
--muted-foreground: #208030;
--accent: #00ff41;
--accent-foreground: #000a00;
--destructive: #dc2626;
--destructive-foreground: #ffffff;
--border: #0a200a;
--input: #0a200a;
--ring: #00ff41;
--sidebar: #031403;
--sidebar-foreground: #d8f8e0;
--sidebar-primary: #00ff41;
--sidebar-primary-foreground: #000a00;
--sidebar-accent: #00ff41;
--sidebar-accent-foreground: #000a00;
--sidebar-border: #0a200a;
--sidebar-ring: #00ff41;
}

Some files were not shown because too many files have changed in this diff Show More