Compare commits

...

7 Commits

Author SHA1 Message Date
a8c84ecfe4 chore+docs: config, agent registry, codecontext, v2.6 spec, changelog
Working-tree config/doc changes (.gitignore, CLAUDE.md, AGENTS.md removal + data/AGENTS.md, codecontext Dockerfile/shim — pre-existing) plus this session's v2-6 persistent-agent-sessions openspec proposal/design/tasks (planning only; feature unimplemented, reserves the v2.6.0 tag) and the v2.5.2 CHANGELOG entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 03:12:31 +00:00
547fd70650 server/coder: working-tree backend changes (pre-existing)
Checkpoint of in-progress backend work present in the tree, not authored this session: auto_name, inference tool-phase/turn, secret_guard, provider-registry, plus a new agent-allowlist test (7 tests, passing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 03:12:16 +00:00
990a615b87 web(coder UI): ChatInput migration + Thinking render + DiffPanel route fix
Bundles in-progress working-tree UI work not authored this session (CoderPane ChatInput migration, AgentComposerBar/CoderMessageList/tab-bar/sidebar/pane refinements, provider icons) with this session's changes to the same files: MessageBubble renders a collapsible 'Thinking' block from reasoning_text/reasoning_parts (surfacing ACP agent_thought_chunk + native reasoning), and the DiffPanel approve/reject calls are repointed to the real /api/coder/pending/:id/apply and /reject routes (the old /sessions/:id/pending/:id/approve|reject paths did not exist).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 03:12:06 +00:00
5352fd9942 coder(pending): new-file-from-RightRail create endpoint + modal
POST /api/sessions/:sessionId/pending/create queues a pending_changes create via queueCreate (WriteGuardError -> 422 with the guard message). RightRail gains a 'New file from pasted text' modal (path + content) wired through api.coder.createPendingFile; sessionId is threaded down from App.tsx. The staged change shows in the CoderPane DiffPanel for explicit apply.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 03:11:50 +00:00
66df410826 web: fix mobile nav stuck-open on rejoin + paste-chip code fence
useViewport re-syncs the snapshot on pageshow/visibilitychange/resize/orientationchange — iOS reported a stale width on backgrounded-tab restore, leaving isMobile=false so the sidebar rendered as a permanent column with no close affordance. flattenToMessage now inserts pasted-text chips verbatim instead of wrapping them in a triple-backtick fence.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 03:11:42 +00:00
f89c8f3f15 coder(dispatcher): react to new tasks via LISTEN/NOTIFY, poll as fallback
AFTER INSERT trigger on tasks fires pg_notify('tasks_new'); the dispatcher listens via porsager sql.listen and triggers an immediate poll, with the setInterval poll kept at 2s as a missed-notification safety net. Per-session guard unchanged (no double-dispatch).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 03:11:34 +00:00
cbef7618b3 v2.5.1-budget-100: raise all tool call budgets to 100 + codecontextignore fix
Budget defaults raised from 50/10/50 to 100/100/100 (read-only,
non-read-only, no-agent). Per-agent max_tool_calls from AGENTS.md
still overrides.

Added .claude/worktrees/ to .codecontextignore to prevent
get_codebase_overview from parsing empty stub files in stale
worktree node_modules.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-28 02:40:26 +00:00
44 changed files with 1441 additions and 705 deletions

View File

@@ -21,6 +21,7 @@ out/
.opencode/
.vscode/
.idea/
.claude/worktrees/
# Test artifacts / coverage
coverage/

1
.gitignore vendored
View File

@@ -16,3 +16,4 @@ data/*
!data/AGENTS.md
!data/skills/
!data/mcp.json
codecontext/fork.tar.gz

109
AGENTS.md
View File

@@ -1,109 +0,0 @@
# Agent navigation
Cursor/agent entry point for the BooCode monorepo. **Deep engineering reference:** `CLAUDE.md` (Claude Code). This file is navigation + task routing only.
Last updated: 2026-05-25
## Doc map
| Need | Read |
|------|------|
| Commands, gotchas, inference, DB, env | `CLAUDE.md` |
| Read-only chat behavior | `BOOCHAT.md` |
| Write tools, dispatch, pending changes | `BOOCODER.md` |
| Shipped vs planned, version order | `boocode_roadmap.md` |
| Latest release truth | `CHANGELOG.md` (top entry = current) |
| System diagram + data flow | `docs/ARCHITECTURE.md` |
| Current focus / blockers | `CURRENT.md` |
| Batch convention | `openspec/README.md` |
| Shipped batch snapshots | `openspec/changes/archived/` |
| Chat agent personas + tool lists | `data/AGENTS.md` |
| External repo lift inventory | `boocode_code_review.md` |
## Monorepo layout (actual)
Three **surfaces**, four **packages**. There is no `apps/chat/` directory.
| Surface | Packages | Port | Deploy |
|---------|----------|------|--------|
| **BooChat** | `apps/server` (API + inference) + `apps/web` (SPA) | `100.114.205.53:9500` | Docker `boocode` container |
| **BooTerm** | `apps/booterm` | `100.114.205.53:9501` | Docker `booterm` container |
| **BooCoder** | `apps/coder` | host `:9502` | systemd `boocoder.service` (not Docker since v2.1.0) |
Shared: Postgres 16 — Docker service `boocode_db`, **database name `boochat`**, host port `127.0.0.1:5500`.
## Task routing
| Task type | Start here |
|-----------|------------|
| Chat inference / tools / compaction | `apps/server/src/services/inference/` |
| WS frames | `apps/server/src/types/ws-frames.ts` + `apps/web/src/api/ws-frames.ts` (keep in sync) |
| Frontend chat UI | `apps/web/src/components/`, hooks in `apps/web/src/hooks/` |
| BooCoder write tools / dispatch | `apps/coder/src/` — build server first (`pnpm -C apps/server build`) |
| Provider picker / external agents | `apps/coder/src/services/provider-registry.ts`, `dispatcher.ts`, `agent-probe.ts` |
| Terminal panes | `apps/booterm/src/`, frontend `TerminalPane.tsx` |
| Schema changes | `apps/server/src/schema.sql` + sync `*_STATUSES` in `apps/server/src/types/api.ts` |
| New batch / feature | `openspec/changes/<slug>/proposal.md` + `tasks.md` (see below) |
## Verification (before claiming done)
```bash
pnpm -C apps/server test && pnpm -C apps/server build
npx tsc -p apps/web/tsconfig.app.json --noEmit # root tsc can miss web errors
curl http://100.114.205.53:9500/api/health # Tailscale IP, not localhost:9500
curl http://100.114.205.53:9502/api/health # BooCoder on host
```
Deploy truth beats source-only reads — check running health + `git log --oneline -3`.
## Hard rules (from CLAUDE.md)
- **Do not commit or push** unless Sam explicitly asks.
- **No app-layer auth** — Authelia at the reverse proxy.
- **Parts table is source of truth** — read message tool fields from `messages_with_parts` view, write via `insertParts`.
- **New WS frame type** — update server + web schemas; publish via `publishFrame` / `publishUserFrame` only.
- **New tool** — own file in `services/`, register in `tools.ts` `ALL_TOOLS`; whitelists derive from there, never hardcoded.
- **Typecheck web with per-app tsconfig** — root `tsc --noEmit` uses project references and can miss errors.
- **`includeUsage: true`** on `createOpenAICompatible` in `provider.ts` — do not remove.
- **Agent dispatch** — direct `spawn`/`exec` on host via `install_path` (v2.1.0+); SSH helpers deprecated.
- **Event dedup** — server publishes via broker; frontend must not duplicate `sessionEvents.emit` after API calls that already WS-broadcast.
## Using openspec with Cursor
Openspec is a **folder convention**, not a CLI. Use it to give agents a scoped brief before coding.
### When starting a batch
1. Create `openspec/changes/<slug>/` (lowercase-hyphenated, e.g. `v2-2-arena-ui`).
2. Write `proposal.md` — why, scope, non-goals, dependencies.
3. Write `tasks.md` — numbered checkbox steps (build + smoke).
4. Optional `design.md` — schema/API decisions that outlive the batch.
See `openspec/README.md` for the full shape. Shipped pre-v1.13.15 batches live in `openspec/changes/archived/` as snapshots only.
### Prompting an agent
```
@openspec/changes/<slug>/proposal.md @openspec/changes/<slug>/tasks.md
Implement tasks 13. Server tests must pass. Do not commit.
```
Attach the spec files with `@` so they load into context. Point at specific code paths when known:
```
@openspec/changes/v2-x/proposal.md
Extend apps/coder/src/routes/providers.ts — follow provider-registry.ts patterns.
```
### After shipping
- Tag: `vMAJOR.MINOR.PATCH-slug`
- Add entry to top of `CHANGELOG.md`
- Move or snapshot the openspec folder to `archived/` if you want history preserved
- Update `CURRENT.md` and `boocode_roadmap.md` shipped table if the batch was roadmap-tracked
### What not to use openspec for
- One-line bug fixes — just describe the bug + file.
- Exploratory questions — Ask mode + `@CLAUDE.md` is enough.
- Duplicating `CLAUDE.md` — openspec is per-batch scope, not permanent conventions.

View File

@@ -2,6 +2,10 @@
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
## v2.5.2-coder-ux-fixes — 2026-05-29
Working-tree checkpoint bundling this session's fixes with in-progress coder UI work. This session: the BooCoder dispatcher now reacts to new tasks immediately via a Postgres `LISTEN/NOTIFY` (`tasks_new`) AFTER INSERT trigger, with the poll loop kept at 2s as a missed-notification fallback (`dispatcher.ts`, `apps/coder/src/schema.sql`); the mobile nav drawer no longer sticks open after returning to a backgrounded tab — `useViewport` re-syncs on `pageshow`/`visibilitychange`/`resize`/`orientationchange` (iOS reported a stale width on bfcache restore, leaving `isMobile=false`); assistant reasoning renders as a collapsible "Thinking" block in `MessageBubble`, surfacing ACP `agent_thought_chunk` from opencode/goose/qwen and native `reasoning_parts`; paste-to-chip inserts pasted text verbatim instead of wrapping it in a code fence; and a "New file from pasted text" affordance in the RightRail browser queues a `pending_changes` create through the new `POST /api/sessions/:id/pending/create` endpoint, paired with a fix repointing the DiffPanel's dead approve/reject calls to the real `/api/pending/:id/apply` and `/reject` routes. Also carried in the tree but not authored this session: the CoderPane `ChatInput` migration and `AgentComposerBar` refinements, plus backend tweaks to `auto_name`, inference `tool-phase`/`turn`, `secret_guard`, and `provider-registry`. Ships the `v2-6-persistent-agent-sessions` openspec proposal/design/tasks (free agent-switching with per-agent memory, opencode-as-server) as planning docs only — the feature is unimplemented and reserves the `v2.6.0` tag for it. Build green across server/coder/web; server suite 531 passing. (CHANGELOG note: the v2.3v2.5.1 entries were never backfilled and remain absent above.)
## v2.2.2-xml-placeholder-reject — 2026-05-26
Reject placeholder XML tool args at parse time in `extractToolCallBlocks` (`xml-parser.ts`). Drops calls when any string arg is `...`, empty/whitespace, `<path>`, `<file>`, `placeholder`, or angle-bracket sentinels; appends the raw XML block to flushed prose instead of silently deleting it. Fixes qwen3.6 answer-then-spurious-tools tail that caused duplicate assistant rows (full answer + failed `xml_call_*` tools + regenerated answer). Four new tests in `xml-parser.test.ts`. Known nit: rejection logs via `console.debug` instead of pino — filed in `docs/DEFERRED-WORK.md` §6 for a later cleanup.

View File

@@ -147,8 +147,9 @@ BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `bo
- 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.
- DB-integration tests opt-in via env var: `DATABASE_URL='postgres://boocode:devpass@localhost:5500/boochat' pnpm -C apps/server test`. Host port is 5500 (mapped from `boocode_db:5432`); password is `${POSTGRES_PASSWORD}` from `.env` (`devpass`), NOT the literal in `.env`'s `DATABASE_URL=postgres://boocode:Ketchup1479@boocode_db:5432/...` line. Pattern: `describe.runIf(!!process.env.DATABASE_URL)(...)` with a `beforeAll` that applies the schema via `sql.unsafe(readFileSync(schemaPath))`. Tests skip cleanly when var is unset. `tool_cost_stats.test.ts` is the reference.
- DB-integration tests opt-in via env var: `DATABASE_URL='postgres://boocode:devpass@localhost:5500/boochat' pnpm -C apps/server test`. Host port is 5500 (mapped from `boocode_db:5432`); password is `${POSTGRES_PASSWORD}` from `.env` (`devpass`), NOT the literal in `.env`'s `DATABASE_URL=postgres://boocode:Ketchup1479@boocode_db:5432/...` line. `psql` is not on the host PATH — for an interactive query use `docker exec boocode_db psql -U boocode -d boochat -c "..."`. Pattern: `describe.runIf(!!process.env.DATABASE_URL)(...)` with a `beforeAll` that applies the schema via `sql.unsafe(readFileSync(schemaPath))`. Tests skip cleanly when var is unset. `tool_cost_stats.test.ts` is the reference.
- Host-side smoke endpoint: `curl http://100.114.205.53:9500/api/...`. The boocode container's port mapping binds to the Tailscale IP, not `0.0.0.0`, so `localhost:9500` doesn't work from the host shell. Same for booterm at `:9501`.
- Frontend blank-screen / runtime crash: get the stack-trace column offset from the browser console, then `cut -c <start>-<end> apps/web/dist/assets/index-*.js | sed -n '<line>p'` to read the exact minified expression that threw. Faster than bisecting source. Watch for `=== null`/`!== null` on optional fields fed an `as unknown as` cast — those bypass tsc.
- 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.
@@ -157,8 +158,8 @@ BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `bo
- 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.
- codecontext sidecar lives at `/opt/boocode/codecontext/`. Sidecar HTTP API at `http://codecontext:8080/v1/<tool_name>` over the `boocode_net` bridge (no host port). BooCode wrappers in `apps/server/src/services/tools/codecontext/`. The `.codecontextignore.template` documents recommended ignore patterns; users copy and adapt to project root manually.
- codecontext fork at `/opt/forks/codecontext/` — separate git repo (branch `boocode-ts`), pushed via the same boocode_gitea SSH key to `indifferentketchup/codecontext`. Build: `go build ./...`. Test: `go test ./...`. Docker rebuild: `docker compose build --no-cache codecontext`.
- codecontext sidecar lives at `/opt/boocode/codecontext/`. Sidecar HTTP API at `http://codecontext:8080/v1/<tool_name>` over the `boocode_net` bridge (no host port). BooCode wrappers in `apps/server/src/services/tools/codecontext/`. The `.codecontextignore` at project root is honored when `--respect-gitignore` is passed (enabled in the shim).
- codecontext fork at `/opt/forks/codecontext/` — separate git repo (branch `boocode-ts`), pushed via the same boocode_gitea SSH key to `indifferentketchup/codecontext`. Build: `go build ./...`. Test: `go test ./...`. Docker rebuild requires staging the fork source first: `tar -czf codecontext/fork.tar.gz -C /opt/forks/codecontext --exclude=.git --exclude=bin .` then `docker compose build --no-cache codecontext`. The Dockerfile COPYs `fork.tar.gz` into the builder stage (Gitea is behind Authelia, no HTTP clone). `fork.tar.gz` is gitignored.
- Go binary: `/snap/go/current/bin/go` (not on PATH by default). Use `export PATH=$PATH:/snap/go/current/bin` or full path for Go commands.
- `os/exec` child supervisors must explicitly call `child.Wait()` in a goroutine and `os.Exit` on child death. `Signal(0)` returns nil on zombies and is NOT a liveness check. Without `Wait()`, docker's `restart: unless-stopped` policy never fires because the parent stays alive. The `codecontext/shim.go` implementation is the reference pattern.
@@ -185,3 +186,11 @@ BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `bo
- **Workspace dependency pattern** (`apps/coder``@boocode/server`): the consuming package adds `"@boocode/server": "workspace:*"` in `package.json`. The provider's `package.json` needs `exports` with `types` + `default` conditions per subpath: `"./inference": { "types": "./dist/.../index.d.ts", "default": "./dist/.../index.js" }`. Without the `types` condition, NodeNext resolution can't find `.d.ts` files and tsc fails with "Cannot find module" in the consumer.
- **JSONB columns**: use `sql.json(value as never)` — NOT `${JSON.stringify(value)}::jsonb` which double-serializes (stores a JSON string instead of a JSON object/array). Pattern established in `parts.ts`, `settings.ts`.
- **`payload.ts:loadContext` SELECT**: must include every `Session` field that downstream code reads. The tool phase reads `session.allowed_read_paths`; if the SELECT omits it, cross-repo read grants silently fail. The `Session` TypeScript type doesn't catch this because `sql<Session[]>` doesn't enforce column coverage.
- **Sidecar routing** (`services/inference/provider.ts`): `upstreamModel(config, modelId, agent)` routes to `LLAMA_SIDECAR_URL` when agent has `llama_extra_args`, otherwise `LLAMA_SWAP_URL`. `resolveRoute(agent)` returns `{route: 'swap'|'sidecar', flags}`. Sidecar provider created fresh per call (not cached) because `X-Agent-Flags` header varies per agent. Boot-time guard in `index.ts` refuses to start if any agent has `llama_extra_args` but `LLAMA_SIDECAR_URL` is unset.
- **Secret guard safe patterns** (`services/secret_guard.ts`): `.env.example`, `.env.sample`, `.env.template`, `.env.defaults` are allowlisted via `SAFE_PATTERNS` set. Do NOT add `.env.production`/`.env.development`/`.env.test` — those can hold real secrets.
- **CoderPane uses ChatInput** (`components/panes/CoderPane.tsx`): shares the same `ChatInput` component as BooChat for full parity — attachments, paste-to-chip, auto-grow textarea, queued messages during send. CoderPane's `sendOneMessage` is the send callback; queued messages drain via `useEffect` when `sending` goes false.
- **Adding a new `SessionEvent` type**: add the interface, add it to the `SessionEvent` union, add a `case` in `useSidebar.ts` `applyEvent` switch (no-op `return prev` is fine), and subscribe in any hook that needs it (e.g. `useSessionStream` for `refetch_messages`).
- **BooCoder provider registry** (`apps/coder/src/services/provider-registry.ts`): static list of provider defs (boocode, opencode, goose, claude, qwen). `PROBED_AGENT_NAMES` derives from it. Adding/removing providers means editing this file, not the frontend.
- **Pane header architecture (mobile vs desktop)**: Desktop coder pane header (BooCode label + [+] [×]) lives in `Workspace.tsx` gated by `isCoder && !isMobile`. Mobile coder controls (● ×) live in `Session.tsx` header row next to `MobileTabSwitcher`/`NewPaneMenu`. `AgentComposerBar` (provider/mode/model pickers) renders inside `CoderPane.tsx` on both. The ● status dot is passed via `connected` prop from CoderPane to AgentComposerBar.
- **MessageBubble shared between BooChat and BooCoder** (`components/MessageBubble.tsx`): accepts optional `actions?: MessageActions` callbacks (onRegenerate, onResend, onFork, onDelete) and `hideActions?: ('fork'|'delete'|'openInPane')[]`. Defaults use BooChat API; CoderPane overrides via `CoderMessageList` props. `CoderTextBubble` was removed. **`CoderMessageList` passes `CoderMessageWire as unknown as Message`** — the coder wire shape lacks `metadata`/`kind`/`summary`, so those fields are `undefined` (not `null`) on coder messages. Null-guards on any `Message` field MUST use loose `!= null`, not strict `!== null` (`undefined !== null` is `true``.kind` throws → blank-screen crash). The `as unknown as` cast hides this from tsc; build + typecheck pass while runtime crashes.
- **llama-sidecar** (`/opt/forks/llama-sidecar/`): Go daemon for per-agent llama-server process pool. Cross-compile: `GOOS=windows GOARCH=amd64 /snap/go/current/bin/go build -o bin/llama-sidecar.exe ./cmd/llama-sidecar`. Gitea: `indifferentketchup/llama-sidecar`. Windows child process gotchas: use `context.Background()` for child lifetime (not request ctx), `os.Open(os.DevNull)` for stdin, `os.Pipe()` for stdout with drain goroutine, `DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP` creation flags. SSH to sam-desktop: `ssh samki@100.101.41.16`; use `schtasks` for persistent process spawning (SSH `start /B` doesn't survive session close).

View File

@@ -1,4 +1,5 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { Sql } from '../db.js';
import {
listPending,
@@ -6,7 +7,14 @@ import {
applyAll,
rejectOne,
rewindOne,
queueCreate,
} from '../services/pending_changes.js';
import { WriteGuardError } from '../services/write_guard.js';
const CreateBody = z.object({
file_path: z.string().min(1),
content: z.string(),
});
/**
* Resolve project root from a session's project path.
@@ -51,6 +59,49 @@ export function registerPendingRoutes(app: FastifyInstance, sql: Sql): void {
},
);
// POST /api/sessions/:sessionId/pending/create — queue a new-file create
// (manual create from the RightRail file browser; no inference involved).
// queueCreate runs resolveWritePath internally, so a path that escapes the
// project root or hits a secret file throws WriteGuardError → 422 with the
// guard message. Mirrors the { error } 404 shape used by the other routes
// and the 422 status used by apply/rewind on failure.
app.post<{ Params: { sessionId: string } }>(
'/api/sessions/:sessionId/pending/create',
async (req, reply) => {
const sessionId = req.params.sessionId;
const parsed = CreateBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const projectRoot = await resolveProjectRoot(sql, sessionId);
if (!projectRoot) {
reply.code(404);
return { error: 'session or project not found' };
}
try {
const change = await queueCreate(
sql,
sessionId,
null,
parsed.data.file_path,
parsed.data.content,
projectRoot,
);
return change;
} catch (err) {
if (err instanceof WriteGuardError) {
reply.code(422);
return { error: err.message };
}
throw err;
}
},
);
// POST /api/sessions/:sessionId/pending/apply — apply all pending changes
app.post<{ Params: { sessionId: string } }>(
'/api/sessions/:sessionId/pending/apply',

View File

@@ -71,3 +71,22 @@ ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS transport TEXT DEFAULT 'pt
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS mode_id TEXT;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS thinking_option_id TEXT;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS feature_values JSONB;
-- LISTEN/NOTIFY fast path: every tasks INSERT (from any call site — routes,
-- new_task tool, arena, MCP server) fires pg_notify('tasks_new') in the same
-- transaction, so the dispatcher reacts immediately instead of waiting for the
-- fallback poll. Postgres holds the notification until COMMIT, so the listener
-- always sees the committed row. A trigger covers all insert paths with no
-- app-code drift. Idempotent: re-applied on every startup.
CREATE OR REPLACE FUNCTION notify_tasks_new() RETURNS trigger AS $$
BEGIN
PERFORM pg_notify('tasks_new', '');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS tasks_notify_new ON tasks;
CREATE TRIGGER tasks_notify_new
AFTER INSERT ON tasks
FOR EACH ROW
EXECUTE FUNCTION notify_tasks_new();

View File

@@ -24,16 +24,29 @@ interface Deps {
config: Config;
}
const POLL_INTERVAL_MS = 5_000;
// LISTEN/NOTIFY ('tasks_new') is the fast path — the dispatcher reacts to new
// tasks immediately. The poll is only a safety net for notifications missed
// during a listen-connection drop (porsager auto-reconnects), so it can stay slow.
const POLL_INTERVAL_MS = 2_000;
const COMPLETION_POLL_MS = 2_000;
export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<void> } {
const { sql, inference, broker, log, config } = deps;
let timer: ReturnType<typeof setInterval> | null = null;
let listener: { unlisten: () => Promise<void> } | null = null;
let running = false;
let stopping = false;
let inflightPromise: Promise<void> | null = null;
// Shared entry point for both the poll timer and the NOTIFY listener. poll()'s
// `running`/`stopping` guard makes this safe to call concurrently — a notify
// arriving mid-task returns immediately and never double-dispatches.
function triggerPoll(reason: string): void {
poll().catch((err) => {
log.error({ err, reason }, 'dispatcher: poll error');
});
}
async function poll(): Promise<void> {
if (running || stopping) return;
@@ -463,12 +476,28 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
return {
start() {
log.info('dispatcher: starting poll loop');
timer = setInterval(() => {
poll().catch((err) => {
log.error({ err }, 'dispatcher: poll error');
log.info('dispatcher: starting poll loop + tasks_new listener');
// Fallback poll — catches notifications missed while the listen connection
// was down. The fast path is the NOTIFY listener below.
timer = setInterval(() => triggerPoll('interval'), POLL_INTERVAL_MS);
// Fast path: react immediately to new tasks. porsager reserves a dedicated
// connection and auto-resubscribes on reconnect; the onlisten callback
// fires on each (re)subscribe, so we kick a catch-up poll there too to
// sweep up anything inserted during a disconnect.
sql
.listen(
'tasks_new',
() => triggerPoll('notify'),
() => triggerPoll('listen-subscribed'),
)
.then((meta) => {
listener = meta;
})
.catch((err) => {
log.error({ err }, 'dispatcher: failed to LISTEN tasks_new — relying on poll fallback');
});
}, POLL_INTERVAL_MS);
},
async stop() {
@@ -477,6 +506,12 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
clearInterval(timer);
timer = null;
}
if (listener) {
await listener.unlisten().catch((err) => {
log.error({ err }, 'dispatcher: unlisten error');
});
listener = null;
}
if (inflightPromise) {
log.info('dispatcher: waiting for in-flight task');
await inflightPromise;

View File

@@ -24,12 +24,6 @@ export const PROVIDERS: ProviderDef[] = [
transport: 'native',
modelSource: 'llama-swap',
},
{
name: 'cursor',
label: 'Cursor Agent',
transport: 'acp',
modelSource: 'probe',
},
{
name: 'opencode',
label: 'OpenCode',
@@ -59,12 +53,6 @@ export const PROVIDERS: ProviderDef[] = [
transport: 'acp',
modelSource: 'probe',
},
{
name: 'copilot',
label: 'GitHub Copilot',
transport: 'acp',
modelSource: 'probe',
},
];
export const PROVIDERS_BY_NAME = new Map(PROVIDERS.map((p) => [p.name, p]));

View File

@@ -0,0 +1,107 @@
import { describe, it, expect } from 'vitest';
import { parseAgentsMd, matchToolGlob } from '../agents.js';
import { toolJsonSchemas } from '../tools.js';
describe('agent tool allowlist', () => {
const plannerMd = `# Agents
## Planner
---
temperature: 0.6
tools: [view_file, grep, list_dir, find_files]
description: Read-only planner
---
You plan.
`;
it('parses an agent with a restricted tool allowlist', () => {
const { agents, errors } = parseAgentsMd(plannerMd);
expect(errors).toHaveLength(0);
expect(agents).toHaveLength(1);
const planner = agents[0]!;
expect(planner.name).toBe('Planner');
expect(planner.tools).toEqual(['view_file', 'grep', 'list_dir', 'find_files']);
});
it('stream-phase filter: agent allowlist excludes tools not in the list', () => {
const { agents } = parseAgentsMd(plannerMd);
const planner = agents[0]!;
const allSchemas = toolJsonSchemas();
const filtered = allSchemas.filter((t) =>
matchToolGlob(t.function.name, planner.tools),
);
const filteredNames = filtered.map((t) => t.function.name);
expect(filteredNames).toContain('view_file');
expect(filteredNames).toContain('grep');
expect(filteredNames).not.toContain('edit_file');
expect(filteredNames).not.toContain('web_search');
expect(filteredNames).not.toContain('get_codebase_overview');
expect(filtered).toHaveLength(4);
});
it('tool-phase guard: rejects tool call not in agent allowlist', () => {
const { agents } = parseAgentsMd(plannerMd);
const planner = agents[0]!;
expect(matchToolGlob('edit_file', planner.tools)).toBe(false);
expect(matchToolGlob('create_file', planner.tools)).toBe(false);
expect(matchToolGlob('delete_file', planner.tools)).toBe(false);
expect(matchToolGlob('web_search', planner.tools)).toBe(false);
});
it('tool-phase guard: allows tool call in agent allowlist', () => {
const { agents } = parseAgentsMd(plannerMd);
const planner = agents[0]!;
expect(matchToolGlob('view_file', planner.tools)).toBe(true);
expect(matchToolGlob('grep', planner.tools)).toBe(true);
expect(matchToolGlob('list_dir', planner.tools)).toBe(true);
expect(matchToolGlob('find_files', planner.tools)).toBe(true);
});
it('null/absent tools field defaults to all tools (no regression)', () => {
const noToolsMd = `# Agents
## Default
---
temperature: 0.7
description: Uses all tools
---
Default agent.
`;
const { agents } = parseAgentsMd(noToolsMd);
const agent = agents[0]!;
const allSchemas = toolJsonSchemas();
const filtered = allSchemas.filter((t) =>
matchToolGlob(t.function.name, agent.tools),
);
expect(filtered.length).toBe(allSchemas.length);
});
it('builder agent: write tools filtered out when not in ALL_TOOLS (BooChat context)', () => {
const builderMd = `# Agents
## Builder
---
temperature: 0.6
tools: [view_file, grep, list_dir, find_files, edit_file, create_file, delete_file, apply_pending, rewind]
description: Read and write tools
---
You build.
`;
const { agents } = parseAgentsMd(builderMd);
const builder = agents[0]!;
expect(matchToolGlob('view_file', builder.tools)).toBe(true);
expect(matchToolGlob('grep', builder.tools)).toBe(true);
// Write tools not in server's ALL_TOOLS are silently filtered during parsing.
// In BooCoder context (where ALL_TOOLS includes write tools), they'd be retained.
expect(builder.tools).not.toContain('edit_file');
expect(builder.tools).not.toContain('create_file');
expect(matchToolGlob('web_search', builder.tools)).toBe(false);
});
it('matchToolGlob rejects hallucinated tool against exact allowlist', () => {
const allowlist = ['view_file', 'grep', 'list_dir'];
expect(matchToolGlob('edit_file', allowlist)).toBe(false);
expect(matchToolGlob('rm_rf', allowlist)).toBe(false);
expect(matchToolGlob('view_file_extended', allowlist)).toBe(false);
});
});

View File

@@ -45,22 +45,26 @@ export async function maybeAutoNameChat(
if (!chat) return;
if (chat.name !== null && chat.name !== '') return;
const assistantMsg = await ctx.sql<{ content: string }[]>`
SELECT content FROM messages
const firstMsgs = await ctx.sql<{ role: string; content: string }[]>`
SELECT role, content FROM messages
WHERE chat_id = ${chatId}
AND role = 'assistant'
AND status = 'complete'
AND role IN ('user', 'assistant')
AND status IN ('complete', 'ok')
AND content <> ''
ORDER BY created_at ASC
LIMIT 1
LIMIT 2
`;
if (!assistantMsg[0]) return;
const userMsg = firstMsgs.find(m => m.role === 'user');
const assistantMsg = firstMsgs.find(m => m.role === 'assistant');
if (!assistantMsg) return;
const assistantText = assistantMsg[0].content.slice(0, 2000);
let namingInput = '';
if (userMsg) namingInput += `User: ${userMsg.content.slice(0, 1000)}\n\n`;
namingInput += `Assistant: ${assistantMsg.content.slice(0, 1000)}`;
const raw = await taskModelCompletion({
system: NAMING_SYSTEM_PROMPT,
user: assistantText,
user: namingInput,
maxTokens: 30,
temperature: 0.3,
});

View File

@@ -18,9 +18,9 @@ import { READ_ONLY_TOOL_NAMES } from '../tools.js';
// turns + deeper exploration without changing the safety floor materially —
// the doom-loop guard (3 identical calls → abort) catches the actual failure
// mode this cap was guarding against.
export const BUDGET_READ_ONLY = 50;
export const BUDGET_NON_READ_ONLY = 10;
export const BUDGET_NO_AGENT = 50;
export const BUDGET_READ_ONLY = 100;
export const BUDGET_NON_READ_ONLY = 100;
export const BUDGET_NO_AGENT = 100;
const READ_ONLY_SET: ReadonlySet<string> = new Set(READ_ONLY_TOOL_NAMES);

View File

@@ -1,7 +1,8 @@
import type { Session, ToolCall } from '../../types/api.js';
import type { Agent, Session, ToolCall } from '../../types/api.js';
import * as modelContext from '../model-context.js';
import { PathScopeError } from '../path_guard.js';
import { TOOLS_BY_NAME } from '../tools.js';
import { matchToolGlob } from '../agents.js';
import { maybeFlagForCompaction } from './payload.js';
import { insertParts, partsFromAssistantMessage, partsFromToolMessage } from './parts.js';
// v1.13.16: richer unknown-tool error so the model can self-correct when it
@@ -98,7 +99,8 @@ export async function executeToolPhase(
result: StreamResult,
startedAt: string | null,
session: Session,
projectRoot: string
projectRoot: string,
agent?: Agent | null,
): Promise<ToolPhaseResult> {
const { sessionId, chatId, assistantMessageId } = args;
const content = stripToolMarkup(result.content, { final: true });
@@ -262,6 +264,31 @@ export async function executeToolPhase(
);
return;
}
if (agent && !matchToolGlob(tc.name, agent.tools)) {
const stored = {
tool_call_id: tc.id,
output: null,
truncated: false,
error: `tool '${tc.name}' is not allowed for agent '${agent.name}'`,
};
await insertParts(
ctx.sql,
partsFromToolMessage({ tool_results: stored }).map((p) => ({
...p,
message_id: toolMessageId,
})),
);
ctx.publish(sessionId, {
type: 'tool_result',
tool_message_id: toolMessageId,
chat_id: chatId,
tool_call_id: tc.id,
output: stored.output,
truncated: false,
error: stored.error,
});
return;
}
const tres = await executeToolCall(projectRoot, tc, session.allowed_read_paths);
if (SYNTHESIS_TOOLS.has(tc.name)) {
synthEntries.push({ tc, output: tres.output, ...(tres.error ? { error: tres.error } : {}) });

View File

@@ -292,7 +292,7 @@ export async function runAssistantTurn(
// ---- tool phase ----
let toolPhaseResult: ToolPhaseResult;
try {
toolPhaseResult = await executeToolPhase(ctx, iterArgs, result, state.startedAt, iterSession, projectRoot);
toolPhaseResult = await executeToolPhase(ctx, iterArgs, result, state.startedAt, iterSession, projectRoot, agent);
} catch (err) {
// Tool phase errors are unexpected (individual tool failures are
// caught inside executeToolPhase). Log and break.

View File

@@ -163,6 +163,13 @@ const COMPILED: ReadonlyArray<CompiledPattern> = DEFAULT_SECURITY_IGNORE_FILETYP
// Returns true when `relPath` matches a known-secret pattern. Case-insensitive
// (regex 'i' flag). Always normalize path separators to `/` so Windows-origin
// paths match the same patterns. Empty or root-only paths return false.
const SAFE_PATTERNS: ReadonlySet<string> = new Set([
'.env.example',
'.env.sample',
'.env.template',
'.env.defaults',
]);
export function isSecretPath(relPath: string): boolean {
if (!relPath) return false;
const normalized = relPath.replace(/\\/g, '/');
@@ -170,6 +177,8 @@ export function isSecretPath(relPath: string): boolean {
if (segments.length === 0) return false;
const base = segments[segments.length - 1]!;
if (SAFE_PATTERNS.has(base.toLowerCase())) return false;
for (const compiled of COMPILED) {
if (compiled.mode === 'basename') {
if (compiled.regex.test(base)) return true;

View File

@@ -33,7 +33,7 @@ function RightRailForSession({ sessionId }: { sessionId: string }) {
// a right-side drawer toggled by the header's FolderTree button (via
// useRightRailDrawer). On desktop, it renders inline as before with its
// own internal open/close state.
return <RightRail projectId={projectId} />;
return <RightRail projectId={projectId} sessionId={sessionId} />;
}
function MobileBackdrop() {

View File

@@ -346,6 +346,23 @@ export const api = {
user_message: userMessage,
}),
}),
// Queue a new-file create from the RightRail browser → BooCoder
// pending_changes (operation='create'). Surfaces in the CoderPane DiffPanel
// for explicit apply. A WriteGuardError comes back as a 422 whose { error }
// body ApiError exposes as .message for inline display.
createPendingFile: (sessionId: string, file_path: string, content: string) =>
request<{
id: string;
session_id: string;
task_id: string | null;
file_path: string;
operation: string;
status: string;
created_at: string;
}>(`/api/coder/sessions/${sessionId}/pending/create`, {
method: 'POST',
body: JSON.stringify({ file_path, content }),
}),
},
agents: {

View File

@@ -182,10 +182,14 @@ export interface Message {
// majority of messages.
metadata: MessageMetadata | null;
// v1.13.1-C: reasoning content captured from models that stream reasoning
// tokens separately (qwen3.6 etc.). Backend populates from message_parts;
// optional on the wire — frontend doesn't render this yet (reserved for
// a v1.14 UI surface).
// tokens separately (qwen3.6 etc.) and from external agents over ACP
// (agent_thought_chunk). Backend populates from message_parts; rendered by
// MessageBubble as a collapsible "Thinking" block.
reasoning_parts?: Array<{ text: string }> | null;
// Coder wire shape pre-joins reasoning_parts into a single string
// (CoderPane/CoderMessageList) and streams it live via reasoning_delta
// frames. MessageBubble reads whichever of the two is present.
reasoning_text?: string | 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

View File

@@ -9,6 +9,7 @@ interface Props {
export function AgentCommandsHint({ commands }: Props) {
const [open, setOpen] = useState(false);
const [expanded, setExpanded] = useState<string | null>(null);
if (commands.length === 0) return null;
@@ -25,10 +26,19 @@ export function AgentCommandsHint({ commands }: Props) {
{open && (
<ul className="px-2 pb-2 space-y-1 border-t border-border/40 max-h-48 overflow-y-auto overscroll-contain touch-pan-y">
{commands.map((cmd) => (
<li key={cmd.name} className="font-mono">
<span className="text-primary/80">/{cmd.name}</span>
<li
key={cmd.name}
className="cursor-pointer"
onClick={() => setExpanded((v) => v === cmd.name ? null : cmd.name)}
>
<span className="font-mono text-primary/80">/{cmd.name}</span>
{cmd.description && (
<span className="ml-1.5 text-muted-foreground font-sans line-clamp-1">{cmd.description}</span>
<span className={cn(
'ml-1.5 text-muted-foreground font-sans',
expanded === cmd.name ? '' : 'line-clamp-2',
)}>
{cmd.description}
</span>
)}
</li>
))}

View File

@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Check, ChevronDown, RefreshCw, Shield, Cpu, Brain } from 'lucide-react';
import { Check, ChevronDown, RefreshCw, Shield, Brain, Bird, Bot, Dog, Terminal as TermIcon } from 'lucide-react';
import { ClaudeIcon, OpenCodeIcon } from '@/components/icons/ProviderIcons';
import { api } from '@/api/client';
import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types';
import { useProviderSnapshot, refreshProviderSnapshot } from '@/hooks/useProviderSnapshot';
@@ -125,9 +126,11 @@ function CompactPicker({ label, value, disabled, options, onPick, icon }: Picker
disabled={disabled}
onClick={() => setOpen(true)}
aria-label={`${label}: ${currentLabel}`}
className="inline-flex items-center justify-center min-h-[44px] min-w-[44px] rounded text-muted-foreground hover:text-foreground disabled:opacity-40"
className="inline-flex items-center gap-1 min-h-[44px] px-1.5 rounded text-xs text-muted-foreground hover:text-foreground disabled:opacity-40"
>
{icon ?? <Cpu className="size-4" />}
{icon}
<span className="truncate max-w-[120px]">{currentLabel}</span>
<ChevronDown className="size-3 opacity-70 shrink-0" />
</button>
<BottomSheet open={open} onClose={() => setOpen(false)} title={label}>
<div className="px-2">{list}</div>
@@ -142,16 +145,16 @@ function CompactPicker({ label, value, disabled, options, onPick, icon }: Picker
<button
type="button"
disabled={disabled}
className="text-xs font-mono text-muted-foreground hover:text-foreground flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted/60 disabled:opacity-40 max-w-[140px]"
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 disabled:opacity-40"
>
{icon}
<span className="truncate">{currentLabel}</span>
<span className="truncate max-w-[180px]">{currentLabel}</span>
<ChevronDown className="size-3 opacity-70 shrink-0" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-64 overflow-y-auto min-w-[160px]">
{options.map((o) => (
<DropdownMenuItem key={o.id} onSelect={() => onPick(o.id)} className="font-mono text-xs">
<DropdownMenuItem key={o.id} onSelect={() => onPick(o.id)} className="text-xs">
<Check className={cn('size-3 shrink-0', o.id === value ? 'opacity-100' : 'opacity-0')} />
{o.label}
</DropdownMenuItem>
@@ -166,9 +169,10 @@ interface Props {
value: AgentSessionConfig;
onChange: (next: AgentSessionConfig) => void;
onProviderCommandsChange?: (commands: AgentCommand[]) => void;
connected?: boolean;
}
export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange }: Props) {
export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange, connected }: Props) {
const allEntries = useProviderSnapshot(projectPath);
const entries = useMemo(
() => allEntries?.filter((e) => e.installed && e.status !== 'error') ?? null,
@@ -255,6 +259,16 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
);
}
const providerIcon = (name: string) => {
switch (name) {
case 'claude': return <ClaudeIcon size={13} className="shrink-0" />;
case 'opencode': return <OpenCodeIcon size={13} className="shrink-0" />;
case 'goose': return <Bird size={13} className="shrink-0" />;
case 'qwen': return <TermIcon size={13} className="shrink-0" />;
default: return <Dog size={13} className="shrink-0" />;
}
};
const providerOptions = entries.map((e) => ({ id: e.name, label: e.label }));
const modeOptions = (currentEntry?.modes ?? []).map((m) => ({ id: m.id, label: m.label }));
const modelOptions = (currentEntry?.models ?? []).map((m) => ({ id: m.id, label: m.label }));
@@ -267,7 +281,7 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
value={value.provider}
options={providerOptions}
onPick={pickProvider}
icon={<Cpu className="size-3 shrink-0" />}
icon={providerIcon(value.provider)}
/>
<CompactPicker
label="Mode"
@@ -283,6 +297,7 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
disabled={modelOptions.length === 0}
options={modelOptions}
onPick={pickModel}
icon={<Bot size={13} className="shrink-0" />}
/>
{thinkingOpts.length > 0 && (
<CompactPicker
@@ -293,11 +308,17 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
icon={<Brain className="size-3 shrink-0" />}
/>
)}
{connected !== undefined && (
<span
className={cn('inline-block w-1.5 h-1.5 rounded-full shrink-0 ml-auto', connected ? 'bg-green-500' : 'bg-red-500')}
title={connected ? 'Connected' : 'Disconnected'}
/>
)}
<button
type="button"
onClick={() => void handleRefresh()}
disabled={refreshing}
className="ml-auto inline-flex items-center justify-center size-7 max-md:min-h-[44px] max-md:min-w-[44px] rounded text-muted-foreground hover:text-foreground disabled:opacity-40"
className={cn('inline-flex items-center justify-center size-7 max-md:min-h-[44px] max-md:min-w-[44px] rounded text-muted-foreground hover:text-foreground disabled:opacity-40', connected === undefined && 'ml-auto')}
aria-label="Refresh provider list"
title="Refresh providers"
>

View File

@@ -22,6 +22,7 @@ import { AttachmentPreviewModal } from '@/components/AttachmentPreviewModal';
import { FileMentionPopover } from '@/components/FileMentionPopover';
import { DropOverlay } from '@/components/DropOverlay';
import { AgentPicker } from '@/components/AgentPicker';
import { AgentCommandsHint } from '@/components/AgentCommandsHint';
import { ContextBar } from '@/components/ContextBar';
import { SlashCommandPicker } from '@/components/SlashCommandPicker';
import { isSlashCommandToken, parseSlashInput, slashQuery } from '@/lib/slash-command';
@@ -560,6 +561,9 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
))}
</div>
)}
{skills.length > 0 && (
<AgentCommandsHint commands={skills.map((s) => ({ name: s.name, description: s.description }))} />
)}
{/* Batch 9 toolbar — agent picker + quick-toggle menu. v1.11.5.1
inlines ContextBar in the same row so the bar lives next to the
picker rather than as a separate header above it. The row renders

View File

@@ -181,7 +181,7 @@ export function ChatTabBar({
<Plus size={12} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-40">
<DropdownMenuContent align="end" className="w-fit">
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
<MessageSquare size={14} /> New BooChat
</DropdownMenuItem>

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import type { ReactNode } from 'react';
import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2, PanelRightOpen } from 'lucide-react';
import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2, PanelRightOpen, Brain } from 'lucide-react';
import { toast } from 'sonner';
import type { Chat, ErrorReason, Message } from '@/api/types';
import { api, ApiError } from '@/api/client';
@@ -117,12 +117,20 @@ function deriveMarkdownTitle(content: string): string {
return 'Markdown artifact';
}
export interface MessageActions {
onRegenerate?: (chatId: string, messageId: string) => Promise<void>;
onResend?: (chatId: string, content: string) => Promise<void>;
onFork?: (chatId: string, messageId: string) => Promise<void>;
onDelete?: (chatId: string, messageId: string) => Promise<void>;
}
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 };
actions?: MessageActions;
/** Hide actions that don't apply (fork, delete, open-in-pane). */
hideActions?: ('fork' | 'delete' | 'openInPane')[];
}
function StatsLine({ message }: { message: Message }) {
@@ -157,8 +165,12 @@ function StatsLine({ message }: { message: Message }) {
function ActionRow({
message,
actions,
hiddenSet,
}: {
message: Message;
actions?: MessageActions;
hiddenSet: Set<string>;
}) {
const [justCopied, setJustCopied] = useState(false);
const [regenerating, setRegenerating] = useState(false);
@@ -180,7 +192,11 @@ function ActionRow({
if (regenerating || message.status === 'streaming') return;
setRegenerating(true);
try {
await api.messages.regenerate(message.chat_id, message.id);
if (actions?.onRegenerate) {
await actions.onRegenerate(message.chat_id, message.id);
} else {
await api.messages.regenerate(message.chat_id, message.id);
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'regenerate failed');
} finally {
@@ -188,12 +204,30 @@ function ActionRow({
}
}
async function resend() {
if (!canResend) return;
try {
if (actions?.onResend) {
await actions.onResend(message.chat_id, message.content!);
} else {
await api.messages.send(message.chat_id, message.content!);
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'resend failed');
}
}
async function fork() {
if (forking || message.status !== 'complete') return;
setForking(true);
try {
const chat = await api.chats.fork(message.chat_id, { messageId: message.id });
sessionEvents.emit({ type: 'open_chat_in_active_pane', chat_id: chat.id });
if (actions?.onFork) {
await actions.onFork(message.chat_id, message.id);
} else {
const chat = await api.chats.fork(message.chat_id, { messageId: message.id });
sessionEvents.emit({ type: 'refetch_messages' });
sessionEvents.emit({ type: 'open_chat_in_active_pane', chat_id: chat.id });
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'fork failed');
} finally {
@@ -205,7 +239,11 @@ function ActionRow({
if (deleting) return;
setDeleting(true);
try {
await api.messages.remove(message.chat_id, message.id);
if (actions?.onDelete) {
await actions.onDelete(message.chat_id, message.id);
} else {
await api.messages.remove(message.chat_id, message.id);
}
setDeleteOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'delete failed');
@@ -215,7 +253,9 @@ function ActionRow({
}
const isAssistant = message.role === 'assistant';
const isUser = message.role === 'user';
const canRegen = isAssistant && message.status !== 'streaming';
const canResend = isUser && message.status === 'complete' && !!message.content?.trim();
const canFork = message.status === 'complete';
const canDelete = message.status !== 'streaming';
const [openingPane, setOpeningPane] = useState(false);
@@ -279,7 +319,18 @@ function ActionRow({
>
{justCopied ? <Check className="size-3" /> : <Copy className="size-3" />}
</button>
{isAssistant && (
{canResend && (
<button
type="button"
onClick={() => void resend()}
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Resend message"
title="Resend"
>
<RefreshCw className="size-3" />
</button>
)}
{isAssistant && !hiddenSet.has('openInPane') && (
<button
type="button"
onClick={() => void openInPane()}
@@ -303,26 +354,30 @@ function ActionRow({
<RefreshCw className={`size-3 ${regenerating ? 'animate-spin' : ''}`} />
</button>
)}
<button
type="button"
onClick={() => void fork()}
disabled={!canFork || forking}
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Fork from here"
title="Fork from here"
>
<GitFork className="size-3" />
</button>
<button
type="button"
onClick={() => setDeleteOpen(true)}
disabled={!canDelete}
{!hiddenSet.has('fork') && (
<button
type="button"
onClick={() => void fork()}
disabled={!canFork || forking}
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Fork from here"
title="Fork from here"
>
<GitFork className="size-3" />
</button>
)}
{!hiddenSet.has('delete') && (
<button
type="button"
onClick={() => setDeleteOpen(true)}
disabled={!canDelete}
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-destructive disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Delete message"
title="Delete message"
>
<Trash2 className="size-3" />
</button>
)}
</div>
<Dialog
open={deleteOpen}
@@ -536,7 +591,39 @@ function SummaryCard({ message }: { message: Message }) {
);
}
export function MessageBubble({ message, sessionChats, capHitInfo }: Props) {
// Collapsible "Thinking" block for assistant reasoning. Fed by either
// reasoning_text (coder wire / live reasoning_delta stream) or reasoning_parts
// (native inference, persisted from message_parts). Auto-expands while the turn
// is still streaming so the user watches it think (Paseo-style), then stays
// where the user left it once the turn completes — initial state is captured
// once at mount, so we never fight a manual collapse on later re-renders.
function ReasoningBlock({ text, streaming }: { text: string; streaming: boolean }) {
const [expanded, setExpanded] = useState(() => streaming);
return (
<div className="max-w-[90%] rounded-lg border bg-muted/30 text-sm">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="flex items-center gap-1.5 w-full px-3 py-1.5 text-left text-muted-foreground hover:text-foreground"
>
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<Brain size={13} />
<span className="text-xs font-medium">Thinking</span>
{streaming && (
<span className="ml-1 inline-block w-1.5 h-3 align-baseline bg-muted-foreground/60 animate-pulse" />
)}
</button>
{expanded && (
<div className="px-3 pb-2.5 pt-0.5 text-xs leading-relaxed text-muted-foreground whitespace-pre-wrap break-words border-t">
{text}
</div>
)}
</div>
);
}
export function MessageBubble({ message, sessionChats, capHitInfo, actions, hideActions }: Props) {
const hiddenSet = new Set(hideActions ?? []);
// 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).
@@ -585,7 +672,7 @@ export function MessageBubble({ message, sessionChats, capHitInfo }: Props) {
{message.content}
</div>
</SendToTerminalMenu>
<ActionRow message={message} />
<ActionRow message={message} actions={actions} hiddenSet={hiddenSet} />
</div>
);
}
@@ -595,16 +682,26 @@ export function MessageBubble({ message, sessionChats, capHitInfo }: Props) {
// v1.13.7: match the MessageList.flatten trim guard so a whitespace-only
// assistant turn doesn't render an empty bubble + dangling ActionRow.
const hasContent = message.content.trim().length > 0;
// Reasoning arrives as a pre-joined string (coder wire) or as parts (native
// inference). Read whichever is present; loose ?? chain tolerates the coder
// shape where reasoning_parts is undefined (see CLAUDE.md null-guard note).
const reasoningText = (
message.reasoning_text ??
message.reasoning_parts?.map((p) => p.text ?? '').join('') ??
''
).trim();
const hasReasoning = reasoningText.length > 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 && message.metadata.kind === 'error'
? message.metadata
: null;
return (
<div className="group flex flex-col gap-2">
{hasReasoning && <ReasoningBlock text={reasoningText} streaming={isStreaming} />}
{(hasContent || isStreaming) && (
<SendToTerminalMenu>
<div className="max-w-[90%] text-sm leading-relaxed space-y-2 break-words min-w-0">
@@ -627,7 +724,7 @@ export function MessageBubble({ message, sessionChats, capHitInfo }: Props) {
</div>
)}
{!isStreaming && <StatsLine message={message} />}
{!isStreaming && hasContent && <ActionRow message={message} />}
{!isStreaming && hasContent && <ActionRow message={message} actions={actions} hiddenSet={hiddenSet} />}
</div>
);
}

View File

@@ -273,7 +273,7 @@ export function MobileTabSwitcher({
<MoreHorizontal size={14} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuContent align="end" className="min-w-44">
{chat && (
<DropdownMenuItem onSelect={() => startRename(chat.id, chat.name)}>
<Edit2 size={14} /> Rename chat

View File

@@ -27,7 +27,7 @@ export function NewPaneMenu({ onAddPane, disabled }: Props) {
<Plus size={16} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuContent align="end" className="w-fit">
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
<MessageSquare size={14} /> New BooChat
</DropdownMenuItem>

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ChevronRight, ChevronDown, FileText, Folder, PanelRightClose, PanelRightOpen } from 'lucide-react';
import { api } from '@/api/client';
import { ChevronRight, ChevronDown, FilePlus, FileText, Folder, PanelRightClose, PanelRightOpen } from 'lucide-react';
import { api, ApiError } from '@/api/client';
import type { FileEntry } from '@/api/types';
import { inferLanguage } from '@/lib/attachments';
import { sessionEvents } from '@/hooks/sessionEvents';
@@ -8,10 +8,22 @@ import { useRightRailDrawer } from '@/hooks/useRightRailDrawer';
import { useViewport } from '@/hooks/useViewport';
import { FileViewerOverlay } from '@/components/FileViewerOverlay';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { cn } from '@/lib/utils';
interface Props {
projectId: string;
sessionId: string;
}
const STORAGE_KEY = 'boocode.rightrail';
@@ -27,7 +39,7 @@ function joinPath(parent: string, name: string): string {
return `${parent}/${name}`;
}
export function RightRail({ projectId }: Props) {
export function RightRail({ projectId, sessionId }: Props) {
const { isMobile } = useViewport();
const { open: drawerOpen, setOpen: setDrawerOpen } = useRightRailDrawer();
const [open, setOpen] = useState(() => {
@@ -39,6 +51,39 @@ export function RightRail({ projectId }: Props) {
const [fullFileList, setFullFileList] = useState<string[] | null>(null);
const [viewerFile, setViewerFile] = useState<{ path: string; content: string } | null>(null);
// New-file-from-pasted-text modal. Queues a pending_changes create via
// BooCoder; it then shows in the CoderPane DiffPanel for explicit apply.
const [newFileOpen, setNewFileOpen] = useState(false);
const [newFilePath, setNewFilePath] = useState('');
const [newFileContent, setNewFileContent] = useState('');
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const openNewFile = useCallback(() => {
setNewFilePath('');
setNewFileContent('');
setCreateError(null);
setNewFileOpen(true);
}, []);
const submitNewFile = useCallback(async () => {
const path = newFilePath.trim();
if (!path || creating) return;
setCreating(true);
setCreateError(null);
try {
await api.coder.createPendingFile(sessionId, path, newFileContent);
setNewFileOpen(false);
setNewFilePath('');
setNewFileContent('');
} catch (err) {
// 422 WriteGuardError surfaces via ApiError.message (the route's { error }).
setCreateError(err instanceof ApiError ? err.message : err instanceof Error ? err.message : 'Failed to create file');
} finally {
setCreating(false);
}
}, [sessionId, newFilePath, newFileContent, creating]);
// Combined open state: on mobile use the global drawer state (toggled by
// the Session header's FolderTree button); on desktop use the persistent
// internal state.
@@ -163,6 +208,15 @@ export function RightRail({ projectId }: Props) {
<aside className={asideCls}>
<div className="flex items-center gap-2 px-3 py-2 border-b shrink-0">
<span className="text-xs font-medium flex-1">Files</span>
<button
type="button"
onClick={openNewFile}
className="p-1 rounded hover:bg-muted text-muted-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="New file from pasted text"
title="New file"
>
<FilePlus size={14} />
</button>
<button
type="button"
onClick={closeRail}
@@ -225,6 +279,48 @@ export function RightRail({ projectId }: Props) {
onNavigate={(path) => void openFile(path)}
/>
)}
<Dialog open={newFileOpen} onOpenChange={setNewFileOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>New file from pasted text</DialogTitle>
<DialogDescription>
Queues a new file as a pending change. Review and apply it from the Coder pane.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="new-file-path" className="text-xs">Path (relative to project root)</Label>
<Input
id="new-file-path"
value={newFilePath}
onChange={(e) => setNewFilePath(e.target.value)}
placeholder="src/example.ts"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="new-file-content" className="text-xs">Content</Label>
<Textarea
id="new-file-content"
value={newFileContent}
onChange={(e) => setNewFileContent(e.target.value)}
placeholder="Paste file contents here…"
autoFocus
className="min-h-[180px] font-mono text-xs"
/>
</div>
{createError && <p className="text-xs text-destructive">{createError}</p>}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setNewFileOpen(false)} disabled={creating}>
Cancel
</Button>
<Button onClick={() => void submitNewFile()} disabled={creating || !newFilePath.trim()}>
{creating ? 'Creating…' : 'Create'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -1,185 +1,27 @@
import { useCallback, useEffect, useState } from 'react';
import { Archive, MessageSquare, Send, ChevronDown, ChevronRight, RotateCcw, Trash2 } from 'lucide-react';
import { useCallback, useState } from 'react';
import { toast } from 'sonner';
import type { Chat } from '@/api/types';
import { api } from '@/api/client';
import { Button } from '@/components/ui/button';
import { ChatInput } from '@/components/ChatInput';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { formatTokens } from '@/lib/format';
interface Props {
sessionId: string;
projectId: string;
chats: Chat[];
onOpenChat: (chatId: string) => void;
sessionId: string;
agentId?: string | null;
onAgentChange?: (agentId: string | null) => void | Promise<void>;
onSend: (content: string) => void;
/** Create a chat and return its id. Used by slash-command handler. */
createChat: () => Promise<{ id: string }>;
onReopenChat: (chatId: string) => Promise<void>;
onArchiveChat: (chatId: string) => Promise<void>;
onRenameChat: (chatId: string, name: string) => Promise<void>;
onDeleteChat: (chatId: string) => Promise<void>;
}
function relTime(iso: string): string {
const now = Date.now();
const t = Date.parse(iso);
if (Number.isNaN(t)) return '';
const sec = Math.max(0, Math.floor((now - t) / 1000));
if (sec < 60) return `${sec}s ago`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
const day = Math.floor(hr / 24);
return `${day}d ago`;
}
interface ChatRowProps {
chat: Chat;
onClick: () => void;
dimmed?: boolean;
trailing?: React.ReactNode;
actions?: React.ReactNode;
renamingId: string | null;
renameValue: string;
setRenameValue: (s: string) => void;
onFinishRename: () => void;
onCancelRename: () => void;
onContextStartRename: () => void;
onContextArchive: () => void;
onContextDelete: () => void;
showContextMenu: boolean;
}
function ChatRow({
chat,
onClick,
dimmed,
trailing,
actions,
renamingId,
renameValue,
setRenameValue,
onFinishRename,
onCancelRename,
onContextStartRename,
onContextArchive,
onContextDelete,
showContextMenu,
}: ChatRowProps) {
const meta: string[] = [relTime(chat.updated_at)];
if (chat.message_count !== undefined && chat.message_count > 0) {
meta.push(`${chat.message_count} msg`);
}
const tokens = formatTokens(chat.effective_context_tokens);
if (tokens) meta.push(tokens);
const preview = chat.last_message_preview;
const isRenaming = renamingId === chat.id;
const inner = (
<button
type="button"
onClick={onClick}
className="w-full flex flex-col gap-0.5 px-3 py-2 hover:bg-muted/50 text-left"
>
<div className="flex items-center gap-2 min-w-0">
<MessageSquare className={`size-3.5 shrink-0 ${dimmed ? 'opacity-40' : 'opacity-70'}`} />
{isRenaming ? (
<input
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={() => onFinishRename()}
onKeyDown={(e) => {
if (e.key === 'Enter') onFinishRename();
if (e.key === 'Escape') onCancelRename();
}}
onClick={(e) => e.stopPropagation()}
className="bg-transparent border-b border-border text-sm outline-none flex-1 min-w-0"
/>
) : (
<span className={`truncate text-sm flex-1 ${dimmed ? 'text-muted-foreground' : ''}`}>
{chat.name ?? 'New chat'}
</span>
)}
{trailing && (
<span className="text-xs text-muted-foreground shrink-0">{trailing}</span>
)}
{actions && (
<div className="flex items-center gap-0.5 shrink-0">{actions}</div>
)}
</div>
<div className="ml-5 text-xs text-muted-foreground tabular-nums">
{meta.join(' · ')}
</div>
{preview && (
<div className="ml-5 text-xs italic text-muted-foreground truncate">
{preview}
</div>
)}
</button>
);
if (!showContextMenu) return inner;
return (
<ContextMenu>
<ContextMenuTrigger asChild>{inner}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={onClick}>Open</ContextMenuItem>
<ContextMenuItem onSelect={onContextStartRename}>Rename</ContextMenuItem>
<ContextMenuItem onSelect={onContextArchive}>Archive</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem variant="destructive" onSelect={onContextDelete}>
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
export function SessionLandingPage({
chats,
onOpenChat,
onSend,
projectId,
sessionId,
agentId,
onAgentChange,
onSend,
createChat,
onReopenChat,
onArchiveChat,
onRenameChat,
onDeleteChat,
}: Props) {
const [composerValue, setComposerValue] = useState('');
const [chatId, setChatId] = useState<string | null>(null);
const [showArchived, setShowArchived] = useState(false);
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
const [archiveConfirm, setArchiveConfirm] = useState<Chat | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<Chat | null>(null);
const openChats = chats
.filter((c) => c.status === 'open')
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
const archivedChats = chats
.filter((c) => c.status === 'archived')
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
// Create a chat lazily on first send or slash command.
const ensureChat = useCallback(async (): Promise<string> => {
if (chatId) return chatId;
try {
@@ -192,207 +34,46 @@ export function SessionLandingPage({
}
}, [chatId, createChat]);
async function handleSend() {
const text = composerValue.trim();
const handleSend = useCallback(async (content: string) => {
const text = content.trim();
if (!text) return;
try {
const cid = await ensureChat();
await ensureChat();
onSend(text);
setComposerValue('');
} catch {
// Error already surfaced via toast.
}
}
}, [ensureChat, onSend]);
// v2.3: slash-command dispatch on landing page. Creates a chat first if
// one doesn't exist, then invokes the skill on that chat.
const handleSlashCommand = useCallback(async (skillName: string, userMessage: string) => {
try {
const cid = await ensureChat();
await api.chats.skillInvoke(cid, skillName, userMessage.length > 0 ? userMessage : null);
setComposerValue('');
} catch (err) {
toast.error(err instanceof Error ? err.message : `/${skillName} failed`);
}
}, [ensureChat]);
function startRename(chat: Chat) {
setRenamingId(chat.id);
setRenameValue(chat.name ?? '');
}
async function finishRename() {
if (renamingId && renameValue.trim()) {
await onRenameChat(renamingId, renameValue.trim());
}
setRenamingId(null);
}
// 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 (
<div className="flex flex-col h-full min-h-0">
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
{openChats.length > 0 && (
<div>
<h3 className="text-xs font-medium text-muted-foreground mb-2">Open chats</h3>
<ul className="divide-y rounded-md border">
{openChats.map((chat) => (
<li key={chat.id}>
<ChatRow
chat={chat}
onClick={() => onOpenChat(chat.id)}
renamingId={renamingId}
renameValue={renameValue}
setRenameValue={setRenameValue}
onFinishRename={() => void finishRename()}
onCancelRename={() => setRenamingId(null)}
onContextStartRename={() => startRename(chat)}
onContextArchive={() => setArchiveConfirm(chat)}
onContextDelete={() => setDeleteConfirm(chat)}
showContextMenu
actions={
<>
<Button
variant="ghost"
size="icon-sm"
aria-label="Archive chat"
title="Archive chat"
onClick={(e) => {
e.stopPropagation();
setArchiveConfirm(chat);
}}
>
<Archive size={14} />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label="Delete chat"
title="Delete chat"
className="text-destructive hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
setDeleteConfirm(chat);
}}
>
<Trash2 size={14} />
</Button>
</>
}
/>
</li>
))}
</ul>
</div>
)}
{archivedChats.length > 0 && (
<div>
<button
type="button"
onClick={() => setShowArchived(!showArchived)}
className="flex items-center gap-1 text-xs font-medium text-muted-foreground mb-2 hover:text-foreground"
>
{showArchived ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
Archived chats ({archivedChats.length})
</button>
{showArchived && (
<ul className="divide-y rounded-md border">
{archivedChats.map((chat) => (
<li key={chat.id}>
<ChatRow
chat={chat}
onClick={() => void onReopenChat(chat.id)}
dimmed
trailing={<><RotateCcw size={10} className="inline mr-1" />Restore</>}
renamingId={null}
renameValue=""
setRenameValue={() => {}}
onFinishRename={() => {}}
onCancelRename={() => {}}
onContextStartRename={() => {}}
onContextArchive={() => {}}
onContextDelete={() => {}}
showContextMenu={false}
/>
</li>
))}
</ul>
)}
</div>
)}
{openChats.length === 0 && archivedChats.length === 0 && (
<div className="text-sm text-muted-foreground py-8 text-center">
No chats yet. Type below to start a conversation.
</div>
)}
<div className="flex-1 flex items-center justify-center px-6">
<p className="text-sm text-muted-foreground">
Send a message to start.
</p>
</div>
{/* v2.3: ChatInput with slash-command support replaces the bare Textarea.
chatId is created lazily on first send/slash. */}
<div className="border-t px-4 py-3 shrink-0">
<ChatInput
projectId={projectId}
onSend={handleSend}
onSlashCommand={handleSlashCommand}
chatId={chatId ?? undefined}
chatLabel={chatId ? undefined : 'Chat'}
disabled={false}
/>
</div>
<Dialog open={archiveConfirm !== null} onOpenChange={(open) => { if (!open) setArchiveConfirm(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Archive chat?</DialogTitle>
<DialogDescription>
Moves {archiveConfirm ? `"${archiveConfirm.name ?? 'New chat'}"` : 'this chat'} to the Archived chats section. You can restore it any time.
</DialogDescription>
</DialogHeader>
<div className="flex gap-2 justify-end pt-2">
<Button variant="outline" onClick={() => setArchiveConfirm(null)}>
Cancel
</Button>
<Button
onClick={() => {
if (archiveConfirm) void onArchiveChat(archiveConfirm.id);
setArchiveConfirm(null);
}}
>
Archive
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={deleteConfirm !== null} onOpenChange={(open) => { if (!open) setDeleteConfirm(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete chat?</DialogTitle>
<DialogDescription>
Permanently delete{' '}
<span className="font-mono font-medium text-foreground">{deleteConfirm?.name || '(unnamed)'}</span>
{' '}and all its messages. This cannot be undone.
</DialogDescription>
</DialogHeader>
<div className="flex gap-2 justify-end pt-2">
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
if (deleteConfirm) void onDeleteChat(deleteConfirm.id);
setDeleteConfirm(null);
}}
>
Delete
</Button>
</div>
</DialogContent>
</Dialog>
<ChatInput
disabled={false}
projectId={projectId}
sessionId={sessionId}
agentId={agentId ?? null}
onAgentChange={onAgentChange}
onSend={handleSend}
onSlashCommand={handleSlashCommand}
chatId={chatId ?? undefined}
chatLabel="Chat"
messages={[]}
modelContextLimit={null}
/>
</div>
);
}

View File

@@ -37,6 +37,7 @@ interface Props {
project: Project | null;
/** New BooCode opens a fresh coder session; chat/terminal split in-place. */
onAddPane: (kind: 'chat' | 'terminal' | 'coder') => void;
onCoderConnectedChange?: (paneId: string, connected: boolean) => void;
}
export function Workspace({
@@ -48,6 +49,7 @@ export function Workspace({
chatsHook,
session,
project,
onCoderConnectedChange,
onAddPane,
}: Props) {
const {
@@ -141,6 +143,7 @@ export function Workspace({
// Per-coder-pane WS connection (status dot lives in the pane header).
const [coderConnected, setCoderConnected] = useState<Record<string, boolean>>({});
const [coderLabels, setCoderLabels] = useState<Record<string, string>>({});
return (
<div className="flex flex-col h-full min-h-0">
@@ -212,24 +215,23 @@ export function Workspace({
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
/>
)}
{isCoder && (
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-2 py-1 shrink-0">
{isCoder && !isMobile && (
<div className="flex items-center gap-1 border-b border-border px-2 py-1 shrink-0">
<Code size={12} className="text-muted-foreground" />
<span className="text-xs text-muted-foreground">BooCode</span>
<div className="ml-auto flex items-center gap-1.5">
<div className="ml-auto flex items-center gap-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:size-7"
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="New pane"
title="New pane"
>
<Plus size={12} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-40">
<DropdownMenuContent align="end" className="w-fit">
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
<MessageSquare size={14} /> New BooChat
</DropdownMenuItem>
@@ -241,23 +243,12 @@ export function Workspace({
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<span
className={cn(
'inline-block w-1.5 h-1.5 rounded-full shrink-0',
coderConnected[pane.id] ? 'bg-green-500' : 'bg-red-500',
)}
title={coderConnected[pane.id] ? 'Connected' : 'Disconnected'}
/>
{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 BooCode pane"
title="Close BooCode pane"
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"
aria-label="Close pane"
>
<X size={12} />
</button>
@@ -283,7 +274,7 @@ export function Workspace({
<Plus size={12} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-40">
<DropdownMenuContent align="end" className="w-fit">
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
<MessageSquare size={14} /> New BooChat
</DropdownMenuItem>
@@ -354,9 +345,15 @@ export function Workspace({
chatId={activePaneChatId(pane)}
chatPending={isPaneChatPending(pane.id)}
projectPath={project?.path}
onConnectedChange={(connected) =>
onConnectedChange={(connected) => {
setCoderConnected((prev) =>
prev[pane.id] === connected ? prev : { ...prev, [pane.id]: connected },
);
onCoderConnectedChange?.(pane.id, connected);
}}
onAgentLabelChange={(label) =>
setCoderLabels((prev) =>
prev[pane.id] === label ? prev : { ...prev, [pane.id]: label },
)
}
/>
@@ -384,19 +381,12 @@ export function Workspace({
/>
) : (
<SessionLandingPage
sessionId={sessionId}
projectId={projectId}
chats={chats}
sessionId={sessionId}
agentId={agentId}
onAgentChange={onAgentChange}
createChat={() => api.chats.create(sessionId)}
onOpenChat={(chatId) => openChatInPane(idx, chatId)}
onSend={(content) => void handleLandingSend(idx, content)}
onReopenChat={async (chatId) => {
await unarchiveChat(chatId);
openChatInPane(idx, chatId);
}}
onArchiveChat={archiveChat}
onRenameChat={renameChat}
onDeleteChat={deleteChat}
/>
)}
</div>

View File

@@ -0,0 +1,21 @@
interface IconProps {
size?: number;
className?: string;
}
export function ClaudeIcon({ size = 14, className }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" fillRule="evenodd" className={className}>
<path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" />
</svg>
);
}
export function OpenCodeIcon({ size = 14, className }: IconProps) {
return (
<svg width={size} height={size} viewBox="96 64 288 384" fill="currentColor" className={className}>
<path d="M320 224V352H192V224H320Z" opacity={0.4} />
<path fillRule="evenodd" clipRule="evenodd" d="M384 416H128V96H384V416ZM320 160H192V352H320V160Z" />
</svg>
);
}

View File

@@ -1,9 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, type ReactNode } from 'react';
import { MarkdownRenderer } from '@/components/MarkdownRenderer';
import { MessageBubble, type MessageActions } from '@/components/MessageBubble';
import { ToolCallGroup } from '@/components/ToolCallGroup';
import { ToolCallLine, type ToolRun } from '@/components/ToolCallLine';
import { AskUserInputCard } from '@/components/AskUserInputCard';
import { wireToolCallToRun, type CoderToolCallWire } from '@/lib/coder-tools';
import type { Message } from '@/api/types';
export interface CoderMessageWire {
id: string;
@@ -141,54 +142,16 @@ function groupToolRuns(items: RenderItem[]): RenderItem[] {
return out;
}
function CoderTextBubble({ message }: { message: CoderMessageWire }) {
const isUser = message.role === 'user';
const isStreaming = message.status === 'streaming';
const hasText = message.content.trim().length > 0;
const hasReasoning = (message.reasoning_text?.trim().length ?? 0) > 0;
if (isUser) {
return (
<div className="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>
</div>
);
}
return (
<div className="flex flex-col gap-2">
{hasReasoning && (
<details className="rounded border border-border/40 bg-muted/20 px-2 py-1">
<summary className="cursor-pointer text-xs text-muted-foreground select-none">Reasoning</summary>
<pre className="mt-1 max-h-48 overflow-y-auto whitespace-pre-wrap text-[11px] text-muted-foreground font-mono">
{message.reasoning_text}
</pre>
</details>
)}
{(hasText || (isStreaming && !hasReasoning)) && (
<div className="max-w-[90%] text-sm leading-relaxed space-y-2 break-words min-w-0">
{hasText ? <MarkdownRenderer content={message.content} /> : null}
{isStreaming && (
<span className="inline-block w-1.5 h-3.5 align-baseline bg-muted-foreground/60 animate-pulse" />
)}
</div>
)}
{message.status === 'failed' && (
<div className="text-xs text-destructive">message failed</div>
)}
</div>
);
}
interface Props {
messages: CoderTimelineWire[];
chatId?: string;
footer?: ReactNode;
actions?: MessageActions;
}
export function CoderMessageList({ messages, chatId, footer }: Props) {
const CODER_HIDDEN_ACTIONS: ('fork' | 'delete' | 'openInPane')[] = ['fork', 'openInPane'];
export function CoderMessageList({ messages, chatId, footer, actions }: Props) {
const endRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const isNearBottomRef = useRef(true);
@@ -220,7 +183,14 @@ export function CoderMessageList({ messages, chatId, footer }: Props) {
<div className="max-w-[1000px] mx-auto w-full px-6 py-4 space-y-4">
{renderItems.map((item) => {
if (item.kind === 'message') {
return <CoderTextBubble key={item.message.id} message={item.message} />;
return (
<MessageBubble
key={item.message.id}
message={item.message as unknown as Message}
actions={actions}
hideActions={CODER_HIDDEN_ACTIONS}
/>
);
}
if (item.kind === 'tool_run') {
if (item.run.call.name === 'ask_user_input' && chatId) {

View File

@@ -4,11 +4,10 @@
// WS: /api/coder/ws/sessions/:id (Vite dev proxies to :9502).
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Code, Send, Check, X, RefreshCw } from 'lucide-react';
import { Code, Check, X, RefreshCw } from 'lucide-react';
import { AgentComposerBar } from '@/components/AgentComposerBar';
import { PermissionCard } from '@/components/PermissionCard';
import { AgentCommandsHint } from '@/components/AgentCommandsHint';
import { SlashCommandPicker } from '@/components/SlashCommandPicker';
import { ChatInput } from '@/components/ChatInput';
import { api } from '@/api/client';
import type { AgentSessionConfig, PermissionPrompt, AgentCommand } from '@/api/types';
import { useSkills } from '@/hooks/useSkills';
@@ -32,6 +31,8 @@ interface CoderMessage {
id: string;
function: { name: string; arguments: string };
}>;
ctx_used?: number | null;
ctx_max?: number | null;
}
interface CoderToolMessage {
@@ -63,6 +64,7 @@ interface Props {
chatPending?: boolean;
projectPath?: string;
onConnectedChange?: (connected: boolean) => void;
onAgentLabelChange?: (label: string) => void;
}
interface WsHandlers {
@@ -91,6 +93,8 @@ type RawCoderMessage = {
| { id: string; name: string; args?: Record<string, unknown> }
| { id: string; function: { name: string; arguments: string } }
> | null;
ctx_used?: number | null;
ctx_max?: number | null;
};
function mapCoderTimelineRow(raw: RawCoderMessage): CoderTimelineMessage | null {
@@ -126,6 +130,8 @@ function mapCoderTimelineRow(raw: RawCoderMessage): CoderTimelineMessage | null
status: (raw.status ?? 'complete') as CoderMessage['status'],
...(reasoning_text ? { reasoning_text } : {}),
...(tool_calls?.length ? { tool_calls } : {}),
ctx_used: raw.ctx_used ?? null,
ctx_max: raw.ctx_max ?? null,
};
}
@@ -228,7 +234,12 @@ function useCoderMessages(sessionId: string, chatId: string | undefined, handler
);
const next = prev.map((m) =>
m.id === frame.message_id && m.role !== 'tool'
? { ...m, status: 'complete' as const }
? {
...m,
status: 'complete' as const,
ctx_used: (frame as any).ctx_used ?? (m as any).ctx_used ?? null,
ctx_max: (frame as any).ctx_max ?? (m as any).ctx_max ?? null,
}
: m,
);
if (completed) {
@@ -343,7 +354,7 @@ function usePendingChanges(sessionId: string) {
useEffect(() => { refresh(); }, [refresh]);
const approve = useCallback(async (changeId: string) => {
const res = await fetch(`/api/coder/sessions/${sessionId}/pending/${changeId}/approve`, {
const res = await fetch(`/api/coder/pending/${changeId}/apply`, {
method: 'POST',
});
if (res.ok) {
@@ -352,7 +363,7 @@ function usePendingChanges(sessionId: string) {
}, [sessionId]);
const reject = useCallback(async (changeId: string) => {
const res = await fetch(`/api/coder/sessions/${sessionId}/pending/${changeId}/reject`, {
const res = await fetch(`/api/coder/pending/${changeId}/reject`, {
method: 'POST',
});
if (res.ok) {
@@ -463,6 +474,7 @@ export function CoderPane({
chatPending = false,
projectPath,
onConnectedChange,
onAgentLabelChange,
}: Props) {
const [agentConfig, setAgentConfig] = useState<AgentSessionConfig>({
provider: 'boocode',
@@ -470,6 +482,12 @@ export function CoderPane({
modeId: null,
thinkingOptionId: null,
});
useEffect(() => {
const parts = [agentConfig.provider || 'boocode'];
if (agentConfig.model) parts.push(agentConfig.model);
onAgentLabelChange?.(parts.join(' · '));
}, [agentConfig.provider, agentConfig.model, onAgentLabelChange]);
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
const [permissionPrompt, setPermissionPrompt] = useState<PermissionPrompt | null>(null);
const [permissionBusy, setPermissionBusy] = useState(false);
@@ -515,6 +533,8 @@ export function CoderPane({
const { changes, loading, refresh, approve, reject } = usePendingChanges(sessionId);
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
const [queue, setQueue] = useState<string[]>([]);
const queueProcessing = useRef(false);
const inputRef = useRef<HTMLTextAreaElement>(null);
// Refresh pending changes when a message_complete arrives
@@ -658,43 +678,87 @@ export function CoderPane({
setMessages,
]);
const handleSlashSelect = useCallback((name: string) => {
const next = `/${name} `;
setInput(next);
setSlashState(null);
requestAnimationFrame(() => {
const ta = inputRef.current;
if (ta) {
ta.selectionStart = ta.selectionEnd = next.length;
ta.focus();
}
});
}, []);
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value;
setInput(newValue);
if (isSlashCommandToken(newValue)) {
setSlashState({ query: slashQuery(newValue) });
} else {
setSlashState(null);
const sendOneMessage = useCallback(async (text: string) => {
if (!chatId) return;
setSending(true);
setPermissionPrompt(null);
setLiveTaskCommands([]);
const tempId = `temp-${Date.now()}`;
setMessages((prev) => [...prev, { id: tempId, role: 'user', content: text, status: 'complete' }]);
try {
const data = await api.coder.sendMessage(sessionId, {
content: text,
pane_id: paneId,
chat_id: chatId,
provider: agentConfig.provider !== 'boocode' ? agentConfig.provider : undefined,
model: agentConfig.model || undefined,
mode_id: agentConfig.modeId ?? undefined,
thinking_option_id: agentConfig.thinkingOptionId ?? undefined,
});
if (data.user_message_id) {
setMessages((prev) =>
prev.map((m) => (m.id === tempId ? { ...m, id: data.user_message_id! } : m))
);
}
if (data.task_id) {
setActiveTaskId(data.task_id);
} else {
setActiveTaskId(null);
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'failed to send');
} finally {
setSending(false);
}
}, []);
}, [sessionId, paneId, chatId, agentConfig, setMessages]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (slashState) return;
if (e.nativeEvent.isComposing) return;
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
void handleSend();
// Drain queue when not busy
useEffect(() => {
if (sending || queue.length === 0 || queueProcessing.current) return;
queueProcessing.current = true;
const next = queue[0]!;
setQueue((prev) => prev.slice(1));
sendOneMessage(next).finally(() => { queueProcessing.current = false; });
}, [sending, queue, sendOneMessage]);
const handleChatInputSend = useCallback(async (content: string) => {
const text = content.trim();
if (!text || !chatId) return;
if (sending) {
setQueue((prev) => [...prev, text]);
return;
}
await sendOneMessage(text);
}, [sending, chatId, sendOneMessage]);
const handleChatInputSlash = useCallback(async (skillName: string, userMessage: string) => {
if (!chatId) return;
if (agentConfig.provider === 'boocode' && skillsByName.has(skillName)) {
setSending(true);
setPermissionPrompt(null);
setLiveTaskCommands([]);
try {
await api.coder.skillInvoke(sessionId, paneId, skillName, userMessage.length > 0 ? userMessage : null);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'skill invocation failed');
} finally {
setSending(false);
}
},
[handleSend, slashState]
);
}
}, [chatId, sessionId, paneId, agentConfig.provider, skillsByName]);
return (
<div className="flex flex-col h-full bg-background">
<AgentComposerBar
projectPath={projectPath}
value={agentConfig}
onChange={setAgentConfig}
onProviderCommandsChange={handleProviderCommandsChange}
connected={connected}
/>
{/* Chat area — BooChat-style timeline (text + tool runs as siblings) */}
<div className="flex-1 min-h-0 flex flex-col">
{messages.length === 0 ? (
@@ -706,6 +770,9 @@ export function CoderPane({
<CoderMessageList
messages={messages as CoderTimelineWire[]}
chatId={chatId}
actions={{
onResend: async (_chatId, content) => { await sendOneMessage(content); },
}}
footer={
activeTaskId && !permissionPrompt && sending === false ? (
<p className="text-xs text-muted-foreground animate-pulse">Agent running</p>
@@ -738,44 +805,16 @@ export function CoderPane({
{/* Composer + input */}
<div className="shrink-0 border-t border-border">
{displayedCommands.length > 0 && <AgentCommandsHint commands={displayedCommands} />}
<AgentComposerBar
projectPath={projectPath}
value={agentConfig}
onChange={setAgentConfig}
onProviderCommandsChange={handleProviderCommandsChange}
<ChatInput
disabled={sending || !chatId || chatPending}
projectId={projectPath ?? ''}
onSend={handleChatInputSend}
onSlashCommand={handleChatInputSlash}
chatId={chatId ?? undefined}
chatLabel="BooCode"
messages={messages as unknown as import('@/api/types').Message[]}
modelContextLimit={null}
/>
<div className="p-2">
<div className="flex items-end gap-2">
<textarea
ref={inputRef}
value={input}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder="Type / for commands…"
rows={1}
className="flex-1 resize-none rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring max-h-32 min-h-[36px] max-md:min-h-[44px]"
/>
<button
type="button"
onClick={() => void handleSend()}
disabled={!input.trim() || sending || !chatId || chatPending}
className="inline-flex items-center justify-center size-9 max-md:min-h-[44px] max-md:min-w-[44px] rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
aria-label="Send message"
>
<Send size={16} />
</button>
</div>
</div>
{slashState && (
<SlashCommandPicker
query={slashState.query}
items={displayedCommands}
inputRef={inputRef}
onSelect={handleSlashSelect}
onClose={() => setSlashState(null)}
/>
)}
</div>
</div>
);

View File

@@ -162,6 +162,10 @@ export interface ChatStatusEvent {
reason?: ErrorReason;
}
export interface RefetchMessagesEvent {
type: 'refetch_messages';
}
export type SessionEvent =
| SessionRenamedEvent
| ProjectCreatedEvent
@@ -186,7 +190,8 @@ export type SessionEvent =
| ProjectArchivedEvent
| ProjectUnarchivedEvent
| ProjectUpdatedEvent
| ChatStatusEvent;
| ChatStatusEvent
| RefetchMessagesEvent;
type Listener = (event: SessionEvent) => void;
const listeners = new Set<Listener>();

View File

@@ -294,5 +294,21 @@ export function useSessionStream(sessionId: string | undefined) {
};
}, [sessionId]);
useEffect(() => {
if (!sessionId) return;
return sessionEvents.subscribe((event) => {
if (event.type === 'refetch_messages') {
void api.messages
.list(sessionId)
.then((messages) => {
setState((s) => applyFrame(s, { type: 'snapshot', messages }));
})
.catch((err: unknown) => {
console.warn('refetch_messages failed', err);
});
}
});
}, [sessionId]);
return state;
}

View File

@@ -184,6 +184,7 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
case 'chat_unarchived':
case 'chat_deleted':
case 'chat_status':
case 'refetch_messages':
return prev;
case 'project_archived': {
const next = prev.projects.filter((p) => p.id !== event.project_id);

View File

@@ -1,6 +1,7 @@
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import type { ReactNode } from 'react';
import { useLocation } from 'react-router-dom';
import { useViewport } from './useViewport';
interface SidebarDrawerState {
open: boolean;
@@ -13,13 +14,17 @@ const Ctx = createContext<SidebarDrawerState | null>(null);
export function SidebarDrawerProvider({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false);
const location = useLocation();
const { isMobile } = useViewport();
// Auto-close on navigation. Effect fires once on mount too (open default
// is false, so no observable effect) and on every pathname change after.
useEffect(() => {
setOpen(false);
}, [location.pathname]);
// Close drawer on orientation change (landscape→portrait transition).
useEffect(() => {
setOpen(false);
}, [isMobile]);
const toggle = useCallback(() => setOpen((v) => !v), []);
return <Ctx.Provider value={{ open, setOpen, toggle }}>{children}</Ctx.Provider>;

View File

@@ -31,13 +31,48 @@ export function useViewport(): ViewportSnapshot {
if (typeof window === 'undefined') return;
const mobileMq = window.matchMedia(`(max-width: ${MOBILE_MAX}px)`);
const tabletMq = window.matchMedia(`(min-width: ${MOBILE_MAX + 1}px) and (max-width: ${TABLET_MAX}px)`);
const update = () => setState(snapshot());
const update = () =>
setState((prev) => {
const next = snapshot();
// Bail if nothing changed — visualViewport 'resize' fires on every
// URL-bar show/hide and scroll, and a fresh object would re-render
// every consumer needlessly.
if (
prev.isMobile === next.isMobile &&
prev.isTablet === next.isTablet &&
prev.width === next.width
) {
return prev;
}
return next;
});
// matchMedia 'change' alone is not enough on iOS Safari/Vivaldi: when a
// backgrounded tab is restored (bfcache) or refocused, no 'change' fires,
// and the width captured at first paint can be a stale/oversized value
// (iOS reports the wrong innerWidth for a beat before layout settles). That
// leaves isMobile=false on a phone, so the sidebar renders as a permanent
// desktop column with no way to close it. Re-snapshot on every signal that
// accompanies a rejoin/viewport correction, not just breakpoint crossings.
const onVisibility = () => {
if (document.visibilityState === 'visible') update();
};
mobileMq.addEventListener('change', update);
tabletMq.addEventListener('change', update);
window.addEventListener('resize', update);
window.addEventListener('orientationchange', update);
window.addEventListener('pageshow', update);
document.addEventListener('visibilitychange', onVisibility);
window.visualViewport?.addEventListener('resize', update);
update();
return () => {
mobileMq.removeEventListener('change', update);
tabletMq.removeEventListener('change', update);
window.removeEventListener('resize', update);
window.removeEventListener('orientationchange', update);
window.removeEventListener('pageshow', update);
document.removeEventListener('visibilitychange', onVisibility);
window.visualViewport?.removeEventListener('resize', update);
};
}, []);

View File

@@ -57,15 +57,17 @@ export function inferLanguage(filename: string): string | null {
export function flattenToMessage(attachments: Attachment[], text: string): string {
if (attachments.length === 0) return text;
const blocks = attachments.map(a => {
const fence = '```' + (a.language ?? '');
let header: string;
if (a.kind === 'lines') {
header = `// from: ${a.filename}:${a.range?.[0] ?? '?'}-${a.range?.[1] ?? '?'}`;
} else if (a.kind === 'paste') {
header = `// from: pasted text (${a.content.split('\n').length} lines)`;
} else {
header = `// from: ${a.filename}`;
// Pasted text is raw context, not code from a file — insert it verbatim with
// no ``` fence or provenance header. The chip only exists to keep the textarea
// tidy while composing; on send it should be exactly what the user pasted.
if (a.kind === 'paste') {
return a.content;
}
const fence = '```' + (a.language ?? '');
const header =
a.kind === 'lines'
? `// from: ${a.filename}:${a.range?.[0] ?? '?'}-${a.range?.[1] ?? '?'}`
: `// from: ${a.filename}`;
return `${fence}\n${header}\n${a.content}\n\`\`\``;
});
return [...blocks, text].filter(Boolean).join('\n\n');

View File

@@ -6,7 +6,7 @@ import {
useParams,
useSearchParams,
} from 'react-router-dom';
import { ChevronRight, FolderTree, Menu } from 'lucide-react';
import { ChevronRight, FolderTree, Menu, X } from 'lucide-react';
import { api } from '@/api/client';
import type { Project, Session as SessionType } from '@/api/types';
import { sessionEvents } from '@/hooks/sessionEvents';
@@ -61,6 +61,9 @@ function SessionInner({ sessionId }: { sessionId: string }) {
initializeFirstChatIfEmpty,
validatePanes,
} = panesHook;
const [coderConnected, setCoderConnected] = useState<Record<string, boolean>>({});
const activePane = panes[activePaneIdx];
const activeIsCoder = activePane?.kind === 'coder';
const openChatInActivePane = useCallback(
(chatId: string) => openChatInPane(activePaneIdxRef.current, chatId),
@@ -402,6 +405,16 @@ function SessionInner({ sessionId }: { sessionId: string }) {
onAddPane={addPaneAndSwitch}
disabled={panes.length >= MAX_PANES}
/>
{activeIsCoder && activePane && panes.length > 1 && (
<button
type="button"
onClick={() => removePane(activePaneIdx)}
className="inline-flex items-center justify-center min-h-[44px] min-w-[44px] rounded-full bg-muted/40 hover:bg-muted/70 text-foreground shrink-0"
aria-label="Close pane"
>
<X size={16} />
</button>
)}
</div>
</>
) : (
@@ -495,6 +508,11 @@ function SessionInner({ sessionId }: { sessionId: string }) {
session={session}
project={project}
onAddPane={addPaneAndSwitch}
onCoderConnectedChange={(paneId, connected) =>
setCoderConnected((prev) =>
prev[paneId] === connected ? prev : { ...prev, [paneId]: connected },
)
}
/>
)}
</div>

View File

@@ -15,9 +15,12 @@ WORKDIR /build
RUN apk add --no-cache git ca-certificates build-base
# Build codecontext from the v3.2.1 tag.
# Build codecontext from the boocode-ts fork (has .codecontextignore support).
# Source is staged into the build context by the pre-build step:
# tar -czf codecontext/fork.tar.gz -C /opt/forks/codecontext .
# CGO is required: codecontext binds tree-sitter via cgo.
RUN git clone --depth=1 --branch v3.2.1 https://github.com/nmakod/codecontext.git /build/codecontext
COPY fork.tar.gz /build/fork.tar.gz
RUN mkdir -p /build/codecontext && tar -xzf /build/fork.tar.gz -C /build/codecontext
WORKDIR /build/codecontext
RUN CGO_ENABLED=1 GOOS=linux go build -o /build/codecontext-bin ./cmd/codecontext

View File

@@ -191,7 +191,7 @@ func startChild() error {
// initial scan target — codecontext rebuilds the graph against whatever
// target_dir each call carries, so this is just a valid bootstrap path
// (the default "." is the alpine root and trips on transient /proc fds).
child = exec.Command("codecontext", "mcp", "--target=/opt/projects", "--watch=true")
child = exec.Command("codecontext", "mcp", "--target=/opt/projects", "--watch=true", "--respect-gitignore")
var err error
childStdin, err = child.StdinPipe()
if err != nil {

View File

@@ -258,3 +258,67 @@ Output:
- Data flow map (entry → transform → output)
- Conventions observed
- Areas that need deeper investigation
## Planner
---
temperature: 0.6
top_p: 0.95
top_k: 20
min_p: 0.0
presence_penalty: 0.0
steps: 10
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
description: Produces actionable step plans from requirements. Read-only — never modifies files.
---
You produce actionable step plans. You do not modify files.
Process:
1. Restate the goal. Confirm scope and constraints.
2. Read the relevant code areas with view_file, list_dir, grep. Understand the current state before planning.
3. Identify dependencies between steps. Order them so each step has its prerequisites met.
4. Estimate complexity per step (small / medium / large).
5. Call out risks and assumptions that could invalidate the plan.
Output:
- Goal: one line
- Prerequisites: what must be true before starting
- Steps: numbered, each with file paths, what changes, and acceptance criteria
- Risks: what could go wrong, how to detect it
- Verification: how to confirm the plan succeeded (test commands, type checks, manual checks)
Rules:
- Every step must be independently verifiable.
- Do not produce code. Describe what to change, not the change itself.
- If a step affects more than 3 files, break it into sub-steps.
- Flag any step that requires a database migration or env var change.
## Builder
---
temperature: 0.6
top_p: 0.95
top_k: 20
min_p: 0.0
presence_penalty: 0.0
steps: 50
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes, edit_file, create_file, delete_file, apply_pending, rewind]
description: Implements changes using read and write tools. Routes all writes through pending changes.
---
You implement. Read the code, make the changes, verify they work.
Process:
1. Read the target files and understand the current state.
2. Use grep and get_dependencies to find all call sites and dependents.
3. Make changes via edit_file / create_file. All writes queue in pending_changes.
4. Review pending changes before calling apply_pending.
5. After applying, verify: read the modified files, check that the change is correct.
Rules:
- All file modifications go through edit_file / create_file / delete_file. Never bypass pending_changes.
- Read before writing. Understand what exists before changing it.
- Match existing code conventions: naming, imports, error handling patterns.
- One logical change per edit. Do not bundle unrelated changes.
- If a change breaks an import or type, fix it in the same batch before applying.
- Use rewind if a batch of changes is wrong. Do not apply broken changes.
- When done, state what changed and what the user should verify (type check, test, manual check)

View File

@@ -0,0 +1,283 @@
# v2.6 Design — Persistent agent sessions
Reference implementations: `/opt/forks/opencode` (server + SDK),
`/opt/forks/paseo` (warm ACP + opencode server-manager + reasoning dedup).
## 1. Architecture overview
```
BooCoder (systemd host service)
┌─────────────────────────────────────────────────────────────────┐
│ dispatcher (per-turn unit = tasks row) │
│ │ resolve backend + worktree + agent-session for the chat │
│ ▼ │
│ agent-pool ──────────────────────────────────────────────────┐ │
│ ├─ OpenCodeServerBackend (1 process, N sessions) │ │
│ │ `opencode serve` ◄── @opencode-ai/sdk ──► /event SSE │ │
│ └─ WarmAcpBackend[session] (1 stdio process per session) │ │
│ `goose acp` / `qwen --acp` ◄── ClientSideConnection │ │
└──────────────────────────────────────────────────────────────┘ │
│ broker.publishFrame (delta / reasoning_delta / tool_call) │
▼ │
web (CoderPane) — unchanged │
```
The **task row stays the per-turn unit**. What changes: instead of building a
fresh world per task, the dispatcher resolves the chat's *persistent* backend,
worktree, and agent-session, sends one prompt, streams events, diffs, and leaves
everything warm.
## 2. Backends
Common interface (`AgentBackend`):
```
interface AgentBackend {
ensureSession(sessionId, opts): Promise<AgentSessionHandle> // create-or-reuse
prompt(handle, input, { worktreePath, model, signal, onEvent }): Promise<TurnResult>
closeSession(handle): Promise<void>
dispose(): Promise<void> // backend teardown
health(): 'up' | 'down'
}
```
`onEvent` emits the same normalized events the current `acp-dispatch.ts` produces
(`text`, `reasoning`, `tool_call`, `tool_update`) so the broker-frame publishing and
`persistExternalAgentTurn` paths are reused unchanged.
### 2a. OpenCodeServerBackend (shared HTTP server)
- **Spawn once per BooCoder process:** `opencode serve --hostname 127.0.0.1 --port <p>`
with `OPENCODE_SERVER_PASSWORD=<random-at-boot>` (verified: `serve.ts`, `network.ts`;
default port 4096, prints `opencode server listening on http://…`). Use the official
`@opencode-ai/sdk` (`createOpencodeServer` / `createOpencodeClient`) rather than
hand-rolling HTTP — it already parses the ready line and wraps routes.
- **One SSE subscription** to `GET /event`, consumed in a single read loop; events
demuxed by `properties.sessionID` → BooCode session. Reasoning arrives as
`message.part.delta` (`field: "reasoning"`) and `message.part.updated`
(`part.type: "reasoning"`); text as the `text` field; tool calls as tool parts.
- **One opencode session per BooCode chat.** `client.session.create()` once, store the
returned `id` in `agent_sessions.agent_session_id`. Per-turn: `client.session.prompt({
path:{id}, body:{ parts:[{type:'text',text}], model:"provider/model" }})`. Worktree
routing via the `x-opencode-directory` header (set to the session's persistent
worktree) so the agent operates inside it.
- **Reasoning dedup (port from Paseo `opencode-agent.ts`):** track
`streamedPartKeys` of `reasoning:${partID}`; when a `message.part.updated` reasoning
part arrives whose key was already streamed via delta, drop it. Prevents the
double-thought bug (covered by Paseo's `opencode-reasoning-dedup` e2e test).
### 2b. WarmAcpBackend (goose, qwen — stdio)
- **One persistent process + ACP connection per (chat, agent)** (Paseo's
`SpawnedACPProcess`): spawn `goose acp` / `qwen --acp` once, NDJSON over stdio,
`initialize``session/new` once; store the ACP session id in the
`agent_sessions` row. Each turn calls `session/prompt` on the same connection;
switching away and back resumes this same connection/session. Reuses the existing `acp-dispatch.ts`
`handleSessionUpdate` switch verbatim for `agent_message_chunk` /
`agent_thought_chunk` / `tool_call*`.
- **Child lifetime is the pool's, not a request's.** Spawn detached/managed; do not
tie the process to a single dispatch's abort signal (only the in-flight `prompt`
gets the per-turn signal). Mirrors the codecontext shim rule (CLAUDE.md): supervise
the child and react to its exit, don't let a request scope kill it.
## 3. Data model
Agent switching is **free** within a chat (the picker is per-turn, not locked), so
the worktree is shared across agents but each agent keeps its own backend session.
That splits into two tables: one **shared worktree per chat**, and one **backend
session per (chat, agent)** pair.
```sql
-- One shared worktree per BooCode chat. All agents used in the chat operate in it.
CREATE TABLE IF NOT EXISTS session_worktrees (
session_id UUID PRIMARY KEY REFERENCES sessions(id),
worktree_path TEXT NOT NULL,
base_commit TEXT, -- project HEAD captured at create (diff baseline)
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
);
-- One backend session per (chat, agent). Resumed when the user switches back to
-- that agent, so each agent retains its own conversation memory across switches.
CREATE TABLE IF NOT EXISTS agent_sessions (
session_id UUID NOT NULL REFERENCES sessions(id),
agent TEXT NOT NULL, -- opencode | goose | qwen (native boocode needs no row)
backend TEXT NOT NULL, -- opencode_server | acp_warm
agent_session_id TEXT, -- opencode/ACP native session id (the memory handle)
server_port INTEGER, -- opencode server port (nullable)
status TEXT NOT NULL DEFAULT 'idle', -- idle | active | crashed | closed
last_active_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
PRIMARY KEY (session_id, agent),
CONSTRAINT agent_sessions_backend_chk CHECK (backend IN ('opencode_server','acp_warm')),
CONSTRAINT agent_sessions_status_chk CHECK (status IN ('idle','active','crashed','closed'))
);
```
Plus one column for attribution (drives the DiffPanel badges in §9):
```sql
-- Which agent staged each pending change. Stamped at queue time:
-- worktree-diff path → the task's agent; native boocode write tools → 'boocode';
-- manual RightRail create (v2.5.x) → NULL (renders as "manual").
ALTER TABLE pending_changes ADD COLUMN IF NOT EXISTS agent TEXT;
```
`tasks.worktree_path` already exists but was per-task; the persistent worktree now
lives on `session_worktrees`. `tasks` stays the per-turn record (state machine
unchanged) and gains nothing required. **Native boocode** keeps no `agent_sessions`
row — it has no warm backend; it reconstructs conversation context from the chat's
`messages` rows each turn (so it transparently sees every other agent's prior turns).
DB is the source of truth for reconnect after a BooCoder restart (the in-memory pool
rebuilds lazily from these tables on the next turn).
## 3a. Agent switching & continuity (the decided model)
Per the design review: **free switch, per-agent memory.** Concretely:
- **Picker is per-turn.** The message route already sends `provider`/`model` per
message; nothing locks a chat to one agent. v2.6 keeps that.
- **Worktree is shared.** All agents in a chat resolve the same `session_worktrees`
row, so file state carries across switches — *once applied*. (See the staging
boundary caveat below.)
- **Each agent resumes its own session.** Switching opencode → boocode → opencode
reuses opencode's stored `agent_session_id` (its memory intact), not a fresh one.
Lazy-create on first use of an agent in the chat; resume thereafter.
- **Native boocode is the universal reader.** It rebuilds from the `messages` table,
so it always sees the full transcript including other agents' turns.
- **Gap turns are NOT auto-replayed** into a resumed agent. When you return to
opencode, it sees the shared worktree + your new prompt, but did not "hear" the
boocode/goose turns in between. (A future refinement could inject a short
"changes since you last ran" preamble; out of scope for v2.6.)
- **Staging-boundary caveat (must be documented in the UI):** external agents edit
*inside the worktree*; native boocode reads/writes the *project root* via
`pending_changes`. So unapplied edits do **not** cross between a worktree agent and
native boocode — file continuity between the two only exists after apply. This is
an inherent consequence of v2.5's review-before-apply model, not a v2.6 bug.
- **No mid-turn switch.** Per-chat turns are serialized (§5); the agent is fixed for
the duration of an in-flight turn. The user can switch the picker for the *next*
turn while one is running, but it won't retarget the running turn.
## 4. Persistent worktree + incremental diff
- **Create** on the first turn of a chat (`createWorktree(projectPath, sessionId)`
— keyed by chat, not task), capturing project HEAD as `base_commit`. Persist the
`session_worktrees` row; all agents in the chat share it.
- **Reuse** every subsequent turn — no new worktree, no cleanup between turns.
- **Diff strategy (per turn):** diff the worktree against the **project HEAD baseline**
captured when the worktree was created. Each turn supersedes the prior
`pending_changes` row for that session (one accumulating unified diff, latest wins) —
mirrors how the anchored rolling summary supersedes itself. Avoids stacking N partial
diffs the user must reason about; the pending change always reflects the full current
delta of the worktree.
- **Apply** merges the worktree delta back to the project (existing `apply_pending`
path); after apply, re-baseline so the next turn's diff is relative to applied state.
- **Cleanup** on chat close/archive (new hook) and on `dispose()`; removes the
`session_worktrees` row + all `agent_sessions` rows for the chat. Orphan reaper
sweeps worktrees with no live `session_worktrees` row (extends the periodic sweeper).
## 5. Concurrency
Current dispatcher: global `running` boolean → strictly one task at a time.
Target: **per-session serialization, cross-session concurrency.**
- Replace the single `running` flag with a `Map<sessionId, Promise>` in-flight registry.
- `poll()` selects the oldest pending task whose **session has no in-flight turn**, so
two different chats run concurrently but a chat never has two turns at once (the agent
holds conversational state — overlapping prompts would corrupt it).
- The LISTEN/NOTIFY `tasks_new` fast path (v2.5.x) already triggers immediate polls;
the registry replaces the boolean guard there too.
## 6. Lifecycle & failure
- **Lazy spawn:** backend/worktree/agent-session created on first turn for a session.
- **Idle eviction:** pool evicts a backend/session after an idle TTL (e.g. 30 min);
worktree persists (DB-backed); next turn re-spawns and reattaches via stored
`agent_session_id` (opencode persists sessions on disk; ACP re-`session/new` if the
native id is gone).
- **Crash recovery:** supervise children; on exit mark `agent_sessions.status='crashed'`,
publish `chat_status='error'`, and rebuild on the next turn. opencode server crash
takes all opencode sessions down → restart server, recreate sessions.
- **Shutdown drain:** `app.addHook('onClose')` disposes the pool (close opencode server,
kill warm ACP children) after in-flight turns settle — extends the existing
dispatcher `stop()`.
- **systemd:** BooCoder already spawns agent children under `NoNewPrivileges`; long-lived
pool children are fine. Use `context.Background`-equivalent detachment so children
outlive the dispatch that created them.
## 7. Risks / open questions
- **opencode single-server blast radius:** one crash drops all opencode sessions. Mitigated
by on-disk session persistence + lazy re-create. Could later shard one server per project
if it bites.
- **Worktree disk growth:** persistent worktrees per session accumulate; the close-hook +
orphan reaper must be reliable or disk leaks. Add a max-live-worktrees cap with LRU evict.
- **SDK version coupling:** `@opencode-ai/sdk` is a new workspace dep pinned to the installed
opencode (1.15.x). Probe-time version check should warn on major drift.
- **Incremental-diff baseline correctness:** re-baselining after apply must handle the user
editing the project out-of-band; diff vs a stored base commit, not vs a moving target.
- **Reconnect fidelity:** after BooCoder restart, reattaching to a stored opencode session id
assumes the server (also restarted) still has it on disk — verify the SDK reattach path.
- **Cross-agent staging gap:** worktree agents and native boocode don't see each other's
*unapplied* edits (worktree vs project root). The UI must make this legible (e.g. show
which agent staged a pending change) so a switch doesn't look like lost work. A resumed
agent also won't have heard other agents' in-between turns — acceptable per the decided
model, but worth a small "N turns by other agents since you last ran" hint later.
- **Per-(chat,agent) session sprawl:** a chat that cycles through many agents accumulates
warm backends/worktree co-tenants; idle eviction (§6) must key on (chat,agent), and the
opencode server's session count is bounded by eviction, not per-chat.
## 8. File map (anticipated)
| File | Change |
|------|--------|
| `apps/coder/src/services/agent-pool.ts` | NEW — pool + backend interface |
| `apps/coder/src/services/backends/opencode-server.ts` | NEW — SDK + SSE demux + dedup |
| `apps/coder/src/services/backends/warm-acp.ts` | NEW — persistent ACP connection |
| `apps/coder/src/services/dispatcher.ts` | per-chat concurrency; resolve-or-create shared worktree + per-(chat,agent) backend session; no per-turn teardown |
| `apps/coder/src/services/worktrees.ts` | chat-keyed create; baseline capture; re-baseline-on-apply |
| `apps/coder/src/services/agent-turn-persist.ts` | reused as-is |
| `apps/coder/src/schema.sql` | `session_worktrees` + `agent_sessions` (per (chat,agent)) + `pending_changes.agent` column |
| `apps/coder/src/routes/sessions|tasks` | chat-close cleanup hook |
| `apps/coder/src/routes/pending.ts` | `agent` on `listPending` response; stamp `agent` in queue paths |
| `apps/coder/src/routes/agent-sessions.ts` | NEW — `GET /api/sessions/:id/agent-sessions` (§9b) |
| `apps/coder/package.json` | add `@opencode-ai/sdk` dep |
| `apps/web/src/components/panes/CoderPane.tsx` | `PendingChange.agent`; DiffPanel badges + staging hint; pass `sessionId` to composer |
| `apps/web/src/components/AgentComposerBar.tsx` | optional `sessionId` prop; resumed/new chip; export `providerIcon` |
| `apps/web/src/hooks/useAgentSessions.ts` | NEW — chat-scoped agent-session fetch |
| `apps/web/src/api/client.ts` | `api.coder.agentSessions(sessionId)` |
## 9. Frontend UX — agent attribution & switch affordances
The switching model (§3a) is only good if it's **legible**: the user must see which
agent did what, and whether switching back resumes or starts fresh. Pure read+display
over the new `agent` column and `agent_sessions` — no dispatch-logic change.
### 9a. Per-change agent attribution (DiffPanel) — Phase 1
- **Wire:** `listPending` returns the row; add `agent` to the response and to the
frontend `PendingChange` type (`CoderPane.tsx`, today `{id, file_path, operation, diff?, status}`).
- **UI:** each DiffPanel row gains a small agent badge before the file path — reuse the
`providerIcon()` switch from `AgentComposerBar` (extract to a shared helper / the new
`icons/ProviderIcons` module) + the provider label; `agent === null` → a neutral
"manual" chip. When the pending set spans >1 distinct agent, a one-line header note
("Changes from opencode, boocode") makes mixed provenance obvious.
### 9b. "Resumed" vs "new session" indicator (AgentComposerBar) — Phase 1
- **API:** `GET /api/sessions/:id/agent-sessions``[{ agent, status, has_session, last_active_at }]`
(reads `agent_sessions` for the chat). Chat-scoped, so it is NOT foldable into the
project-level provider snapshot.
- **Hook:** `useAgentSessions(sessionId)` — fetch on mount, refetch on `message_complete`
(same trigger `usePendingChanges` already uses).
- **UI:** a subtle chip right of the Provider picker:
- current provider has a live row → muted **"resumed"** (title: "Resuming <agent> · last active <relative>").
- native boocode (never has a row) → **"history"** (it reconstructs from the transcript).
- otherwise → **"new session"**.
- Render only when connected and the chat has ≥1 prior turn; hidden on a fresh chat.
- `AgentComposerBar` gains an optional `sessionId?: string` prop (CoderPane has it);
absent → render nothing, so BooChat and other callers are unaffected.
### 9c. Staging-boundary hint (DiffPanel) — Phase 3 polish
- When the selected provider is **native boocode** and pending changes were staged by a
**worktree agent** (or vice-versa), show a one-line muted caveat:
"opencode's edits live in its worktree — boocode won't see them until applied."
Derived purely from per-change `agent` + current `value.provider`; no new state.
Keeps the §3a staging caveat from biting silently.

View File

@@ -0,0 +1,114 @@
# v2.6 Persistent agent sessions (warm processes + OpenCode server)
**Status:** Planned
**Depends on:** v2.2 Paseo providers (ACP dispatch), v2.3 provider lifecycle (registry/snapshot)
**Reference fork:** `/opt/forks/paseo`, `/opt/forks/opencode`
**Pairs with:** the v2.5.x MessageBubble "Thinking" render fix — reasoning already flows; this batch is about persistence, not capability.
## Why
BooCode dispatches external agents (opencode, goose, qwen) **one-shot per task**:
per task the dispatcher cuts a fresh worktree (`createWorktree(projectPath, taskId)`),
spawns `opencode acp` / `goose acp` / `qwen --acp`, runs **one** turn, then tears
down the process *and* the worktree (`dispatcher.ts:runExternalAgent`). Consequences:
- **No session continuity.** A follow-up message in the same chat creates a new
task with a new worktree and a new agent process. The agent has no memory of
the prior turn beyond what BooCode replays as chat history, and it cannot see
the files it edited last turn (fresh worktree every time).
- **Cold start every turn.** Each turn pays the process spawn + ACP `initialize`
handshake (and, for some agents, model load) before any work happens.
- **Diverges from Paseo.** Paseo runs **OpenCode as a long-lived HTTP server**
(`opencode serve` + `@opencode-ai/sdk`, SSE `/event` stream) and keeps **goose /
qwen as warm stdio-ACP processes** (`SpawnedACPProcess`: one ACP connection,
`newSession()` once, many `prompt()`s). BooCode rebuilds the world per turn.
This batch makes a BooCode chat map to a **persistent agent backend + a persistent
worktree** that live for the whole conversation, so turns are warm and the agent
sees its own accumulating edits. Reasoning passthrough is **already solved** (ACP
`agent_thought_chunk``reasoning_delta` → the new MessageBubble Thinking block);
this batch does not touch it beyond porting OpenCode's reasoning-dedup.
## Decisions locked (from design review)
- **Worktree model:** *Persistent worktree per session.* A chat owns one worktree
for the whole conversation; each turn the agent sees prior edits; pending_changes
accumulate; worktree is cleaned on session close, not per turn.
- **Agent switching:** *Free switch, per-agent memory.* The picker stays per-turn
(not locked to a chat). The worktree is shared across agents; each agent keeps its
own backend session, resumed when you switch back to it. Native boocode reconstructs
from chat history (so it sees every agent's turns); a resumed agent does not auto-
ingest the gap turns. Data model: one shared worktree per chat + one backend session
per `(chat, agent)` pair. Caveat: unapplied edits don't cross the worktree↔project
boundary between external agents and native boocode (a v2.5 review-model consequence).
- **Transport per agent (matches Paseo exactly):**
- **OpenCode** → one shared `opencode serve` HTTP server, driven via
`@opencode-ai/sdk`; one opencode *session* per BooCode chat (multi-session,
directory-routed via `x-opencode-directory`).
- **Goose / Qwen** → warm **stdio** ACP process per live session. Their HTTP
"server" modes are just ACP-over-HTTP wrappers (goose: undocumented/internal;
qwen `serve`: an HTTP bridge around a single `qwen --acp` child) — no gain over
stdio, so we keep stdio ACP like Paseo does.
## Scope
### In scope
1. **Agent process pool** (`apps/coder/src/services/agent-pool.ts`) — owns long-lived
backends, lazy spawn, idle eviction, crash restart, shutdown drain.
2. **OpenCode server backend** — spawn `opencode serve`, hold SDK client + single
SSE subscription demuxed by opencode `sessionID` → BooCode session; port +
`OPENCODE_SERVER_PASSWORD` managed at boot.
3. **Warm ACP backend** — persistent `SpawnedACPProcess`-style connection for
goose/qwen reused across turns (one `newSession()`, many prompts).
4. **Persistent worktree lifecycle** — worktree created on first turn of a session,
reused, diffed incrementally into `pending_changes`, cleaned on session close.
5. **Session ↔ backend ↔ worktree mapping** — new `agent_sessions` table.
6. **Per-session concurrency** — replace the dispatcher's global single-flight
`running` guard with per-session serialization (different sessions run
concurrently; one turn at a time within a session).
7. **OpenCode reasoning dedup** — port Paseo's `streamedPartKeys` partID dedup so
reasoning isn't double-emitted (delta + final part).
8. **Switch-aware UI** (design §9) — per-change agent attribution in the DiffPanel
(`pending_changes.agent` column + badges), a resumed/new-session chip on the
AgentComposerBar (chat-scoped `agent-sessions` endpoint), and a staging-boundary
hint so the worktree↔project gap is legible.
9. **Tests + smoke** — pool lifecycle unit tests; multi-turn opencode smoke; switch
round-trip smoke; attribution/indicator smoke.
### Out of scope (this batch)
- Claude PTY→structured transport (separate deferred work — claude stays PTY here).
- Goose/qwen HTTP server modes (intentionally not used).
- Frontend redesign — existing CoderPane multi-turn chat UI already supports
follow-ups; only backend continuity changes.
- Replacing `acp-dispatch.ts` wholesale — warm backend reuses its event handlers.
- Cross-host agent servers (opencode server stays local to the BooCoder host).
## Non-goals
- Multi-user session sharing (single-user homelab).
- Multiple concurrent turns within one agent session (the agent holds conversational
state; turns within a session are serialized).
## Success criteria
- Send two messages in one external-agent chat → second turn reuses the same agent
session **and** the same worktree (verified: no second `createWorktree`, agent
references files it edited in turn 1).
- Warm-start latency for turn 2 materially below turn 1 (no spawn/handshake).
- opencode reasoning shows once per thought (no dupes) in the Thinking block.
- Killing the opencode server mid-session → pool restarts it and the next turn
recovers (opencode persists sessions on disk).
- Switch opencode → boocode → opencode in one chat → opencode resumes its *same*
session (its memory intact), boocode saw opencode's turns as history, and all three
shared the one worktree. No agent is locked to the chat.
- Closing/archiving a session removes its worktree; BooCoder restart drains cleanly.
- Existing one-shot paths (arena, `new_task` tool, MCP create-task) still work.
## Deliverables
| Doc | Purpose |
|-----|---------|
| [`design.md`](./design.md) | Architecture, backends, data model, worktree/diff strategy, lifecycle, risks |
| [`tasks.md`](./tasks.md) | Phased implementation checklist |

View File

@@ -0,0 +1,94 @@
# v2.6 Tasks — Persistent agent sessions
Phased so each phase is independently shippable and smoke-testable. Phase 1
(OpenCode server) delivers the most value on the cleanest API; goose/qwen warm
ACP follows; hardening last.
## Phase 0 — Foundations (no behavior change)
- [ ] 0.1 Add `session_worktrees` + `agent_sessions` tables (per `(session_id, agent)`)
to `apps/coder/src/schema.sql` (idempotent; see design §3).
- [ ] 0.2 Define `AgentBackend` / `AgentSessionHandle` interface + normalized `onEvent`
event union (reuse shapes from `acp-dispatch.ts`).
- [ ] 0.3 Scaffold `agent-pool.ts` with lazy get-or-create keyed by `(chat, agent)`,
health, `dispose()`; wire `app.addHook('onClose')` to dispose alongside dispatcher `stop()`.
## Phase 1 — OpenCode server backend (multi-turn, warm)
- [ ] 1.1 Add `@opencode-ai/sdk` to `apps/coder/package.json`; pin to installed opencode major.
- [ ] 1.2 `backends/opencode-server.ts`: spawn `opencode serve` once (random
`OPENCODE_SERVER_PASSWORD`, allocated port), `createOpencodeClient`, wait for ready line.
- [ ] 1.3 Single `/event` SSE read loop; demux by `properties.sessionID`; map
`message.part.delta`/`updated` (text + reasoning) + tool parts to `onEvent`.
- [ ] 1.4 Port Paseo `streamedPartKeys` reasoning dedup (delta vs final part).
- [ ] 1.5 `ensureSession`: reuse the `(chat, opencode)` `agent_sessions` row if present
(resume on switch-back), else `client.session.create()` → store `agent_session_id`.
- [ ] 1.6 `prompt`: send via SDK with `x-opencode-directory` = session worktree + `model`.
- [ ] 1.7 Dispatcher: when `agent==='opencode'`, route to pool backend instead of
`dispatchViaAcp`; keep broker frames + `persistExternalAgentTurn` identical.
- [ ] 1.8 Persistent worktree: chat-keyed `createWorktree` (shared across agents);
capture base commit in `session_worktrees`; reuse across turns and agents.
- [ ] 1.9 Per-session concurrency: replace global `running` with `Map<sessionId,Promise>`;
`poll()` skips sessions with an in-flight turn.
- [ ] 1.10 Per-turn diff → supersede prior `pending_changes` row for the session (latest-wins).
- [ ] **Smoke 1:** two messages in one opencode chat → same `agent_session_id`, same worktree,
no second `createWorktree`; agent references turn-1 edits; reasoning shows once; turn-2 faster.
## Phase 1 (UX) — Attribution & switch affordances (design §9)
- [ ] U.1 Stamp `pending_changes.agent` at queue time (worktree path → task agent;
native write tools → `'boocode'`; manual RightRail create → NULL).
- [ ] U.2 Add `agent` to `listPending` response + frontend `PendingChange` type.
- [ ] U.3 Extract `providerIcon()` to a shared helper; DiffPanel renders an agent badge
per row + a "Changes from X, Y" note when the pending set spans >1 agent (§9a).
- [ ] U.4 `GET /api/sessions/:id/agent-sessions` route + `api.coder.agentSessions` +
`useAgentSessions(sessionId)` (refetch on `message_complete`) (§9b).
- [ ] U.5 `AgentComposerBar` optional `sessionId` prop → resumed / history / new-session
chip beside the Provider picker; hidden on fresh chats and other callers (§9b).
- [ ] **Smoke U:** stage edits with opencode then boocode → DiffPanel badges each row to the
right agent; composer shows "resumed" when re-selecting opencode, "new session" for goose.
## Phase 2 — Warm ACP backend (goose, qwen)
- [ ] 2.1 `backends/warm-acp.ts`: persistent spawn + `ClientSideConnection`; `initialize` +
`session/new` once; reuse `acp-dispatch.ts` `handleSessionUpdate`.
- [ ] 2.2 `prompt`: `session/prompt` on the warm connection per turn; per-turn abort signal only.
- [ ] 2.3 Child supervision: detached lifetime, exit handler marks `status='crashed'`.
- [ ] 2.4 Dispatcher routes `goose`/`qwen` to warm backend; keep one-shot fallback for arena/MCP
(or opt those into pool too — decide in review).
- [ ] **Smoke 2:** two messages in a goose chat reuse the same process + ACP session + worktree;
reasoning still renders; no per-turn respawn.
- [ ] **Smoke 2b (switch round-trip):** opencode → boocode → opencode in one chat — opencode
resumes the SAME `agent_session_id` (memory intact), boocode saw opencode's turns as
history, all three shared the one worktree, and no agent was locked to the chat.
## Phase 3 — Lifecycle hardening
- [ ] 3.1 Idle TTL eviction keyed per `(chat, agent)`; reattach-on-next-turn from `agent_sessions`.
- [ ] 3.2 Crash recovery: opencode server restart recreates sessions; ACP re-`session/new`.
- [ ] 3.3 Chat close/archive hook → `closeSession` for every `(chat, agent)` + remove the
shared `session_worktrees` row + worktree; mark agent rows `status='closed'`.
- [ ] 3.4 Orphan worktree reaper (extend periodic sweeper) + max-live-worktrees LRU cap.
- [ ] 3.5 Re-baseline worktree diff after `apply_pending`.
- [ ] 3.6 Reconnect test: restart BooCoder mid-session → next turn reattaches/recreates cleanly.
- [ ] 3.7 Staging-boundary hint in DiffPanel (§9c): muted one-liner when the selected
provider can't see another agent's unapplied worktree edits (derived from per-change
`agent` + current provider; no new state).
## Tests
- [ ] T.1 `agent-pool` unit: get-or-create, idle evict, dispose drains in-flight (DB-opt-in pattern).
- [ ] T.2 opencode SSE demux + reasoning dedup unit (fixture event stream).
- [ ] T.3 per-session concurrency: two sessions run concurrently, one session serializes.
## Docs
- [ ] D.1 Update `CLAUDE.md` (BooCoder dispatch section) + `BOOCODER.md` health/contract.
- [ ] D.2 Note opencode `@opencode-ai/sdk` dep + `OPENCODE_SERVER_PASSWORD` env in env docs.
- [ ] D.3 `CHANGELOG.md` entry on tag (`v2.6.0-persistent-agent-sessions`).
## Build / deploy gate
- [ ] B.1 `pnpm -C apps/server build && pnpm -C apps/coder build` clean.
- [ ] B.2 `pnpm -C apps/server test` (+ DB-opt-in) green.
- [ ] B.3 Deploy: `sudo systemctl restart boocoder`; `curl :9502/api/health` reports tool count.