Compare commits
9 Commits
v2.4.0-uns
...
v2.5.2-cod
| Author | SHA1 | Date | |
|---|---|---|---|
| a8c84ecfe4 | |||
| 547fd70650 | |||
| 990a615b87 | |||
| 5352fd9942 | |||
| 66df410826 | |||
| f89c8f3f15 | |||
| cbef7618b3 | |||
| fcc7c5a86e | |||
| bcfc94fa47 |
@@ -21,6 +21,7 @@ out/
|
|||||||
.opencode/
|
.opencode/
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
.claude/worktrees/
|
||||||
|
|
||||||
# Test artifacts / coverage
|
# Test artifacts / coverage
|
||||||
coverage/
|
coverage/
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ POSTGRES_PASSWORD=CHANGE_ME
|
|||||||
# point BooCode at a different SearXNG instance.
|
# point BooCode at a different SearXNG instance.
|
||||||
SEARXNG_URL=http://100.114.205.53:8888
|
SEARXNG_URL=http://100.114.205.53:8888
|
||||||
|
|
||||||
|
# Task model: lightweight model for auto-naming, search rewrite, etc.
|
||||||
|
# Direct llama-server instance (NOT llama-swap). Falls back to LLAMA_SWAP_URL
|
||||||
|
# with FAST_MODEL when unset.
|
||||||
|
# TASK_MODEL_URL=http://100.90.172.55:7995
|
||||||
|
|
||||||
# v1.13.15-tools: BOOCODE_TOOLS narrows the tool whitelist sent to the LLM.
|
# v1.13.15-tools: BOOCODE_TOOLS narrows the tool whitelist sent to the LLM.
|
||||||
# Unset (default) → all tools (~21k schema). Useful primarily for single-purpose
|
# Unset (default) → all tools (~21k schema). Useful primarily for single-purpose
|
||||||
# sessions where the model only needs read-only filesystem access.
|
# sessions where the model only needs read-only filesystem access.
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -16,3 +16,4 @@ data/*
|
|||||||
!data/AGENTS.md
|
!data/AGENTS.md
|
||||||
!data/skills/
|
!data/skills/
|
||||||
!data/mcp.json
|
!data/mcp.json
|
||||||
|
codecontext/fork.tar.gz
|
||||||
|
|||||||
109
AGENTS.md
109
AGENTS.md
@@ -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 1–3. 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.
|
|
||||||
@@ -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.
|
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.3–v2.5.1 entries were never backfilled and remain absent above.)
|
||||||
|
|
||||||
## v2.2.2-xml-placeholder-reject — 2026-05-26
|
## 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.
|
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.
|
||||||
|
|||||||
15
CLAUDE.md
15
CLAUDE.md
@@ -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).
|
- 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`.
|
- 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.
|
- 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`.
|
- 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.
|
- 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).
|
- Event dedup discipline: for any mutation the server publishes via `broker.publishUser`, do NOT add a local `sessionEvents.emit(...)` after the API call — `useUserEvents` forwards the WS frame onto the bus. Frontend mutation handlers must be idempotent (dedup by id, no-op on already-present).
|
||||||
- `node:20-*` base images ship a `node` user at uid/gid 1000 — delete it (`userdel`/`groupdel` on debian, `deluser`/`delgroup` on alpine) before adding samkintop at 1000.
|
- `node: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.
|
- 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.
|
- `/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.
|
- 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 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: `docker compose build --no-cache codecontext`.
|
- 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.
|
- 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.
|
- `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.
|
- **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`.
|
- **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.
|
- **`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).
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
import type { Sql } from '../db.js';
|
import type { Sql } from '../db.js';
|
||||||
import {
|
import {
|
||||||
listPending,
|
listPending,
|
||||||
@@ -6,7 +7,14 @@ import {
|
|||||||
applyAll,
|
applyAll,
|
||||||
rejectOne,
|
rejectOne,
|
||||||
rewindOne,
|
rewindOne,
|
||||||
|
queueCreate,
|
||||||
} from '../services/pending_changes.js';
|
} 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.
|
* 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
|
// POST /api/sessions/:sessionId/pending/apply — apply all pending changes
|
||||||
app.post<{ Params: { sessionId: string } }>(
|
app.post<{ Params: { sessionId: string } }>(
|
||||||
'/api/sessions/:sessionId/pending/apply',
|
'/api/sessions/:sessionId/pending/apply',
|
||||||
|
|||||||
@@ -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 mode_id TEXT;
|
||||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS thinking_option_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;
|
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();
|
||||||
|
|||||||
@@ -24,16 +24,29 @@ interface Deps {
|
|||||||
config: Config;
|
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;
|
const COMPLETION_POLL_MS = 2_000;
|
||||||
|
|
||||||
export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<void> } {
|
export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<void> } {
|
||||||
const { sql, inference, broker, log, config } = deps;
|
const { sql, inference, broker, log, config } = deps;
|
||||||
let timer: ReturnType<typeof setInterval> | null = null;
|
let timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let listener: { unlisten: () => Promise<void> } | null = null;
|
||||||
let running = false;
|
let running = false;
|
||||||
let stopping = false;
|
let stopping = false;
|
||||||
let inflightPromise: Promise<void> | null = null;
|
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> {
|
async function poll(): Promise<void> {
|
||||||
if (running || stopping) return;
|
if (running || stopping) return;
|
||||||
|
|
||||||
@@ -463,12 +476,28 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
start() {
|
start() {
|
||||||
log.info('dispatcher: starting poll loop');
|
log.info('dispatcher: starting poll loop + tasks_new listener');
|
||||||
timer = setInterval(() => {
|
|
||||||
poll().catch((err) => {
|
// Fallback poll — catches notifications missed while the listen connection
|
||||||
log.error({ err }, 'dispatcher: poll error');
|
// 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() {
|
async stop() {
|
||||||
@@ -477,6 +506,12 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
timer = null;
|
timer = null;
|
||||||
}
|
}
|
||||||
|
if (listener) {
|
||||||
|
await listener.unlisten().catch((err) => {
|
||||||
|
log.error({ err }, 'dispatcher: unlisten error');
|
||||||
|
});
|
||||||
|
listener = null;
|
||||||
|
}
|
||||||
if (inflightPromise) {
|
if (inflightPromise) {
|
||||||
log.info('dispatcher: waiting for in-flight task');
|
log.info('dispatcher: waiting for in-flight task');
|
||||||
await inflightPromise;
|
await inflightPromise;
|
||||||
|
|||||||
@@ -24,12 +24,6 @@ export const PROVIDERS: ProviderDef[] = [
|
|||||||
transport: 'native',
|
transport: 'native',
|
||||||
modelSource: 'llama-swap',
|
modelSource: 'llama-swap',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'cursor',
|
|
||||||
label: 'Cursor Agent',
|
|
||||||
transport: 'acp',
|
|
||||||
modelSource: 'probe',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'opencode',
|
name: 'opencode',
|
||||||
label: 'OpenCode',
|
label: 'OpenCode',
|
||||||
@@ -59,12 +53,6 @@ export const PROVIDERS: ProviderDef[] = [
|
|||||||
transport: 'acp',
|
transport: 'acp',
|
||||||
modelSource: 'probe',
|
modelSource: 'probe',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'copilot',
|
|
||||||
label: 'GitHub Copilot',
|
|
||||||
transport: 'acp',
|
|
||||||
modelSource: 'probe',
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export const PROVIDERS_BY_NAME = new Map(PROVIDERS.map((p) => [p.name, p]));
|
export const PROVIDERS_BY_NAME = new Map(PROVIDERS.map((p) => [p.name, p]));
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ const ConfigSchema = z.object({
|
|||||||
// v2.0.5: cheaper model for titles, summaries, labeling. Falls back to
|
// v2.0.5: cheaper model for titles, summaries, labeling. Falls back to
|
||||||
// session model (auto_name) or DEFAULT_MODEL when unset.
|
// session model (auto_name) or DEFAULT_MODEL when unset.
|
||||||
FAST_MODEL: z.string().optional(),
|
FAST_MODEL: z.string().optional(),
|
||||||
|
TASK_MODEL_URL: z.string().url().optional(),
|
||||||
|
LLAMA_SIDECAR_URL: z.string().url().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Config = z.infer<typeof ConfigSchema>;
|
export type Config = z.infer<typeof ConfigSchema>;
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import { cleanupTruncations } from './services/truncate.js';
|
|||||||
import { loadMcpConfig } from './services/mcp-config.js';
|
import { loadMcpConfig } from './services/mcp-config.js';
|
||||||
import { initialize as initMcp, getTools as getMcpTools, shutdown as shutdownMcp } from './services/mcp-client.js';
|
import { initialize as initMcp, getTools as getMcpTools, shutdown as shutdownMcp } from './services/mcp-client.js';
|
||||||
import { appendMcpTools } from './services/tools.js';
|
import { appendMcpTools } from './services/tools.js';
|
||||||
import { refreshToolNames } from './services/agents.js';
|
import { refreshToolNames, getAgentsForProject } from './services/agents.js';
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
@@ -91,6 +91,20 @@ async function main() {
|
|||||||
}
|
}
|
||||||
app.addHook('onClose', async () => { await shutdownMcp(); });
|
app.addHook('onClose', async () => { await shutdownMcp(); });
|
||||||
|
|
||||||
|
// Boot-time guard: if any agent has llama_extra_args but LLAMA_SIDECAR_URL
|
||||||
|
// is unset, fail fast. Silent fallback would defeat per-agent flags.
|
||||||
|
if (!config.LLAMA_SIDECAR_URL) {
|
||||||
|
const { agents } = await getAgentsForProject('');
|
||||||
|
const offending = agents.find(a => a.llama_extra_args && a.llama_extra_args.length > 0);
|
||||||
|
if (offending) {
|
||||||
|
app.log.fatal(
|
||||||
|
{ agent: offending.name },
|
||||||
|
`Agent "${offending.name}" has llama_extra_args but LLAMA_SIDECAR_URL is not set`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await app.register(fastifyWebsocket);
|
await app.register(fastifyWebsocket);
|
||||||
|
|
||||||
app.get('/api/health', async () => {
|
app.get('/api/health', async () => {
|
||||||
|
|||||||
@@ -344,6 +344,7 @@ INSERT INTO settings (key, value) VALUES ('theme_mode', '"dark"') ON CONFLICT (k
|
|||||||
ALTER TABLE projects ADD COLUMN IF NOT EXISTS default_system_prompt TEXT NOT NULL DEFAULT '';
|
ALTER TABLE projects ADD COLUMN IF NOT EXISTS default_system_prompt TEXT NOT NULL DEFAULT '';
|
||||||
ALTER TABLE projects ADD COLUMN IF NOT EXISTS default_web_search_enabled BOOLEAN NOT NULL DEFAULT false;
|
ALTER TABLE projects ADD COLUMN IF NOT EXISTS default_web_search_enabled BOOLEAN NOT NULL DEFAULT false;
|
||||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS web_search_enabled BOOLEAN;
|
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS web_search_enabled BOOLEAN;
|
||||||
|
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS tags TEXT[] DEFAULT '{}';
|
||||||
|
|
||||||
-- v1.11: anchored rolling compaction.
|
-- v1.11: anchored rolling compaction.
|
||||||
-- compacted_at — marks rows that are "behind the curtain" of the latest
|
-- compacted_at — marks rows that are "behind the curtain" of the latest
|
||||||
@@ -366,3 +367,39 @@ ALTER TABLE messages ADD COLUMN IF NOT EXISTS summary BOOLEAN NOT NULL DEFAULT F
|
|||||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS tail_start_id UUID REFERENCES messages(id) ON DELETE SET NULL;
|
ALTER TABLE messages ADD COLUMN IF NOT EXISTS tail_start_id UUID REFERENCES messages(id) ON DELETE SET NULL;
|
||||||
ALTER TABLE chats ADD COLUMN IF NOT EXISTS needs_compaction BOOLEAN NOT NULL DEFAULT FALSE;
|
ALTER TABLE chats ADD COLUMN IF NOT EXISTS needs_compaction BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
CREATE INDEX IF NOT EXISTS idx_messages_chat_compacted ON messages (chat_id, compacted_at);
|
CREATE INDEX IF NOT EXISTS idx_messages_chat_compacted ON messages (chat_id, compacted_at);
|
||||||
|
|
||||||
|
-- tasks table (provider dispatch, arena)
|
||||||
|
CREATE TABLE IF NOT EXISTS tasks (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
session_id UUID REFERENCES sessions(id) ON DELETE CASCADE,
|
||||||
|
parent_task_id UUID REFERENCES tasks(id),
|
||||||
|
arena_id UUID,
|
||||||
|
state TEXT NOT NULL DEFAULT 'pending'
|
||||||
|
CHECK (state IN ('pending','running','completed','failed','blocked','cancelled')),
|
||||||
|
input TEXT NOT NULL,
|
||||||
|
output_summary TEXT,
|
||||||
|
agent TEXT,
|
||||||
|
model TEXT,
|
||||||
|
mode_id TEXT,
|
||||||
|
thinking_option_id TEXT,
|
||||||
|
feature_values JSONB,
|
||||||
|
execution_path TEXT CHECK (execution_path IS NULL OR execution_path IN ('native','acp','pty','qwen')),
|
||||||
|
worktree_path TEXT,
|
||||||
|
cost_tokens INTEGER,
|
||||||
|
started_at TIMESTAMPTZ,
|
||||||
|
ended_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Fix tasks FK to cascade on session delete (existing tables without CASCADE)
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'tasks_session_id_fkey'
|
||||||
|
AND confdeltype != 'c'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE tasks DROP CONSTRAINT tasks_session_id_fkey;
|
||||||
|
ALTER TABLE tasks ADD CONSTRAINT tasks_session_id_fkey
|
||||||
|
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|||||||
107
apps/server/src/services/__tests__/agent-allowlist.test.ts
Normal file
107
apps/server/src/services/__tests__/agent-allowlist.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
58
apps/server/src/services/__tests__/provider.test.ts
Normal file
58
apps/server/src/services/__tests__/provider.test.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { resolveRoute, upstreamModel } from '../inference/provider.js';
|
||||||
|
|
||||||
|
describe('resolveRoute', () => {
|
||||||
|
it('routes to swap when agent is null', () => {
|
||||||
|
expect(resolveRoute(null)).toEqual({ route: 'swap', flags: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes to swap when agent has no llama_extra_args', () => {
|
||||||
|
expect(resolveRoute({ llama_extra_args: null })).toEqual({ route: 'swap', flags: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes to swap when agent has empty llama_extra_args', () => {
|
||||||
|
expect(resolveRoute({ llama_extra_args: [] })).toEqual({ route: 'swap', flags: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes to sidecar when agent has llama_extra_args', () => {
|
||||||
|
const result = resolveRoute({ llama_extra_args: ['--top-k', '20'] });
|
||||||
|
expect(result.route).toBe('sidecar');
|
||||||
|
expect(result.flags).toEqual(['--top-k', '20']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('upstreamModel', () => {
|
||||||
|
const swapConfig = { LLAMA_SWAP_URL: 'http://localhost:8401' };
|
||||||
|
const fullConfig = {
|
||||||
|
LLAMA_SWAP_URL: 'http://localhost:8401',
|
||||||
|
LLAMA_SIDECAR_URL: 'http://localhost:8402',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('returns a model for swap route (no agent)', () => {
|
||||||
|
const model = upstreamModel(swapConfig, 'test-model');
|
||||||
|
expect(model).toBeDefined();
|
||||||
|
expect((model as any).modelId).toBe('test-model');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a model for swap route (agent without extra args)', () => {
|
||||||
|
const model = upstreamModel(swapConfig, 'test-model', { llama_extra_args: null });
|
||||||
|
expect(model).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a model for sidecar route', () => {
|
||||||
|
const model = upstreamModel(fullConfig, 'test-model', { llama_extra_args: ['--top-k', '20'] });
|
||||||
|
expect(model).toBeDefined();
|
||||||
|
expect((model as any).modelId).toBe('test-model');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when sidecar route requested but URL missing', () => {
|
||||||
|
expect(() =>
|
||||||
|
upstreamModel(swapConfig, 'test-model', { llama_extra_args: ['--top-k', '20'] }),
|
||||||
|
).toThrow(/LLAMA_SIDECAR_URL/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes to swap for empty llama_extra_args array', () => {
|
||||||
|
const model = upstreamModel(swapConfig, 'test-model', { llama_extra_args: [] });
|
||||||
|
expect(model).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { InferenceContext } from './inference/index.js';
|
import type { InferenceContext } from './inference/index.js';
|
||||||
|
import { taskModelCompletion } from './task-model.js';
|
||||||
|
|
||||||
const NAMING_SYSTEM_PROMPT =
|
const NAMING_SYSTEM_PROMPT =
|
||||||
'You name chat sessions based on what the assistant did. Summarize the topic or outcome — do NOT copy the first few words verbatim. Reply directly with no thinking, reasoning, or explanation. Output ONLY the title, 4 words max, no quotes, no punctuation, no prefix like "Title:".';
|
'You name chat sessions. Reply with ONLY the title. 4 to 6 words. No quotes, no punctuation, no prefix.';
|
||||||
|
|
||||||
const MAX_TITLE_CHARS = 60;
|
const MAX_TITLE_CHARS = 80;
|
||||||
|
|
||||||
function cleanTitle(raw: string): string {
|
function cleanTitle(raw: string): string {
|
||||||
let name = raw.trim();
|
let name = raw.trim();
|
||||||
@@ -18,27 +19,7 @@ function cleanTitle(raw: string): string {
|
|||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NamingResponse {
|
// TODO: wire suggestTags after task model validation
|
||||||
choices?: Array<{
|
|
||||||
message?: {
|
|
||||||
content?: string;
|
|
||||||
reasoning_content?: string;
|
|
||||||
};
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickTitleSource(data: NamingResponse): string {
|
|
||||||
const choice = data.choices?.[0]?.message;
|
|
||||||
if (!choice) return '';
|
|
||||||
if (choice.content && choice.content.trim().length > 0) return choice.content;
|
|
||||||
const reasoning = choice.reasoning_content ?? '';
|
|
||||||
if (reasoning.length === 0) return '';
|
|
||||||
const lines = reasoning
|
|
||||||
.split('\n')
|
|
||||||
.map((l) => l.trim())
|
|
||||||
.filter((l) => l.length > 0);
|
|
||||||
return lines[lines.length - 1] ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function maybeAutoNameChat(
|
export async function maybeAutoNameChat(
|
||||||
ctx: InferenceContext,
|
ctx: InferenceContext,
|
||||||
@@ -64,52 +45,29 @@ export async function maybeAutoNameChat(
|
|||||||
if (!chat) return;
|
if (!chat) return;
|
||||||
if (chat.name !== null && chat.name !== '') return;
|
if (chat.name !== null && chat.name !== '') return;
|
||||||
|
|
||||||
const sessionRows = await ctx.sql<{ model: string }[]>`
|
const firstMsgs = await ctx.sql<{ role: string; content: string }[]>`
|
||||||
SELECT model FROM sessions WHERE id = ${sessionId}
|
SELECT role, content FROM messages
|
||||||
`;
|
|
||||||
// v2.0.5: prefer FAST_MODEL for cheap LLM calls (titles, summaries).
|
|
||||||
const model = ctx.config.FAST_MODEL ?? sessionRows[0]?.model;
|
|
||||||
if (!model) return;
|
|
||||||
|
|
||||||
const assistantMsg = await ctx.sql<{ content: string }[]>`
|
|
||||||
SELECT content FROM messages
|
|
||||||
WHERE chat_id = ${chatId}
|
WHERE chat_id = ${chatId}
|
||||||
AND role = 'assistant'
|
AND role IN ('user', 'assistant')
|
||||||
AND status = 'complete'
|
AND status IN ('complete', 'ok')
|
||||||
AND content <> ''
|
AND content <> ''
|
||||||
ORDER BY created_at ASC
|
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 body = {
|
const raw = await taskModelCompletion({
|
||||||
model,
|
system: NAMING_SYSTEM_PROMPT,
|
||||||
messages: [
|
user: namingInput,
|
||||||
{ role: 'system', content: NAMING_SYSTEM_PROMPT },
|
maxTokens: 30,
|
||||||
{
|
|
||||||
role: 'user',
|
|
||||||
content: assistantText,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
max_tokens: 30,
|
|
||||||
temperature: 0.3,
|
temperature: 0.3,
|
||||||
stream: false,
|
|
||||||
chat_template_kwargs: { enable_thinking: false },
|
|
||||||
};
|
|
||||||
|
|
||||||
const res = await fetch(`${ctx.config.LLAMA_SWAP_URL}/v1/chat/completions`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text().catch(() => '');
|
|
||||||
throw new Error(`naming request failed: ${res.status} ${text.slice(0, 200)}`);
|
|
||||||
}
|
|
||||||
const data = (await res.json()) as NamingResponse;
|
|
||||||
const raw = pickTitleSource(data);
|
|
||||||
const name = cleanTitle(raw);
|
const name = cleanTitle(raw);
|
||||||
if (!name) {
|
if (!name) {
|
||||||
ctx.log.warn({ chatId, raw }, 'auto-name: empty title from model');
|
ctx.log.warn({ chatId, raw }, 'auto-name: empty title from model');
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ import { READ_ONLY_TOOL_NAMES } from '../tools.js';
|
|||||||
// turns + deeper exploration without changing the safety floor materially —
|
// turns + deeper exploration without changing the safety floor materially —
|
||||||
// the doom-loop guard (3 identical calls → abort) catches the actual failure
|
// the doom-loop guard (3 identical calls → abort) catches the actual failure
|
||||||
// mode this cap was guarding against.
|
// mode this cap was guarding against.
|
||||||
export const BUDGET_READ_ONLY = 50;
|
export const BUDGET_READ_ONLY = 100;
|
||||||
export const BUDGET_NON_READ_ONLY = 10;
|
export const BUDGET_NON_READ_ONLY = 100;
|
||||||
export const BUDGET_NO_AGENT = 50;
|
export const BUDGET_NO_AGENT = 100;
|
||||||
|
|
||||||
const READ_ONLY_SET: ReadonlySet<string> = new Set(READ_ONLY_TOOL_NAMES);
|
const READ_ONLY_SET: ReadonlySet<string> = new Set(READ_ONLY_TOOL_NAMES);
|
||||||
|
|
||||||
|
|||||||
@@ -1,37 +1,84 @@
|
|||||||
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
||||||
import type { LanguageModel } from 'ai';
|
import type { LanguageModel } from 'ai';
|
||||||
|
|
||||||
// TODO: When per-agent llama-server flag overrides are added, route them
|
|
||||||
// through validateExtraArgs (./llama-args-validator.ts) first.
|
|
||||||
|
|
||||||
// v1.13.1-A: AI SDK provider against llama-swap. baseURL is threaded from
|
// v1.13.1-A: AI SDK provider against llama-swap. baseURL is threaded from
|
||||||
// config.LLAMA_SWAP_URL at call time (not module-load) so tests can stub the
|
// config.LLAMA_SWAP_URL at call time (not module-load) so tests can stub the
|
||||||
// upstream without touching env vars. No apiKey — llama-swap is unauth in our
|
// upstream without touching env vars. No apiKey — llama-swap is unauth in our
|
||||||
// Tailscale topology and exposing it over the public internet is gated by
|
// Tailscale topology and exposing it over the public internet is gated by
|
||||||
// Authelia at the Caddy layer, not by API keys.
|
// Authelia at the Caddy layer, not by API keys.
|
||||||
|
//
|
||||||
|
// v2.4.1-sidecar: when the agent has llama_extra_args, route through
|
||||||
|
// llama-sidecar instead. A fresh provider is created per call (not cached)
|
||||||
|
// because the X-Agent-Flags header varies per agent. The llama-swap path
|
||||||
|
// stays cached since it has no per-request headers.
|
||||||
|
|
||||||
const cache = new Map<string, ReturnType<typeof createOpenAICompatible>>();
|
const swapCache = new Map<string, ReturnType<typeof createOpenAICompatible>>();
|
||||||
|
|
||||||
function getProvider(baseURL: string): ReturnType<typeof createOpenAICompatible> {
|
function getSwapProvider(baseURL: string): ReturnType<typeof createOpenAICompatible> {
|
||||||
let provider = cache.get(baseURL);
|
let provider = swapCache.get(baseURL);
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
provider = createOpenAICompatible({
|
provider = createOpenAICompatible({
|
||||||
name: 'llama-swap',
|
name: 'llama-swap',
|
||||||
baseURL: baseURL.endsWith('/v1') ? baseURL : `${baseURL}/v1`,
|
baseURL: baseURL.endsWith('/v1') ? baseURL : `${baseURL}/v1`,
|
||||||
// v1.13.7: @ai-sdk/openai-compatible defaults includeUsage=false, which
|
|
||||||
// omits `stream_options.include_usage` from the request body. Without
|
|
||||||
// it, llama.cpp / llama-swap never emits the trailing usage block, so
|
|
||||||
// `result.usage` resolves with inputTokens=outputTokens=undefined and
|
|
||||||
// tokens_used / ctx_used land as NULL in every messages row. Setting
|
|
||||||
// true here re-enables the per-stream usage payload across all models
|
|
||||||
// served via the llama-swap provider.
|
|
||||||
includeUsage: true,
|
includeUsage: true,
|
||||||
});
|
});
|
||||||
cache.set(baseURL, provider);
|
swapCache.set(baseURL, provider);
|
||||||
}
|
}
|
||||||
return provider;
|
return provider;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function upstreamModel(baseURL: string, modelId: string): LanguageModel {
|
function sidecarProvider(
|
||||||
return getProvider(baseURL).chatModel(modelId);
|
baseURL: string,
|
||||||
|
flags: string[],
|
||||||
|
): ReturnType<typeof createOpenAICompatible> {
|
||||||
|
return createOpenAICompatible({
|
||||||
|
name: 'llama-sidecar',
|
||||||
|
baseURL: baseURL.endsWith('/v1') ? baseURL : `${baseURL}/v1`,
|
||||||
|
includeUsage: true,
|
||||||
|
headers: {
|
||||||
|
'X-Agent-Flags': flags.join(' '),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InferenceRoute = 'swap' | 'sidecar';
|
||||||
|
|
||||||
|
export interface RoutingInfo {
|
||||||
|
route: InferenceRoute;
|
||||||
|
flags: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AgentLike {
|
||||||
|
llama_extra_args: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfigLike {
|
||||||
|
LLAMA_SWAP_URL: string;
|
||||||
|
LLAMA_SIDECAR_URL?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveRoute(agent: AgentLike | null): RoutingInfo {
|
||||||
|
const flags = agent?.llama_extra_args;
|
||||||
|
if (flags && flags.length > 0) {
|
||||||
|
return { route: 'sidecar', flags };
|
||||||
|
}
|
||||||
|
return { route: 'swap', flags: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upstreamModel(
|
||||||
|
config: ConfigLike,
|
||||||
|
modelId: string,
|
||||||
|
agent?: AgentLike | null,
|
||||||
|
): LanguageModel {
|
||||||
|
const { route, flags } = resolveRoute(agent ?? null);
|
||||||
|
if (route === 'sidecar') {
|
||||||
|
const url = config.LLAMA_SIDECAR_URL;
|
||||||
|
if (!url) {
|
||||||
|
throw new Error(
|
||||||
|
`Agent has llama_extra_args but LLAMA_SIDECAR_URL is not set`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return sidecarProvider(url, flags!).chatModel(modelId);
|
||||||
|
}
|
||||||
|
return getSwapProvider(config.LLAMA_SWAP_URL).chatModel(modelId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,7 +157,8 @@ export async function streamCompletion(
|
|||||||
opts: StreamOptions,
|
opts: StreamOptions,
|
||||||
onDelta: (content: string) => void,
|
onDelta: (content: string) => void,
|
||||||
onUsage: ((prompt: number | null, completion: number | null) => void) | undefined,
|
onUsage: ((prompt: number | null, completion: number | null) => void) | undefined,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal,
|
||||||
|
agent?: Agent | null,
|
||||||
): Promise<StreamResult> {
|
): Promise<StreamResult> {
|
||||||
const aiMessages = toModelMessages(messages);
|
const aiMessages = toModelMessages(messages);
|
||||||
const hasTools = opts.tools !== null && opts.tools.length > 0;
|
const hasTools = opts.tools !== null && opts.tools.length > 0;
|
||||||
@@ -195,7 +196,7 @@ export async function streamCompletion(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const result = streamText({
|
const result = streamText({
|
||||||
model: upstreamModel(ctx.config.LLAMA_SWAP_URL, model),
|
model: upstreamModel(ctx.config, model, agent ?? null),
|
||||||
messages: aiMessages,
|
messages: aiMessages,
|
||||||
...(aiTools
|
...(aiTools
|
||||||
? { tools: aiTools, toolChoice: 'auto' as const, experimental_repairToolCall: repairToolCall }
|
? { tools: aiTools, toolChoice: 'auto' as const, experimental_repairToolCall: repairToolCall }
|
||||||
@@ -458,7 +459,8 @@ export async function executeStreamPhase(
|
|||||||
}, USAGE_THROTTLE_MS - elapsed);
|
}, USAGE_THROTTLE_MS - elapsed);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
signal
|
signal,
|
||||||
|
agent,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (pendingFlushTimer) {
|
if (pendingFlushTimer) {
|
||||||
|
|||||||
@@ -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 * as modelContext from '../model-context.js';
|
||||||
import { PathScopeError } from '../path_guard.js';
|
import { PathScopeError } from '../path_guard.js';
|
||||||
import { TOOLS_BY_NAME } from '../tools.js';
|
import { TOOLS_BY_NAME } from '../tools.js';
|
||||||
|
import { matchToolGlob } from '../agents.js';
|
||||||
import { maybeFlagForCompaction } from './payload.js';
|
import { maybeFlagForCompaction } from './payload.js';
|
||||||
import { insertParts, partsFromAssistantMessage, partsFromToolMessage } from './parts.js';
|
import { insertParts, partsFromAssistantMessage, partsFromToolMessage } from './parts.js';
|
||||||
// v1.13.16: richer unknown-tool error so the model can self-correct when it
|
// 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,
|
result: StreamResult,
|
||||||
startedAt: string | null,
|
startedAt: string | null,
|
||||||
session: Session,
|
session: Session,
|
||||||
projectRoot: string
|
projectRoot: string,
|
||||||
|
agent?: Agent | null,
|
||||||
): Promise<ToolPhaseResult> {
|
): Promise<ToolPhaseResult> {
|
||||||
const { sessionId, chatId, assistantMessageId } = args;
|
const { sessionId, chatId, assistantMessageId } = args;
|
||||||
const content = stripToolMarkup(result.content, { final: true });
|
const content = stripToolMarkup(result.content, { final: true });
|
||||||
@@ -262,6 +264,31 @@ export async function executeToolPhase(
|
|||||||
);
|
);
|
||||||
return;
|
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);
|
const tres = await executeToolCall(projectRoot, tc, session.allowed_read_paths);
|
||||||
if (SYNTHESIS_TOOLS.has(tc.name)) {
|
if (SYNTHESIS_TOOLS.has(tc.name)) {
|
||||||
synthEntries.push({ tc, output: tres.output, ...(tres.error ? { error: tres.error } : {}) });
|
synthEntries.push({ tc, output: tres.output, ...(tres.error ? { error: tres.error } : {}) });
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type {
|
|||||||
import { ALL_TOOLS } from '../tools.js';
|
import { ALL_TOOLS } from '../tools.js';
|
||||||
import { resolveProjectRoot } from '../path_guard.js';
|
import { resolveProjectRoot } from '../path_guard.js';
|
||||||
import { maybeAutoNameChat } from '../auto_name.js';
|
import { maybeAutoNameChat } from '../auto_name.js';
|
||||||
|
import { rewriteSearchQuery } from '../task-search-rewrite.js';
|
||||||
import { getAgentById } from '../agents.js';
|
import { getAgentById } from '../agents.js';
|
||||||
import * as compaction from '../compaction.js';
|
import * as compaction from '../compaction.js';
|
||||||
import type { Broker } from '../broker.js';
|
import type { Broker } from '../broker.js';
|
||||||
@@ -254,6 +255,16 @@ export async function runAssistantTurn(
|
|||||||
const webToolsEnabled =
|
const webToolsEnabled =
|
||||||
iterSession.web_search_enabled ?? iterProject.default_web_search_enabled ?? false;
|
iterSession.web_search_enabled ?? iterProject.default_web_search_enabled ?? false;
|
||||||
|
|
||||||
|
if (stepNumber === 0 && webToolsEnabled && messages.length >= 2) {
|
||||||
|
const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user');
|
||||||
|
if (lastUserMsg?.content) {
|
||||||
|
const hint = await rewriteSearchQuery(lastUserMsg.content);
|
||||||
|
if (hint && messages[0]?.role === 'system' && messages[0].content) {
|
||||||
|
messages[0].content += `\n\nThe user's search intent can be summarized as: "${hint}"`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const iterArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, signal };
|
const iterArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, signal };
|
||||||
const state: StreamPhaseState = { accumulated: '', startedAt: null };
|
const state: StreamPhaseState = { accumulated: '', startedAt: null };
|
||||||
let result: StreamResult;
|
let result: StreamResult;
|
||||||
@@ -281,7 +292,7 @@ export async function runAssistantTurn(
|
|||||||
// ---- tool phase ----
|
// ---- tool phase ----
|
||||||
let toolPhaseResult: ToolPhaseResult;
|
let toolPhaseResult: ToolPhaseResult;
|
||||||
try {
|
try {
|
||||||
toolPhaseResult = await executeToolPhase(ctx, iterArgs, result, state.startedAt, iterSession, projectRoot);
|
toolPhaseResult = await executeToolPhase(ctx, iterArgs, result, state.startedAt, iterSession, projectRoot, agent);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Tool phase errors are unexpected (individual tool failures are
|
// Tool phase errors are unexpected (individual tool failures are
|
||||||
// caught inside executeToolPhase). Log and break.
|
// caught inside executeToolPhase). Log and break.
|
||||||
|
|||||||
@@ -163,6 +163,13 @@ const COMPILED: ReadonlyArray<CompiledPattern> = DEFAULT_SECURITY_IGNORE_FILETYP
|
|||||||
// Returns true when `relPath` matches a known-secret pattern. Case-insensitive
|
// Returns true when `relPath` matches a known-secret pattern. Case-insensitive
|
||||||
// (regex 'i' flag). Always normalize path separators to `/` so Windows-origin
|
// (regex 'i' flag). Always normalize path separators to `/` so Windows-origin
|
||||||
// paths match the same patterns. Empty or root-only paths return false.
|
// 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 {
|
export function isSecretPath(relPath: string): boolean {
|
||||||
if (!relPath) return false;
|
if (!relPath) return false;
|
||||||
const normalized = relPath.replace(/\\/g, '/');
|
const normalized = relPath.replace(/\\/g, '/');
|
||||||
@@ -170,6 +177,8 @@ export function isSecretPath(relPath: string): boolean {
|
|||||||
if (segments.length === 0) return false;
|
if (segments.length === 0) return false;
|
||||||
const base = segments[segments.length - 1]!;
|
const base = segments[segments.length - 1]!;
|
||||||
|
|
||||||
|
if (SAFE_PATTERNS.has(base.toLowerCase())) return false;
|
||||||
|
|
||||||
for (const compiled of COMPILED) {
|
for (const compiled of COMPILED) {
|
||||||
if (compiled.mode === 'basename') {
|
if (compiled.mode === 'basename') {
|
||||||
if (compiled.regex.test(base)) return true;
|
if (compiled.regex.test(base)) return true;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { createHash } from 'node:crypto';
|
|||||||
import { readFile, stat } from 'node:fs/promises';
|
import { readFile, stat } from 'node:fs/promises';
|
||||||
import type { Agent, Project, Session } from '../types/api.js';
|
import type { Agent, Project, Session } from '../types/api.js';
|
||||||
import { getAgentsMtimes } from './agents.js';
|
import { getAgentsMtimes } from './agents.js';
|
||||||
|
import { resolveRoute } from './inference/provider.js';
|
||||||
|
|
||||||
const BASE_SYSTEM_PROMPT = (projectPath: string) =>
|
const BASE_SYSTEM_PROMPT = (projectPath: string) =>
|
||||||
`You are BooCode Chat, a code investigation assistant. The user is working on a project located at ${projectPath}. Use the file-read tools (view_file, list_dir, grep, find_files) to investigate code when needed. Be concise. Cite file paths and line numbers when discussing code. Do not hallucinate file contents — read the file first. Tool results may be truncated; if so, narrow your query rather than guessing.`;
|
`You are BooCode Chat, a code investigation assistant. The user is working on a project located at ${projectPath}. Use the file-read tools (view_file, list_dir, grep, find_files) to investigate code when needed. Be concise. Cite file paths and line numbers when discussing code. Do not hallucinate file contents — read the file first. Tool results may be truncated; if so, narrow your query rather than guessing.`;
|
||||||
@@ -98,6 +99,7 @@ export interface PrefixFingerprint {
|
|||||||
has_agent_system_prompt: boolean;
|
has_agent_system_prompt: boolean;
|
||||||
has_session_override: boolean;
|
has_session_override: boolean;
|
||||||
has_project_override: boolean;
|
has_project_override: boolean;
|
||||||
|
route: 'swap' | 'sidecar';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PrefixDrift {
|
export interface PrefixDrift {
|
||||||
@@ -125,6 +127,7 @@ interface ObservedInputs {
|
|||||||
has_agent_system_prompt: boolean;
|
has_agent_system_prompt: boolean;
|
||||||
has_session_override: boolean;
|
has_session_override: boolean;
|
||||||
has_project_override: boolean;
|
has_project_override: boolean;
|
||||||
|
route: 'swap' | 'sidecar';
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ObserverEntry {
|
interface ObserverEntry {
|
||||||
@@ -183,6 +186,7 @@ export async function buildSystemPromptWithFingerprint(
|
|||||||
has_agent_system_prompt: !!(agent && agent.system_prompt.trim().length > 0),
|
has_agent_system_prompt: !!(agent && agent.system_prompt.trim().length > 0),
|
||||||
has_session_override: sessionPrompt.length > 0,
|
has_session_override: sessionPrompt.length > 0,
|
||||||
has_project_override: projectPrompt.length > 0,
|
has_project_override: projectPrompt.length > 0,
|
||||||
|
route: resolveRoute(agent).route,
|
||||||
};
|
};
|
||||||
|
|
||||||
const fingerprint: PrefixFingerprint = {
|
const fingerprint: PrefixFingerprint = {
|
||||||
@@ -199,6 +203,7 @@ export async function buildSystemPromptWithFingerprint(
|
|||||||
has_agent_system_prompt: inputs.has_agent_system_prompt,
|
has_agent_system_prompt: inputs.has_agent_system_prompt,
|
||||||
has_session_override: inputs.has_session_override,
|
has_session_override: inputs.has_session_override,
|
||||||
has_project_override: inputs.has_project_override,
|
has_project_override: inputs.has_project_override,
|
||||||
|
route: inputs.route,
|
||||||
};
|
};
|
||||||
|
|
||||||
let drift: PrefixDrift | null = null;
|
let drift: PrefixDrift | null = null;
|
||||||
|
|||||||
68
apps/server/src/services/task-model.ts
Normal file
68
apps/server/src/services/task-model.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { loadConfig, type Config } from '../config.js';
|
||||||
|
|
||||||
|
const TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
|
export async function taskModelCompletion(opts: {
|
||||||
|
system: string;
|
||||||
|
user: string;
|
||||||
|
maxTokens?: number;
|
||||||
|
temperature?: number;
|
||||||
|
fallbackModel?: string;
|
||||||
|
}): Promise<string> {
|
||||||
|
const config = loadConfig();
|
||||||
|
const maxTokens = opts.maxTokens ?? 30;
|
||||||
|
const temperature = opts.temperature ?? 0.3;
|
||||||
|
|
||||||
|
const { url, model } = resolveEndpoint(config, opts.fallbackModel);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${url}/v1/chat/completions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: opts.system },
|
||||||
|
{ role: 'user', content: opts.user },
|
||||||
|
],
|
||||||
|
max_tokens: maxTokens,
|
||||||
|
temperature,
|
||||||
|
stream: false,
|
||||||
|
chat_template_kwargs: { enable_thinking: false },
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
console.warn(`task-model: ${res.status} ${text.slice(0, 200)}`);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
choices?: Array<{
|
||||||
|
message?: { content?: string; reasoning_content?: string };
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
const choice = data.choices?.[0]?.message;
|
||||||
|
if (!choice) return '';
|
||||||
|
const content = (choice.content ?? '').trim();
|
||||||
|
if (content.length > 0) return content;
|
||||||
|
const reasoning = choice.reasoning_content ?? '';
|
||||||
|
if (reasoning.length === 0) return '';
|
||||||
|
const lines = reasoning.split('\n').map((l) => l.trim()).filter((l) => l.length > 0);
|
||||||
|
return lines[lines.length - 1] ?? '';
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('task-model: request failed', err);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEndpoint(
|
||||||
|
config: Config,
|
||||||
|
fallbackModel?: string,
|
||||||
|
): { url: string; model: string } {
|
||||||
|
if (config.TASK_MODEL_URL) {
|
||||||
|
return { url: config.TASK_MODEL_URL, model: 'gemma-3-270m-it' };
|
||||||
|
}
|
||||||
|
const model = config.FAST_MODEL ?? fallbackModel ?? config.DEFAULT_MODEL;
|
||||||
|
return { url: config.LLAMA_SWAP_URL, model };
|
||||||
|
}
|
||||||
19
apps/server/src/services/task-search-rewrite.ts
Normal file
19
apps/server/src/services/task-search-rewrite.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { taskModelCompletion } from './task-model.js';
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT =
|
||||||
|
'You rewrite user messages into concise web search queries. Reply with ONLY the search query. 3 to 6 words. No quotes, no explanation.';
|
||||||
|
|
||||||
|
const MAX_INPUT_CHARS = 500;
|
||||||
|
const FALLBACK_CHARS = 60;
|
||||||
|
|
||||||
|
export async function rewriteSearchQuery(userMessage: string): Promise<string> {
|
||||||
|
const input = userMessage.slice(0, MAX_INPUT_CHARS);
|
||||||
|
const result = await taskModelCompletion({
|
||||||
|
system: SYSTEM_PROMPT,
|
||||||
|
user: input,
|
||||||
|
maxTokens: 20,
|
||||||
|
temperature: 0.2,
|
||||||
|
});
|
||||||
|
if (result.length > 0) return result;
|
||||||
|
return userMessage.slice(0, FALLBACK_CHARS).trim();
|
||||||
|
}
|
||||||
24
apps/server/src/services/task-summary.ts
Normal file
24
apps/server/src/services/task-summary.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { taskModelCompletion } from './task-model.js';
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT =
|
||||||
|
'Summarize this conversation in one sentence, 15 words max. No quotes, no prefix.';
|
||||||
|
|
||||||
|
const MAX_INPUT_CHARS = 1000;
|
||||||
|
|
||||||
|
export async function oneLineSummary(
|
||||||
|
messages: Array<{ role: string; content: string }>,
|
||||||
|
): Promise<string> {
|
||||||
|
const lastPairs = messages.slice(-6);
|
||||||
|
let input = lastPairs
|
||||||
|
.map((m) => `${m.role}: ${m.content}`)
|
||||||
|
.join('\n');
|
||||||
|
if (input.length > MAX_INPUT_CHARS) {
|
||||||
|
input = input.slice(0, MAX_INPUT_CHARS);
|
||||||
|
}
|
||||||
|
return taskModelCompletion({
|
||||||
|
system: SYSTEM_PROMPT,
|
||||||
|
user: input,
|
||||||
|
maxTokens: 30,
|
||||||
|
temperature: 0.3,
|
||||||
|
});
|
||||||
|
}
|
||||||
22
apps/server/src/services/task-tags.ts
Normal file
22
apps/server/src/services/task-tags.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { taskModelCompletion } from './task-model.js';
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT =
|
||||||
|
'You tag chat sessions. Reply with 1 to 3 lowercase tags separated by commas. Tags should describe the topic. No explanation. Examples: "docker, deployment", "python, debugging", "react, styling".';
|
||||||
|
|
||||||
|
export async function suggestTags(
|
||||||
|
userMessage: string,
|
||||||
|
assistantReply: string,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const input = `User: ${userMessage.slice(0, 300)}\nAssistant: ${assistantReply.slice(0, 300)}`;
|
||||||
|
const result = await taskModelCompletion({
|
||||||
|
system: SYSTEM_PROMPT,
|
||||||
|
user: input,
|
||||||
|
maxTokens: 30,
|
||||||
|
temperature: 0.3,
|
||||||
|
});
|
||||||
|
if (result.length === 0) return [];
|
||||||
|
return result
|
||||||
|
.split(',')
|
||||||
|
.map((t) => t.trim().toLowerCase())
|
||||||
|
.filter((t) => t.length > 0 && t.length <= 30);
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@ function RightRailForSession({ sessionId }: { sessionId: string }) {
|
|||||||
// a right-side drawer toggled by the header's FolderTree button (via
|
// a right-side drawer toggled by the header's FolderTree button (via
|
||||||
// useRightRailDrawer). On desktop, it renders inline as before with its
|
// useRightRailDrawer). On desktop, it renders inline as before with its
|
||||||
// own internal open/close state.
|
// own internal open/close state.
|
||||||
return <RightRail projectId={projectId} />;
|
return <RightRail projectId={projectId} sessionId={sessionId} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MobileBackdrop() {
|
function MobileBackdrop() {
|
||||||
|
|||||||
@@ -346,6 +346,23 @@ export const api = {
|
|||||||
user_message: userMessage,
|
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: {
|
agents: {
|
||||||
|
|||||||
@@ -182,10 +182,14 @@ export interface Message {
|
|||||||
// majority of messages.
|
// majority of messages.
|
||||||
metadata: MessageMetadata | null;
|
metadata: MessageMetadata | null;
|
||||||
// v1.13.1-C: reasoning content captured from models that stream reasoning
|
// v1.13.1-C: reasoning content captured from models that stream reasoning
|
||||||
// tokens separately (qwen3.6 etc.). Backend populates from message_parts;
|
// tokens separately (qwen3.6 etc.) and from external agents over ACP
|
||||||
// optional on the wire — frontend doesn't render this yet (reserved for
|
// (agent_thought_chunk). Backend populates from message_parts; rendered by
|
||||||
// a v1.14 UI surface).
|
// MessageBubble as a collapsible "Thinking" block.
|
||||||
reasoning_parts?: Array<{ text: string }> | null;
|
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
|
// v1.11: anchored rolling compaction fields. Optional on the wire so that
|
||||||
// older API responses (or test fixtures) parse without explicit nulls.
|
// older API responses (or test fixtures) parse without explicit nulls.
|
||||||
// summary — true on the assistant row that holds the active
|
// summary — true on the assistant row that holds the active
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ interface Props {
|
|||||||
|
|
||||||
export function AgentCommandsHint({ commands }: Props) {
|
export function AgentCommandsHint({ commands }: Props) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [expanded, setExpanded] = useState<string | null>(null);
|
||||||
|
|
||||||
if (commands.length === 0) return null;
|
if (commands.length === 0) return null;
|
||||||
|
|
||||||
@@ -25,10 +26,19 @@ export function AgentCommandsHint({ commands }: Props) {
|
|||||||
{open && (
|
{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">
|
<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) => (
|
{commands.map((cmd) => (
|
||||||
<li key={cmd.name} className="font-mono">
|
<li
|
||||||
<span className="text-primary/80">/{cmd.name}</span>
|
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 && (
|
{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>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
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 { api } from '@/api/client';
|
||||||
import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types';
|
import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types';
|
||||||
import { useProviderSnapshot, refreshProviderSnapshot } from '@/hooks/useProviderSnapshot';
|
import { useProviderSnapshot, refreshProviderSnapshot } from '@/hooks/useProviderSnapshot';
|
||||||
@@ -125,9 +126,11 @@ function CompactPicker({ label, value, disabled, options, onPick, icon }: Picker
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
aria-label={`${label}: ${currentLabel}`}
|
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>
|
</button>
|
||||||
<BottomSheet open={open} onClose={() => setOpen(false)} title={label}>
|
<BottomSheet open={open} onClose={() => setOpen(false)} title={label}>
|
||||||
<div className="px-2">{list}</div>
|
<div className="px-2">{list}</div>
|
||||||
@@ -142,16 +145,16 @@ function CompactPicker({ label, value, disabled, options, onPick, icon }: Picker
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={disabled}
|
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}
|
{icon}
|
||||||
<span className="truncate">{currentLabel}</span>
|
<span className="truncate max-w-[180px]">{currentLabel}</span>
|
||||||
<ChevronDown className="size-3 opacity-70 shrink-0" />
|
<ChevronDown className="size-3 opacity-70 shrink-0" />
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start" className="max-h-64 overflow-y-auto min-w-[160px]">
|
<DropdownMenuContent align="start" className="max-h-64 overflow-y-auto min-w-[160px]">
|
||||||
{options.map((o) => (
|
{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')} />
|
<Check className={cn('size-3 shrink-0', o.id === value ? 'opacity-100' : 'opacity-0')} />
|
||||||
{o.label}
|
{o.label}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -166,9 +169,10 @@ interface Props {
|
|||||||
value: AgentSessionConfig;
|
value: AgentSessionConfig;
|
||||||
onChange: (next: AgentSessionConfig) => void;
|
onChange: (next: AgentSessionConfig) => void;
|
||||||
onProviderCommandsChange?: (commands: AgentCommand[]) => 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 allEntries = useProviderSnapshot(projectPath);
|
||||||
const entries = useMemo(
|
const entries = useMemo(
|
||||||
() => allEntries?.filter((e) => e.installed && e.status !== 'error') ?? null,
|
() => 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 providerOptions = entries.map((e) => ({ id: e.name, label: e.label }));
|
||||||
const modeOptions = (currentEntry?.modes ?? []).map((m) => ({ id: m.id, label: m.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 }));
|
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}
|
value={value.provider}
|
||||||
options={providerOptions}
|
options={providerOptions}
|
||||||
onPick={pickProvider}
|
onPick={pickProvider}
|
||||||
icon={<Cpu className="size-3 shrink-0" />}
|
icon={providerIcon(value.provider)}
|
||||||
/>
|
/>
|
||||||
<CompactPicker
|
<CompactPicker
|
||||||
label="Mode"
|
label="Mode"
|
||||||
@@ -283,6 +297,7 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
|
|||||||
disabled={modelOptions.length === 0}
|
disabled={modelOptions.length === 0}
|
||||||
options={modelOptions}
|
options={modelOptions}
|
||||||
onPick={pickModel}
|
onPick={pickModel}
|
||||||
|
icon={<Bot size={13} className="shrink-0" />}
|
||||||
/>
|
/>
|
||||||
{thinkingOpts.length > 0 && (
|
{thinkingOpts.length > 0 && (
|
||||||
<CompactPicker
|
<CompactPicker
|
||||||
@@ -293,11 +308,17 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
|
|||||||
icon={<Brain className="size-3 shrink-0" />}
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void handleRefresh()}
|
onClick={() => void handleRefresh()}
|
||||||
disabled={refreshing}
|
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"
|
aria-label="Refresh provider list"
|
||||||
title="Refresh providers"
|
title="Refresh providers"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { AttachmentPreviewModal } from '@/components/AttachmentPreviewModal';
|
|||||||
import { FileMentionPopover } from '@/components/FileMentionPopover';
|
import { FileMentionPopover } from '@/components/FileMentionPopover';
|
||||||
import { DropOverlay } from '@/components/DropOverlay';
|
import { DropOverlay } from '@/components/DropOverlay';
|
||||||
import { AgentPicker } from '@/components/AgentPicker';
|
import { AgentPicker } from '@/components/AgentPicker';
|
||||||
|
import { AgentCommandsHint } from '@/components/AgentCommandsHint';
|
||||||
import { ContextBar } from '@/components/ContextBar';
|
import { ContextBar } from '@/components/ContextBar';
|
||||||
import { SlashCommandPicker } from '@/components/SlashCommandPicker';
|
import { SlashCommandPicker } from '@/components/SlashCommandPicker';
|
||||||
import { isSlashCommandToken, parseSlashInput, slashQuery } from '@/lib/slash-command';
|
import { isSlashCommandToken, parseSlashInput, slashQuery } from '@/lib/slash-command';
|
||||||
@@ -560,6 +561,9 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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
|
{/* 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
|
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
|
picker rather than as a separate header above it. The row renders
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ export function ChatTabBar({
|
|||||||
<Plus size={12} />
|
<Plus size={12} />
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="min-w-40">
|
<DropdownMenuContent align="end" className="w-fit">
|
||||||
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
|
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
|
||||||
<MessageSquare size={14} /> New BooChat
|
<MessageSquare size={14} /> New BooChat
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import type { ReactNode } 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 { toast } from 'sonner';
|
||||||
import type { Chat, ErrorReason, Message } from '@/api/types';
|
import type { Chat, ErrorReason, Message } from '@/api/types';
|
||||||
import { api, ApiError } from '@/api/client';
|
import { api, ApiError } from '@/api/client';
|
||||||
@@ -117,12 +117,20 @@ function deriveMarkdownTitle(content: string): string {
|
|||||||
return 'Markdown artifact';
|
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 {
|
interface Props {
|
||||||
message: Message;
|
message: Message;
|
||||||
sessionChats?: Chat[];
|
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 };
|
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 }) {
|
function StatsLine({ message }: { message: Message }) {
|
||||||
@@ -157,8 +165,12 @@ function StatsLine({ message }: { message: Message }) {
|
|||||||
|
|
||||||
function ActionRow({
|
function ActionRow({
|
||||||
message,
|
message,
|
||||||
|
actions,
|
||||||
|
hiddenSet,
|
||||||
}: {
|
}: {
|
||||||
message: Message;
|
message: Message;
|
||||||
|
actions?: MessageActions;
|
||||||
|
hiddenSet: Set<string>;
|
||||||
}) {
|
}) {
|
||||||
const [justCopied, setJustCopied] = useState(false);
|
const [justCopied, setJustCopied] = useState(false);
|
||||||
const [regenerating, setRegenerating] = useState(false);
|
const [regenerating, setRegenerating] = useState(false);
|
||||||
@@ -180,7 +192,11 @@ function ActionRow({
|
|||||||
if (regenerating || message.status === 'streaming') return;
|
if (regenerating || message.status === 'streaming') return;
|
||||||
setRegenerating(true);
|
setRegenerating(true);
|
||||||
try {
|
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) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : 'regenerate failed');
|
toast.error(err instanceof Error ? err.message : 'regenerate failed');
|
||||||
} finally {
|
} 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() {
|
async function fork() {
|
||||||
if (forking || message.status !== 'complete') return;
|
if (forking || message.status !== 'complete') return;
|
||||||
setForking(true);
|
setForking(true);
|
||||||
try {
|
try {
|
||||||
const chat = await api.chats.fork(message.chat_id, { messageId: message.id });
|
if (actions?.onFork) {
|
||||||
sessionEvents.emit({ type: 'open_chat_in_active_pane', chat_id: chat.id });
|
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) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : 'fork failed');
|
toast.error(err instanceof Error ? err.message : 'fork failed');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -205,7 +239,11 @@ function ActionRow({
|
|||||||
if (deleting) return;
|
if (deleting) return;
|
||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
try {
|
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);
|
setDeleteOpen(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : 'delete failed');
|
toast.error(err instanceof Error ? err.message : 'delete failed');
|
||||||
@@ -215,7 +253,9 @@ function ActionRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isAssistant = message.role === 'assistant';
|
const isAssistant = message.role === 'assistant';
|
||||||
|
const isUser = message.role === 'user';
|
||||||
const canRegen = isAssistant && message.status !== 'streaming';
|
const canRegen = isAssistant && message.status !== 'streaming';
|
||||||
|
const canResend = isUser && message.status === 'complete' && !!message.content?.trim();
|
||||||
const canFork = message.status === 'complete';
|
const canFork = message.status === 'complete';
|
||||||
const canDelete = message.status !== 'streaming';
|
const canDelete = message.status !== 'streaming';
|
||||||
const [openingPane, setOpeningPane] = useState(false);
|
const [openingPane, setOpeningPane] = useState(false);
|
||||||
@@ -279,7 +319,18 @@ function ActionRow({
|
|||||||
>
|
>
|
||||||
{justCopied ? <Check className="size-3" /> : <Copy className="size-3" />}
|
{justCopied ? <Check className="size-3" /> : <Copy className="size-3" />}
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void openInPane()}
|
onClick={() => void openInPane()}
|
||||||
@@ -303,26 +354,30 @@ function ActionRow({
|
|||||||
<RefreshCw className={`size-3 ${regenerating ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`size-3 ${regenerating ? 'animate-spin' : ''}`} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
{!hiddenSet.has('fork') && (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => void fork()}
|
type="button"
|
||||||
disabled={!canFork || forking}
|
onClick={() => void fork()}
|
||||||
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]"
|
disabled={!canFork || forking}
|
||||||
aria-label="Fork from here"
|
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]"
|
||||||
title="Fork from here"
|
aria-label="Fork from here"
|
||||||
>
|
title="Fork from here"
|
||||||
<GitFork className="size-3" />
|
>
|
||||||
</button>
|
<GitFork className="size-3" />
|
||||||
<button
|
</button>
|
||||||
type="button"
|
)}
|
||||||
onClick={() => setDeleteOpen(true)}
|
{!hiddenSet.has('delete') && (
|
||||||
disabled={!canDelete}
|
<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]"
|
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"
|
aria-label="Delete message"
|
||||||
title="Delete message"
|
title="Delete message"
|
||||||
>
|
>
|
||||||
<Trash2 className="size-3" />
|
<Trash2 className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Dialog
|
<Dialog
|
||||||
open={deleteOpen}
|
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'
|
// v1.11: anchored rolling summary row. Checked BEFORE the kind==='compact'
|
||||||
// branch because summary=true never coexists with kind='compact' (new
|
// branch because summary=true never coexists with kind='compact' (new
|
||||||
// compactions emit role='assistant' rows with kind='message'+summary=true).
|
// compactions emit role='assistant' rows with kind='message'+summary=true).
|
||||||
@@ -585,7 +672,7 @@ export function MessageBubble({ message, sessionChats, capHitInfo }: Props) {
|
|||||||
{message.content}
|
{message.content}
|
||||||
</div>
|
</div>
|
||||||
</SendToTerminalMenu>
|
</SendToTerminalMenu>
|
||||||
<ActionRow message={message} />
|
<ActionRow message={message} actions={actions} hiddenSet={hiddenSet} />
|
||||||
</div>
|
</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
|
// v1.13.7: match the MessageList.flatten trim guard so a whitespace-only
|
||||||
// assistant turn doesn't render an empty bubble + dangling ActionRow.
|
// assistant turn doesn't render an empty bubble + dangling ActionRow.
|
||||||
const hasContent = message.content.trim().length > 0;
|
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
|
// 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
|
// generic "message failed" line. Keeps the user's eye where it already is
|
||||||
// rather than introducing a separate banner.
|
// rather than introducing a separate banner.
|
||||||
const errorMeta =
|
const errorMeta =
|
||||||
message.metadata !== null && message.metadata.kind === 'error'
|
message.metadata != null && message.metadata.kind === 'error'
|
||||||
? message.metadata
|
? message.metadata
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group flex flex-col gap-2">
|
<div className="group flex flex-col gap-2">
|
||||||
|
{hasReasoning && <ReasoningBlock text={reasoningText} streaming={isStreaming} />}
|
||||||
{(hasContent || isStreaming) && (
|
{(hasContent || isStreaming) && (
|
||||||
<SendToTerminalMenu>
|
<SendToTerminalMenu>
|
||||||
<div className="max-w-[90%] text-sm leading-relaxed space-y-2 break-words min-w-0">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isStreaming && <StatsLine message={message} />}
|
{!isStreaming && <StatsLine message={message} />}
|
||||||
{!isStreaming && hasContent && <ActionRow message={message} />}
|
{!isStreaming && hasContent && <ActionRow message={message} actions={actions} hiddenSet={hiddenSet} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -273,7 +273,7 @@ export function MobileTabSwitcher({
|
|||||||
<MoreHorizontal size={14} />
|
<MoreHorizontal size={14} />
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end" className="min-w-44">
|
||||||
{chat && (
|
{chat && (
|
||||||
<DropdownMenuItem onSelect={() => startRename(chat.id, chat.name)}>
|
<DropdownMenuItem onSelect={() => startRename(chat.id, chat.name)}>
|
||||||
<Edit2 size={14} /> Rename chat
|
<Edit2 size={14} /> Rename chat
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function NewPaneMenu({ onAddPane, disabled }: Props) {
|
|||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end" className="w-fit">
|
||||||
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
|
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
|
||||||
<MessageSquare size={14} /> New BooChat
|
<MessageSquare size={14} /> New BooChat
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { ChevronRight, ChevronDown, FileText, Folder, PanelRightClose, PanelRightOpen } from 'lucide-react';
|
import { ChevronRight, ChevronDown, FilePlus, FileText, Folder, PanelRightClose, PanelRightOpen } from 'lucide-react';
|
||||||
import { api } from '@/api/client';
|
import { api, ApiError } from '@/api/client';
|
||||||
import type { FileEntry } from '@/api/types';
|
import type { FileEntry } from '@/api/types';
|
||||||
import { inferLanguage } from '@/lib/attachments';
|
import { inferLanguage } from '@/lib/attachments';
|
||||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
@@ -8,10 +8,22 @@ import { useRightRailDrawer } from '@/hooks/useRightRailDrawer';
|
|||||||
import { useViewport } from '@/hooks/useViewport';
|
import { useViewport } from '@/hooks/useViewport';
|
||||||
import { FileViewerOverlay } from '@/components/FileViewerOverlay';
|
import { FileViewerOverlay } from '@/components/FileViewerOverlay';
|
||||||
import { Input } from '@/components/ui/input';
|
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';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
sessionId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = 'boocode.rightrail';
|
const STORAGE_KEY = 'boocode.rightrail';
|
||||||
@@ -27,7 +39,7 @@ function joinPath(parent: string, name: string): string {
|
|||||||
return `${parent}/${name}`;
|
return `${parent}/${name}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RightRail({ projectId }: Props) {
|
export function RightRail({ projectId, sessionId }: Props) {
|
||||||
const { isMobile } = useViewport();
|
const { isMobile } = useViewport();
|
||||||
const { open: drawerOpen, setOpen: setDrawerOpen } = useRightRailDrawer();
|
const { open: drawerOpen, setOpen: setDrawerOpen } = useRightRailDrawer();
|
||||||
const [open, setOpen] = useState(() => {
|
const [open, setOpen] = useState(() => {
|
||||||
@@ -39,6 +51,39 @@ export function RightRail({ projectId }: Props) {
|
|||||||
const [fullFileList, setFullFileList] = useState<string[] | null>(null);
|
const [fullFileList, setFullFileList] = useState<string[] | null>(null);
|
||||||
const [viewerFile, setViewerFile] = useState<{ path: string; content: 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
|
// Combined open state: on mobile use the global drawer state (toggled by
|
||||||
// the Session header's FolderTree button); on desktop use the persistent
|
// the Session header's FolderTree button); on desktop use the persistent
|
||||||
// internal state.
|
// internal state.
|
||||||
@@ -163,6 +208,15 @@ export function RightRail({ projectId }: Props) {
|
|||||||
<aside className={asideCls}>
|
<aside className={asideCls}>
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b shrink-0">
|
<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>
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={closeRail}
|
onClick={closeRail}
|
||||||
@@ -225,6 +279,48 @@ export function RightRail({ projectId }: Props) {
|
|||||||
onNavigate={(path) => void openFile(path)}
|
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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,185 +1,27 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { Archive, MessageSquare, Send, ChevronDown, ChevronRight, RotateCcw, Trash2 } from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import type { Chat } from '@/api/types';
|
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { ChatInput } from '@/components/ChatInput';
|
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 {
|
interface Props {
|
||||||
sessionId: string;
|
|
||||||
projectId: string;
|
projectId: string;
|
||||||
chats: Chat[];
|
sessionId: string;
|
||||||
onOpenChat: (chatId: string) => void;
|
agentId?: string | null;
|
||||||
|
onAgentChange?: (agentId: string | null) => void | Promise<void>;
|
||||||
onSend: (content: string) => void;
|
onSend: (content: string) => void;
|
||||||
/** Create a chat and return its id. Used by slash-command handler. */
|
|
||||||
createChat: () => Promise<{ id: string }>;
|
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({
|
export function SessionLandingPage({
|
||||||
chats,
|
|
||||||
onOpenChat,
|
|
||||||
onSend,
|
|
||||||
projectId,
|
projectId,
|
||||||
|
sessionId,
|
||||||
|
agentId,
|
||||||
|
onAgentChange,
|
||||||
|
onSend,
|
||||||
createChat,
|
createChat,
|
||||||
onReopenChat,
|
|
||||||
onArchiveChat,
|
|
||||||
onRenameChat,
|
|
||||||
onDeleteChat,
|
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [composerValue, setComposerValue] = useState('');
|
|
||||||
const [chatId, setChatId] = useState<string | null>(null);
|
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> => {
|
const ensureChat = useCallback(async (): Promise<string> => {
|
||||||
if (chatId) return chatId;
|
if (chatId) return chatId;
|
||||||
try {
|
try {
|
||||||
@@ -192,207 +34,46 @@ export function SessionLandingPage({
|
|||||||
}
|
}
|
||||||
}, [chatId, createChat]);
|
}, [chatId, createChat]);
|
||||||
|
|
||||||
async function handleSend() {
|
const handleSend = useCallback(async (content: string) => {
|
||||||
const text = composerValue.trim();
|
const text = content.trim();
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
try {
|
try {
|
||||||
const cid = await ensureChat();
|
await ensureChat();
|
||||||
onSend(text);
|
onSend(text);
|
||||||
setComposerValue('');
|
|
||||||
} catch {
|
} catch {
|
||||||
// Error already surfaced via toast.
|
// 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) => {
|
const handleSlashCommand = useCallback(async (skillName: string, userMessage: string) => {
|
||||||
try {
|
try {
|
||||||
const cid = await ensureChat();
|
const cid = await ensureChat();
|
||||||
await api.chats.skillInvoke(cid, skillName, userMessage.length > 0 ? userMessage : null);
|
await api.chats.skillInvoke(cid, skillName, userMessage.length > 0 ? userMessage : null);
|
||||||
setComposerValue('');
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : `/${skillName} failed`);
|
toast.error(err instanceof Error ? err.message : `/${skillName} failed`);
|
||||||
}
|
}
|
||||||
}, [ensureChat]);
|
}, [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 (
|
return (
|
||||||
<div className="flex flex-col h-full min-h-0">
|
<div className="flex flex-col h-full min-h-0">
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
|
<div className="flex-1 flex items-center justify-center px-6">
|
||||||
{openChats.length > 0 && (
|
<p className="text-sm text-muted-foreground">
|
||||||
<div>
|
Send a message to start.
|
||||||
<h3 className="text-xs font-medium text-muted-foreground mb-2">Open chats</h3>
|
</p>
|
||||||
<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>
|
</div>
|
||||||
|
<ChatInput
|
||||||
{/* v2.3: ChatInput with slash-command support replaces the bare Textarea.
|
disabled={false}
|
||||||
chatId is created lazily on first send/slash. */}
|
projectId={projectId}
|
||||||
<div className="border-t px-4 py-3 shrink-0">
|
sessionId={sessionId}
|
||||||
<ChatInput
|
agentId={agentId ?? null}
|
||||||
projectId={projectId}
|
onAgentChange={onAgentChange}
|
||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
onSlashCommand={handleSlashCommand}
|
onSlashCommand={handleSlashCommand}
|
||||||
chatId={chatId ?? undefined}
|
chatId={chatId ?? undefined}
|
||||||
chatLabel={chatId ? undefined : 'Chat'}
|
chatLabel="Chat"
|
||||||
disabled={false}
|
messages={[]}
|
||||||
/>
|
modelContextLimit={null}
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ interface Props {
|
|||||||
project: Project | null;
|
project: Project | null;
|
||||||
/** New BooCode opens a fresh coder session; chat/terminal split in-place. */
|
/** New BooCode opens a fresh coder session; chat/terminal split in-place. */
|
||||||
onAddPane: (kind: 'chat' | 'terminal' | 'coder') => void;
|
onAddPane: (kind: 'chat' | 'terminal' | 'coder') => void;
|
||||||
|
onCoderConnectedChange?: (paneId: string, connected: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Workspace({
|
export function Workspace({
|
||||||
@@ -48,6 +49,7 @@ export function Workspace({
|
|||||||
chatsHook,
|
chatsHook,
|
||||||
session,
|
session,
|
||||||
project,
|
project,
|
||||||
|
onCoderConnectedChange,
|
||||||
onAddPane,
|
onAddPane,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const {
|
const {
|
||||||
@@ -141,6 +143,7 @@ export function Workspace({
|
|||||||
|
|
||||||
// Per-coder-pane WS connection (status dot lives in the pane header).
|
// Per-coder-pane WS connection (status dot lives in the pane header).
|
||||||
const [coderConnected, setCoderConnected] = useState<Record<string, boolean>>({});
|
const [coderConnected, setCoderConnected] = useState<Record<string, boolean>>({});
|
||||||
|
const [coderLabels, setCoderLabels] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full min-h-0">
|
<div className="flex flex-col h-full min-h-0">
|
||||||
@@ -212,24 +215,23 @@ export function Workspace({
|
|||||||
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
|
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isCoder && (
|
{isCoder && !isMobile && (
|
||||||
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-2 py-1 shrink-0">
|
<div className="flex items-center gap-1 border-b border-border px-2 py-1 shrink-0">
|
||||||
<Code size={12} className="text-muted-foreground" />
|
<Code size={12} className="text-muted-foreground" />
|
||||||
<span className="text-xs text-muted-foreground">BooCode</span>
|
<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>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => e.stopPropagation()}
|
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"
|
aria-label="New pane"
|
||||||
title="New pane"
|
|
||||||
>
|
>
|
||||||
<Plus size={12} />
|
<Plus size={12} />
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="min-w-40">
|
<DropdownMenuContent align="end" className="w-fit">
|
||||||
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
|
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
|
||||||
<MessageSquare size={14} /> New BooChat
|
<MessageSquare size={14} /> New BooChat
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -241,23 +243,12 @@ export function Workspace({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</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 && (
|
{panes.length > 1 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => { e.stopPropagation(); removePane(idx); }}
|
||||||
e.stopPropagation();
|
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
removePane(idx);
|
aria-label="Close pane"
|
||||||
}}
|
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
<X size={12} />
|
<X size={12} />
|
||||||
</button>
|
</button>
|
||||||
@@ -283,7 +274,7 @@ export function Workspace({
|
|||||||
<Plus size={12} />
|
<Plus size={12} />
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="min-w-40">
|
<DropdownMenuContent align="end" className="w-fit">
|
||||||
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
|
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
|
||||||
<MessageSquare size={14} /> New BooChat
|
<MessageSquare size={14} /> New BooChat
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -354,9 +345,15 @@ export function Workspace({
|
|||||||
chatId={activePaneChatId(pane)}
|
chatId={activePaneChatId(pane)}
|
||||||
chatPending={isPaneChatPending(pane.id)}
|
chatPending={isPaneChatPending(pane.id)}
|
||||||
projectPath={project?.path}
|
projectPath={project?.path}
|
||||||
onConnectedChange={(connected) =>
|
onConnectedChange={(connected) => {
|
||||||
setCoderConnected((prev) =>
|
setCoderConnected((prev) =>
|
||||||
prev[pane.id] === connected ? prev : { ...prev, [pane.id]: connected },
|
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
|
<SessionLandingPage
|
||||||
sessionId={sessionId}
|
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
chats={chats}
|
sessionId={sessionId}
|
||||||
|
agentId={agentId}
|
||||||
|
onAgentChange={onAgentChange}
|
||||||
createChat={() => api.chats.create(sessionId)}
|
createChat={() => api.chats.create(sessionId)}
|
||||||
onOpenChat={(chatId) => openChatInPane(idx, chatId)}
|
|
||||||
onSend={(content) => void handleLandingSend(idx, content)}
|
onSend={(content) => void handleLandingSend(idx, content)}
|
||||||
onReopenChat={async (chatId) => {
|
|
||||||
await unarchiveChat(chatId);
|
|
||||||
openChatInPane(idx, chatId);
|
|
||||||
}}
|
|
||||||
onArchiveChat={archiveChat}
|
|
||||||
onRenameChat={renameChat}
|
|
||||||
onDeleteChat={deleteChat}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
21
apps/web/src/components/icons/ProviderIcons.tsx
Normal file
21
apps/web/src/components/icons/ProviderIcons.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, type ReactNode } from 'react';
|
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 { ToolCallGroup } from '@/components/ToolCallGroup';
|
||||||
import { ToolCallLine, type ToolRun } from '@/components/ToolCallLine';
|
import { ToolCallLine, type ToolRun } from '@/components/ToolCallLine';
|
||||||
import { AskUserInputCard } from '@/components/AskUserInputCard';
|
import { AskUserInputCard } from '@/components/AskUserInputCard';
|
||||||
import { wireToolCallToRun, type CoderToolCallWire } from '@/lib/coder-tools';
|
import { wireToolCallToRun, type CoderToolCallWire } from '@/lib/coder-tools';
|
||||||
|
import type { Message } from '@/api/types';
|
||||||
|
|
||||||
export interface CoderMessageWire {
|
export interface CoderMessageWire {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -141,54 +142,16 @@ function groupToolRuns(items: RenderItem[]): RenderItem[] {
|
|||||||
return out;
|
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 {
|
interface Props {
|
||||||
messages: CoderTimelineWire[];
|
messages: CoderTimelineWire[];
|
||||||
chatId?: string;
|
chatId?: string;
|
||||||
footer?: ReactNode;
|
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 endRef = useRef<HTMLDivElement>(null);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const isNearBottomRef = useRef(true);
|
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">
|
<div className="max-w-[1000px] mx-auto w-full px-6 py-4 space-y-4">
|
||||||
{renderItems.map((item) => {
|
{renderItems.map((item) => {
|
||||||
if (item.kind === 'message') {
|
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.kind === 'tool_run') {
|
||||||
if (item.run.call.name === 'ask_user_input' && chatId) {
|
if (item.run.call.name === 'ask_user_input' && chatId) {
|
||||||
|
|||||||
@@ -4,11 +4,10 @@
|
|||||||
// WS: /api/coder/ws/sessions/:id (Vite dev proxies to :9502).
|
// WS: /api/coder/ws/sessions/:id (Vite dev proxies to :9502).
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
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 { AgentComposerBar } from '@/components/AgentComposerBar';
|
||||||
import { PermissionCard } from '@/components/PermissionCard';
|
import { PermissionCard } from '@/components/PermissionCard';
|
||||||
import { AgentCommandsHint } from '@/components/AgentCommandsHint';
|
import { ChatInput } from '@/components/ChatInput';
|
||||||
import { SlashCommandPicker } from '@/components/SlashCommandPicker';
|
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import type { AgentSessionConfig, PermissionPrompt, AgentCommand } from '@/api/types';
|
import type { AgentSessionConfig, PermissionPrompt, AgentCommand } from '@/api/types';
|
||||||
import { useSkills } from '@/hooks/useSkills';
|
import { useSkills } from '@/hooks/useSkills';
|
||||||
@@ -32,6 +31,8 @@ interface CoderMessage {
|
|||||||
id: string;
|
id: string;
|
||||||
function: { name: string; arguments: string };
|
function: { name: string; arguments: string };
|
||||||
}>;
|
}>;
|
||||||
|
ctx_used?: number | null;
|
||||||
|
ctx_max?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CoderToolMessage {
|
interface CoderToolMessage {
|
||||||
@@ -63,6 +64,7 @@ interface Props {
|
|||||||
chatPending?: boolean;
|
chatPending?: boolean;
|
||||||
projectPath?: string;
|
projectPath?: string;
|
||||||
onConnectedChange?: (connected: boolean) => void;
|
onConnectedChange?: (connected: boolean) => void;
|
||||||
|
onAgentLabelChange?: (label: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WsHandlers {
|
interface WsHandlers {
|
||||||
@@ -91,6 +93,8 @@ type RawCoderMessage = {
|
|||||||
| { id: string; name: string; args?: Record<string, unknown> }
|
| { id: string; name: string; args?: Record<string, unknown> }
|
||||||
| { id: string; function: { name: string; arguments: string } }
|
| { id: string; function: { name: string; arguments: string } }
|
||||||
> | null;
|
> | null;
|
||||||
|
ctx_used?: number | null;
|
||||||
|
ctx_max?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function mapCoderTimelineRow(raw: RawCoderMessage): CoderTimelineMessage | null {
|
function mapCoderTimelineRow(raw: RawCoderMessage): CoderTimelineMessage | null {
|
||||||
@@ -126,6 +130,8 @@ function mapCoderTimelineRow(raw: RawCoderMessage): CoderTimelineMessage | null
|
|||||||
status: (raw.status ?? 'complete') as CoderMessage['status'],
|
status: (raw.status ?? 'complete') as CoderMessage['status'],
|
||||||
...(reasoning_text ? { reasoning_text } : {}),
|
...(reasoning_text ? { reasoning_text } : {}),
|
||||||
...(tool_calls?.length ? { tool_calls } : {}),
|
...(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) =>
|
const next = prev.map((m) =>
|
||||||
m.id === frame.message_id && m.role !== 'tool'
|
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,
|
: m,
|
||||||
);
|
);
|
||||||
if (completed) {
|
if (completed) {
|
||||||
@@ -343,7 +354,7 @@ function usePendingChanges(sessionId: string) {
|
|||||||
useEffect(() => { refresh(); }, [refresh]);
|
useEffect(() => { refresh(); }, [refresh]);
|
||||||
|
|
||||||
const approve = useCallback(async (changeId: string) => {
|
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',
|
method: 'POST',
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -352,7 +363,7 @@ function usePendingChanges(sessionId: string) {
|
|||||||
}, [sessionId]);
|
}, [sessionId]);
|
||||||
|
|
||||||
const reject = useCallback(async (changeId: string) => {
|
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',
|
method: 'POST',
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -463,6 +474,7 @@ export function CoderPane({
|
|||||||
chatPending = false,
|
chatPending = false,
|
||||||
projectPath,
|
projectPath,
|
||||||
onConnectedChange,
|
onConnectedChange,
|
||||||
|
onAgentLabelChange,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [agentConfig, setAgentConfig] = useState<AgentSessionConfig>({
|
const [agentConfig, setAgentConfig] = useState<AgentSessionConfig>({
|
||||||
provider: 'boocode',
|
provider: 'boocode',
|
||||||
@@ -470,6 +482,12 @@ export function CoderPane({
|
|||||||
modeId: null,
|
modeId: null,
|
||||||
thinkingOptionId: 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 [activeTaskId, setActiveTaskId] = useState<string | null>(null);
|
||||||
const [permissionPrompt, setPermissionPrompt] = useState<PermissionPrompt | null>(null);
|
const [permissionPrompt, setPermissionPrompt] = useState<PermissionPrompt | null>(null);
|
||||||
const [permissionBusy, setPermissionBusy] = useState(false);
|
const [permissionBusy, setPermissionBusy] = useState(false);
|
||||||
@@ -515,6 +533,8 @@ export function CoderPane({
|
|||||||
const { changes, loading, refresh, approve, reject } = usePendingChanges(sessionId);
|
const { changes, loading, refresh, approve, reject } = usePendingChanges(sessionId);
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
|
const [queue, setQueue] = useState<string[]>([]);
|
||||||
|
const queueProcessing = useRef(false);
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
// Refresh pending changes when a message_complete arrives
|
// Refresh pending changes when a message_complete arrives
|
||||||
@@ -658,43 +678,87 @@ export function CoderPane({
|
|||||||
setMessages,
|
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 sendOneMessage = useCallback(async (text: string) => {
|
||||||
const newValue = e.target.value;
|
if (!chatId) return;
|
||||||
setInput(newValue);
|
setSending(true);
|
||||||
if (isSlashCommandToken(newValue)) {
|
setPermissionPrompt(null);
|
||||||
setSlashState({ query: slashQuery(newValue) });
|
setLiveTaskCommands([]);
|
||||||
} else {
|
|
||||||
setSlashState(null);
|
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(
|
// Drain queue when not busy
|
||||||
(e: React.KeyboardEvent) => {
|
useEffect(() => {
|
||||||
if (slashState) return;
|
if (sending || queue.length === 0 || queueProcessing.current) return;
|
||||||
if (e.nativeEvent.isComposing) return;
|
queueProcessing.current = true;
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
const next = queue[0]!;
|
||||||
e.preventDefault();
|
setQueue((prev) => prev.slice(1));
|
||||||
void handleSend();
|
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 (
|
return (
|
||||||
<div className="flex flex-col h-full bg-background">
|
<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) */}
|
{/* Chat area — BooChat-style timeline (text + tool runs as siblings) */}
|
||||||
<div className="flex-1 min-h-0 flex flex-col">
|
<div className="flex-1 min-h-0 flex flex-col">
|
||||||
{messages.length === 0 ? (
|
{messages.length === 0 ? (
|
||||||
@@ -706,6 +770,9 @@ export function CoderPane({
|
|||||||
<CoderMessageList
|
<CoderMessageList
|
||||||
messages={messages as CoderTimelineWire[]}
|
messages={messages as CoderTimelineWire[]}
|
||||||
chatId={chatId}
|
chatId={chatId}
|
||||||
|
actions={{
|
||||||
|
onResend: async (_chatId, content) => { await sendOneMessage(content); },
|
||||||
|
}}
|
||||||
footer={
|
footer={
|
||||||
activeTaskId && !permissionPrompt && sending === false ? (
|
activeTaskId && !permissionPrompt && sending === false ? (
|
||||||
<p className="text-xs text-muted-foreground animate-pulse">Agent running…</p>
|
<p className="text-xs text-muted-foreground animate-pulse">Agent running…</p>
|
||||||
@@ -738,44 +805,16 @@ export function CoderPane({
|
|||||||
|
|
||||||
{/* Composer + input */}
|
{/* Composer + input */}
|
||||||
<div className="shrink-0 border-t border-border">
|
<div className="shrink-0 border-t border-border">
|
||||||
{displayedCommands.length > 0 && <AgentCommandsHint commands={displayedCommands} />}
|
<ChatInput
|
||||||
<AgentComposerBar
|
disabled={sending || !chatId || chatPending}
|
||||||
projectPath={projectPath}
|
projectId={projectPath ?? ''}
|
||||||
value={agentConfig}
|
onSend={handleChatInputSend}
|
||||||
onChange={setAgentConfig}
|
onSlashCommand={handleChatInputSlash}
|
||||||
onProviderCommandsChange={handleProviderCommandsChange}
|
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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -162,6 +162,10 @@ export interface ChatStatusEvent {
|
|||||||
reason?: ErrorReason;
|
reason?: ErrorReason;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RefetchMessagesEvent {
|
||||||
|
type: 'refetch_messages';
|
||||||
|
}
|
||||||
|
|
||||||
export type SessionEvent =
|
export type SessionEvent =
|
||||||
| SessionRenamedEvent
|
| SessionRenamedEvent
|
||||||
| ProjectCreatedEvent
|
| ProjectCreatedEvent
|
||||||
@@ -186,7 +190,8 @@ export type SessionEvent =
|
|||||||
| ProjectArchivedEvent
|
| ProjectArchivedEvent
|
||||||
| ProjectUnarchivedEvent
|
| ProjectUnarchivedEvent
|
||||||
| ProjectUpdatedEvent
|
| ProjectUpdatedEvent
|
||||||
| ChatStatusEvent;
|
| ChatStatusEvent
|
||||||
|
| RefetchMessagesEvent;
|
||||||
type Listener = (event: SessionEvent) => void;
|
type Listener = (event: SessionEvent) => void;
|
||||||
|
|
||||||
const listeners = new Set<Listener>();
|
const listeners = new Set<Listener>();
|
||||||
|
|||||||
@@ -294,5 +294,21 @@ export function useSessionStream(sessionId: string | undefined) {
|
|||||||
};
|
};
|
||||||
}, [sessionId]);
|
}, [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;
|
return state;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
|
|||||||
case 'chat_unarchived':
|
case 'chat_unarchived':
|
||||||
case 'chat_deleted':
|
case 'chat_deleted':
|
||||||
case 'chat_status':
|
case 'chat_status':
|
||||||
|
case 'refetch_messages':
|
||||||
return prev;
|
return prev;
|
||||||
case 'project_archived': {
|
case 'project_archived': {
|
||||||
const next = prev.projects.filter((p) => p.id !== event.project_id);
|
const next = prev.projects.filter((p) => p.id !== event.project_id);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { useViewport } from './useViewport';
|
||||||
|
|
||||||
interface SidebarDrawerState {
|
interface SidebarDrawerState {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -13,13 +14,17 @@ const Ctx = createContext<SidebarDrawerState | null>(null);
|
|||||||
export function SidebarDrawerProvider({ children }: { children: ReactNode }) {
|
export function SidebarDrawerProvider({ children }: { children: ReactNode }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const location = useLocation();
|
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(() => {
|
useEffect(() => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}, [location.pathname]);
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
// Close drawer on orientation change (landscape→portrait transition).
|
||||||
|
useEffect(() => {
|
||||||
|
setOpen(false);
|
||||||
|
}, [isMobile]);
|
||||||
|
|
||||||
const toggle = useCallback(() => setOpen((v) => !v), []);
|
const toggle = useCallback(() => setOpen((v) => !v), []);
|
||||||
|
|
||||||
return <Ctx.Provider value={{ open, setOpen, toggle }}>{children}</Ctx.Provider>;
|
return <Ctx.Provider value={{ open, setOpen, toggle }}>{children}</Ctx.Provider>;
|
||||||
|
|||||||
@@ -31,13 +31,48 @@ export function useViewport(): ViewportSnapshot {
|
|||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
const mobileMq = window.matchMedia(`(max-width: ${MOBILE_MAX}px)`);
|
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 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);
|
mobileMq.addEventListener('change', update);
|
||||||
tabletMq.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();
|
update();
|
||||||
return () => {
|
return () => {
|
||||||
mobileMq.removeEventListener('change', update);
|
mobileMq.removeEventListener('change', update);
|
||||||
tabletMq.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);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -57,15 +57,17 @@ export function inferLanguage(filename: string): string | null {
|
|||||||
export function flattenToMessage(attachments: Attachment[], text: string): string {
|
export function flattenToMessage(attachments: Attachment[], text: string): string {
|
||||||
if (attachments.length === 0) return text;
|
if (attachments.length === 0) return text;
|
||||||
const blocks = attachments.map(a => {
|
const blocks = attachments.map(a => {
|
||||||
const fence = '```' + (a.language ?? '');
|
// Pasted text is raw context, not code from a file — insert it verbatim with
|
||||||
let header: string;
|
// no ``` fence or provenance header. The chip only exists to keep the textarea
|
||||||
if (a.kind === 'lines') {
|
// tidy while composing; on send it should be exactly what the user pasted.
|
||||||
header = `// from: ${a.filename}:${a.range?.[0] ?? '?'}-${a.range?.[1] ?? '?'}`;
|
if (a.kind === 'paste') {
|
||||||
} else if (a.kind === 'paste') {
|
return a.content;
|
||||||
header = `// from: pasted text (${a.content.split('\n').length} lines)`;
|
|
||||||
} else {
|
|
||||||
header = `// from: ${a.filename}`;
|
|
||||||
}
|
}
|
||||||
|
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 `${fence}\n${header}\n${a.content}\n\`\`\``;
|
||||||
});
|
});
|
||||||
return [...blocks, text].filter(Boolean).join('\n\n');
|
return [...blocks, text].filter(Boolean).join('\n\n');
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
useParams,
|
useParams,
|
||||||
useSearchParams,
|
useSearchParams,
|
||||||
} from 'react-router-dom';
|
} 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 { api } from '@/api/client';
|
||||||
import type { Project, Session as SessionType } from '@/api/types';
|
import type { Project, Session as SessionType } from '@/api/types';
|
||||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
@@ -61,6 +61,9 @@ function SessionInner({ sessionId }: { sessionId: string }) {
|
|||||||
initializeFirstChatIfEmpty,
|
initializeFirstChatIfEmpty,
|
||||||
validatePanes,
|
validatePanes,
|
||||||
} = panesHook;
|
} = panesHook;
|
||||||
|
const [coderConnected, setCoderConnected] = useState<Record<string, boolean>>({});
|
||||||
|
const activePane = panes[activePaneIdx];
|
||||||
|
const activeIsCoder = activePane?.kind === 'coder';
|
||||||
|
|
||||||
const openChatInActivePane = useCallback(
|
const openChatInActivePane = useCallback(
|
||||||
(chatId: string) => openChatInPane(activePaneIdxRef.current, chatId),
|
(chatId: string) => openChatInPane(activePaneIdxRef.current, chatId),
|
||||||
@@ -402,6 +405,16 @@ function SessionInner({ sessionId }: { sessionId: string }) {
|
|||||||
onAddPane={addPaneAndSwitch}
|
onAddPane={addPaneAndSwitch}
|
||||||
disabled={panes.length >= MAX_PANES}
|
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>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -495,6 +508,11 @@ function SessionInner({ sessionId }: { sessionId: string }) {
|
|||||||
session={session}
|
session={session}
|
||||||
project={project}
|
project={project}
|
||||||
onAddPane={addPaneAndSwitch}
|
onAddPane={addPaneAndSwitch}
|
||||||
|
onCoderConnectedChange={(paneId, connected) =>
|
||||||
|
setCoderConnected((prev) =>
|
||||||
|
prev[paneId] === connected ? prev : { ...prev, [paneId]: connected },
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,9 +15,12 @@ WORKDIR /build
|
|||||||
|
|
||||||
RUN apk add --no-cache git ca-certificates build-base
|
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.
|
# 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
|
WORKDIR /build/codecontext
|
||||||
RUN CGO_ENABLED=1 GOOS=linux go build -o /build/codecontext-bin ./cmd/codecontext
|
RUN CGO_ENABLED=1 GOOS=linux go build -o /build/codecontext-bin ./cmd/codecontext
|
||||||
|
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ func startChild() error {
|
|||||||
// initial scan target — codecontext rebuilds the graph against whatever
|
// initial scan target — codecontext rebuilds the graph against whatever
|
||||||
// target_dir each call carries, so this is just a valid bootstrap path
|
// 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).
|
// (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
|
var err error
|
||||||
childStdin, err = child.StdinPipe()
|
childStdin, err = child.StdinPipe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ top_p: 0.95
|
|||||||
top_k: 20
|
top_k: 20
|
||||||
min_p: 0.0
|
min_p: 0.0
|
||||||
presence_penalty: 0.0
|
presence_penalty: 0.0
|
||||||
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]
|
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: Reviews code for bugs, security issues, and maintainability. Read-only.
|
description: Reviews code for bugs, security issues, and maintainability. Read-only.
|
||||||
---
|
---
|
||||||
You review code. Find real problems, not style nits.
|
You review code. Find real problems, not style nits.
|
||||||
@@ -46,7 +46,7 @@ top_p: 0.95
|
|||||||
top_k: 20
|
top_k: 20
|
||||||
min_p: 0.0
|
min_p: 0.0
|
||||||
presence_penalty: 0.0
|
presence_penalty: 0.0
|
||||||
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]
|
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: Diagnoses bugs from error messages, logs, or described symptoms.
|
description: Diagnoses bugs from error messages, logs, or described symptoms.
|
||||||
---
|
---
|
||||||
You diagnose bugs. Form a hypothesis, prove it with evidence from the code.
|
You diagnose bugs. Form a hypothesis, prove it with evidence from the code.
|
||||||
@@ -72,7 +72,7 @@ top_k: 20
|
|||||||
min_p: 0.0
|
min_p: 0.0
|
||||||
presence_penalty: 0.0
|
presence_penalty: 0.0
|
||||||
steps: 5
|
steps: 5
|
||||||
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]
|
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: Proposes refactors for clarity, deduplication, or decoupling. Read-only — outputs plans, not edits.
|
description: Proposes refactors for clarity, deduplication, or decoupling. Read-only — outputs plans, not edits.
|
||||||
---
|
---
|
||||||
You propose refactors. You do not apply them. The user applies via OpenCode or Claude Code.
|
You propose refactors. You do not apply them. The user applies via OpenCode or Claude Code.
|
||||||
@@ -115,7 +115,7 @@ top_k: 20
|
|||||||
min_p: 0.0
|
min_p: 0.0
|
||||||
presence_penalty: 1.5
|
presence_penalty: 1.5
|
||||||
steps: 20
|
steps: 20
|
||||||
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]
|
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: Designs new features, modules, or architectural changes. Outputs a build plan.
|
description: Designs new features, modules, or architectural changes. Outputs a build plan.
|
||||||
---
|
---
|
||||||
You design. You produce build plans, not code.
|
You design. You produce build plans, not code.
|
||||||
@@ -157,7 +157,7 @@ top_p: 0.95
|
|||||||
top_k: 20
|
top_k: 20
|
||||||
min_p: 0.0
|
min_p: 0.0
|
||||||
presence_penalty: 0.0
|
presence_penalty: 0.0
|
||||||
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]
|
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: Audits code for security vulnerabilities. Read-only.
|
description: Audits code for security vulnerabilities. Read-only.
|
||||||
---
|
---
|
||||||
You audit for security issues. Concrete findings only, no generic warnings.
|
You audit for security issues. Concrete findings only, no generic warnings.
|
||||||
@@ -240,7 +240,7 @@ top_p: 0.95
|
|||||||
top_k: 20
|
top_k: 20
|
||||||
min_p: 0.0
|
min_p: 0.0
|
||||||
presence_penalty: 0.0
|
presence_penalty: 0.0
|
||||||
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]
|
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: Discovers and maps unfamiliar codebases. Reads architecture, traces data flow, identifies key symbols.
|
description: Discovers and maps unfamiliar codebases. Reads architecture, traces data flow, identifies key symbols.
|
||||||
---
|
---
|
||||||
You map codebases. Start broad, then drill into specifics.
|
You map codebases. Start broad, then drill into specifics.
|
||||||
@@ -258,3 +258,67 @@ Output:
|
|||||||
- Data flow map (entry → transform → output)
|
- Data flow map (entry → transform → output)
|
||||||
- Conventions observed
|
- Conventions observed
|
||||||
- Areas that need deeper investigation
|
- 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)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ services:
|
|||||||
CONTAINER_GUIDANCE_FILE: /app/BOOCHAT.md
|
CONTAINER_GUIDANCE_FILE: /app/BOOCHAT.md
|
||||||
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boochat
|
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boochat
|
||||||
BOOCODER_URL: http://100.114.205.53:9502
|
BOOCODER_URL: http://100.114.205.53:9502
|
||||||
|
LLAMA_SIDECAR_URL: http://100.101.41.16:8402
|
||||||
volumes:
|
volumes:
|
||||||
- /opt:/opt
|
- /opt:/opt
|
||||||
- /opt/projects:/opt/projects:rw
|
- /opt/projects:/opt/projects:rw
|
||||||
|
|||||||
283
openspec/changes/v2-6-persistent-agent-sessions/design.md
Normal file
283
openspec/changes/v2-6-persistent-agent-sessions/design.md
Normal 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.
|
||||||
114
openspec/changes/v2-6-persistent-agent-sessions/proposal.md
Normal file
114
openspec/changes/v2-6-persistent-agent-sessions/proposal.md
Normal 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 |
|
||||||
94
openspec/changes/v2-6-persistent-agent-sessions/tasks.md
Normal file
94
openspec/changes/v2-6-persistent-agent-sessions/tasks.md
Normal 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.
|
||||||
Reference in New Issue
Block a user