Compare commits

..

32 Commits

Author SHA1 Message Date
12d91c9a12 v1.8.1: global agents + parser robustness + WS reconnect toast
Builtins move out of code into /data/AGENTS.md (always-on, mounted ro
into the container); per-project AGENTS.md is now an optional override.
agents.ts merges global + project entries with project-wins-by-name and
caches per-source mtimes (60s TTL). Parser switches to per-block
try/catch and returns AgentsResponse { agents, errors[] } so one
malformed block no longer fails the file. AgentPicker shows a
non-blocking amber chip listing skipped blocks and only fires a gray
toast when zero agents loaded.

WS reconnect UX (useUserEvents + useSessionStream) now silent on the
first disconnect; createWsReconnectToast escalates to gray after 3
failures or 15 s, then to red with a Retry Now action after 60 s.
useSessionStream also gained the exponential-backoff reconnect it was
missing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:16:02 +00:00
2bce4d85fa feat(mobile): v1.8 tab switcher + branch indicator + git_status tool
Mobile header is now two rows. Row 1: hamburger | project · branch
indicator (live via GET /api/projects/:id/git, 30s poll) | ModelPicker |
FolderTree. Row 2: pane-switcher pill (hand-rolled BottomSheet) +
NewPaneMenu. Chat-within-pane navigation hidden on mobile; users switch
panes via the sheet. Cross-tab status sync via chat_status frames
published from inference.ts at working/idle/error transitions; StatusDot
component renders amber-pulse/green/red/gray on each pane row and on
desktop ChatTabBar tabs. Level 1 git awareness exposes a read-only
git_status tool to the model, backed by services/git_meta.ts (execFile
+ 2s timeout + 30s cache). Workspace.tsx now receives panes/chats hooks
as props (hoisted into Session.tsx) so the header pill shares state
with the pane grid.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 20:06:51 +00:00
934f739ca1 Merge branch 'v1.7-drag-drop' 2026-05-16 15:35:07 +00:00
e9895fd694 Merge branch 'v1.6.3-mobile-root-nav' 2026-05-16 15:34:56 +00:00
83c7d33f3c Merge branch 'v1.6.5-session-rename-publish' 2026-05-16 15:34:47 +00:00
c3415574d6 Merge branch 'v1.6.4-auto-name-sessions' 2026-05-16 15:34:36 +00:00
50a756aca1 feat(input): drag-drop + paste-as-attachment for long text 2026-05-16 15:23:41 +00:00
3cb1ead5e2 feat(mobile): add hamburger + file explorer button to root empty state 2026-05-16 15:23:33 +00:00
5ee266a4d9 feat(auto_name): propagate first chat name to parent session
When a chat is auto-named, also rename the parent session if it is
still on its default 'New session' label. UPDATE is gated by an
atomic WHERE clause so user renames and prior propagations are not
clobbered. Publishes session_renamed via broker.publishUser; useSidebar
already listens.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 15:23:11 +00:00
c750ce9e62 fix(api): suppress no-op session_renamed publish on PATCH /api/sessions/:id
The v1.4 publisher fired whenever the PATCH body included `name`,
including no-op rename calls (PATCH { name } where name ===
currentName). Read the prior name with a fast SELECT before the
UPDATE and only publish session_renamed when the post-update name
actually differs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 15:20:03 +00:00
bbf9fac936 docs(roadmap): reconcile post-v1.6.1 + v1.6.2 in-flight
Update version summary: v1.6-mobile-pass and v1.6.1-cleanup are now
merged with SHAs; v1.6.2-mobile-ui-fixes added as in-flight with its
4-commit plan. v1.6.1-cleanup details rewritten to reflect what
actually shipped (B1) vs what was audited-only (secrets, panes,
unused exports, hand-rolled patterns, mount scope, etc.).

Closed two open items: session_renamed has a server publisher since
v1.4; PATCH /api/panes/:id is moot (endpoint never re-introduced).
Dependency graph updated with v1.6.2 node between v1.6.1 and v1.7.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 06:37:27 +00:00
6fa6eb7f32 feat(inference): raise MAX_TOOL_LOOP_DEPTH from 5 to 15
Allows assistant turns up to 15 tool calls in a single chain before
the loop-depth guard trips. Real chats commonly need 6-10 tool calls
(grep -> view_file -> view_file -> grep -> view_file -> answer); the
old cap of 5 was firing on legitimate investigation patterns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 06:37:27 +00:00
5932682193 feat(mobile): right-rail as drawer on mobile, header toggle button
Reverts v1.6.1's max-md:hidden wrapper around RightRail. On mobile,
RightRail now renders as a fixed right-side drawer (w-[85vw],
max-w-sm) toggled by a new FolderTree button in the Session header.

- New useRightRailDrawer hook mirrors useSidebarDrawer (Context +
  auto-close on route change).
- New MobileRightRailBackdrop component in App.tsx mirrors the
  existing MobileBackdrop for the left sidebar.
- RightRail computes an isOpen synthesis: on mobile, reads the
  drawer Context; on desktop, reads the persistent internal state.
  The existing tree-load effect and open_file_in_browser
  subscription share this plumbing via openRail / closeRail
  helpers.
- The desktop floating chevron handle is hidden on mobile (the
  Session header's FolderTree button replaces it).
- Session header gains a mobile-only FolderTree button after the
  ModelPicker, calling toggle() on the drawer Context.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 06:00:25 +00:00
943ae7df03 docs: add v1.x roadmap snapshot
Captures v1.0 through v1.6 history with status, decisions made,
schema additions, reusable patterns, tech stack, container topology,
and the dependency graph going forward through v1.11 (BooTerm).
Authored by Sam; v1.6 details lifted from the v1.6 hand-back.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 05:55:50 +00:00
4b5b9b2cb3 feat(mobile): pull-to-refresh sidebar list
- usePullToRefresh: hand-rolled hook. Records startY only when the
  scroll container is at scrollTop=0 to avoid hijacking mid-scroll
  pulls. Tracks downward delta on touchmove; fires onRefresh on
  touchend if delta >= 80px threshold. Holds the refreshing state for
  600ms minimum so the action feels intentional.
- ProjectSidebar: wires usePullToRefresh(() => retry()) on the nav
  element, mobile-only. A status indicator above the nav grows with
  pullDist (max 80px) and cycles 'Pull to refresh' -> 'Release to
  refresh' -> 'Refreshing...'. retry() is from useSidebar and refetches
  GET /api/sidebar.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 05:55:34 +00:00
cd897d6893 feat(mobile): single-pane stack + long-press tab menu + swipe-to-close
- Workspace: on mobile renders only panes[activePaneIdx] (rest skipped).
  When panes.length > 1, adds a horizontal pane-navigator strip built
  from SwipeablePaneTab above the active pane. URL state ?pane=<paneId>
  written by switchActivePane (user-initiated only) and read on URL
  change (back-button + deep-link). Bare URL resets activePaneIdx to 0.
- useLongPress: 500ms touchstart timer; on fire, dispatches a synthetic
  contextmenu event on target.closest('[data-tab-id]') so the existing
  Radix ContextMenuTrigger opens at the touch coordinates. Suppresses
  the synthetic click that follows touchend. Cancels on touchmove /
  touchend / touchcancel.
- ChatTabBar: each tab gets data-tab-id, touch handlers wired to
  useLongPress, and WebkitTouchCallout: 'none' to disable iOS Safari's
  text-selection callout.
- SwipeablePaneTab: tracks horizontal drag; bails if vertical delta
  exceeds 30px (so vertical scroll still works); past 60px on release
  fires onClose (removePane), else snaps back. Opacity fades 1->0.4
  approaching the threshold. Hand-rolled per spec.
- Pane drag-and-drop disabled on mobile (HTML5 drag is broken on touch
  anyway; mobile uses the navigator + swipe-to-close instead).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 05:55:05 +00:00
a643b5f67f feat(mobile): viewport hook + sidebar drawer + hamburger headers
- useViewport: matchMedia-based hook (no resize polling). Breakpoints
  mobile <768 / tablet 768-1023 / desktop >=1024. SSR-safe.
- useSidebarDrawer: Context provider with open/setOpen/toggle + auto-close
  on useLocation().pathname change.
- App.tsx: wraps SidebarDrawerProvider around AppShell, renders a
  MobileBackdrop (z-30) when the drawer is open on mobile.
- ProjectSidebar: aside is fixed/translate-x-full off-screen on mobile,
  slides in (z-40, 200ms transform) when drawerOpen. Inline column on
  desktop, unchanged.
- Session.tsx + Project.tsx: hamburger (Menu icon, >=44x44 min) on mobile
  opens the drawer. Headers gain paddingTop: max(0.75rem,
  env(safe-area-inset-top)) for notch devices. Home.tsx left alone
  (sidebar content duplicates the home page).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 05:54:33 +00:00
57c883b775 chore: fix resolveProjectPath whitelist-root bypass
The scope check at routes/projects.ts:56 short-circuited when
real === whitelistReal, allowing the whitelist directory itself to
resolve as a valid project root. Dropped the `real !== whitelistReal`
half of the && so the predicate becomes the strict prefix check.

Flipped the unit test from a "BEHAVIOR GAP" assertion (documenting
the bug) to a strict-rejection assertion. 23/23 tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 05:53:56 +00:00
4a9f207fe8 v1.5.1: bootstrap fixes (git + ssh in container, Tailscale host rewrite, /opt/projects label)
- Dockerfile: install git + openssh-client in runtime image; pre-populate
  /root/.ssh/known_hosts with the Tailscale ssh-keyscan for
  100.114.205.53:2222 (Gitea SSH). Without these, the bootstrap push
  step from inside the container fails with "command not found" or
  host-key prompts.
- docker-compose.yml: mount ./secrets/boocode_gitea as
  /root/.ssh/id_ed25519:ro so the container can authenticate to Gitea
  over SSH for the initial push.
- .gitignore: add secrets/ so the keypair never lands in the repo.
- project_bootstrap.ts: rewrite the Gitea-returned ssh_url's hostname
  from git.indifferentketchup.com to 100.114.205.53 before adding it
  as origin, so the push hits the Tailscale interface that the
  known_hosts entry covers.
- CreateProjectModal.tsx: preview label now reads "Folder:
  /opt/projects/<name>" to match the new BOOTSTRAP_ROOT (was /opt/).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 05:11:39 +00:00
782c2b183d feat: persistent context-window tracker in ChatPane
Adds a floating popover above the chat input showing current
context-window usage. Modeled on Paseo's tracker.

- New hook useChatContextStats(chatId, messages) finds the latest
  assistant message in the chat with both ctx_used and ctx_max set,
  computes percent, and returns null when data unavailable.
- New component ChatContextPopover renders a small card with the
  "Context window" label, big percent, and "used / max tokens"
  subline. Hidden when stats is null.
- Color thresholds: <60% muted, 60-85 amber, >85 destructive.
- Not a portal — absolutely positioned inside a new relative
  wrapper around ChatInput in ChatPane.tsx, so it's pane-local
  (multi-pane safe).
- Live updates via the existing messages-array dependency.
- No API / schema / WS changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 04:36:08 +00:00
7f0fd1281b security: scope /opt mount to /opt/projects
Splits the previous /opt:/opt:rw bind into two mounts to narrow the
writable scope of the container:

- /opt:/opt:ro — read-only mount for legacy/existing project
  add-existing flow. resolveProjectPath still uses
  PROJECT_ROOT_WHITELIST (/opt by default) so existing projects under
  /opt/<name> (analytics, boolab, boocode itself) continue to resolve
  and serve their file-tree via the read-only tools.
- /opt/projects:/opt/projects:rw — writable mount targeted at the
  create-new-project bootstrap path.

Picked Option B from the spec (simpler than two scan roots):
PROJECT_ROOT_WHITELIST stays /opt, new BOOTSTRAP_ROOT env var defaults
to /opt/projects and is used by project_bootstrap.ts as the mkdir
target. Bootstrap path-escape check now compares against
BOOTSTRAP_ROOT.

Prereq: host must `mkdir -p /opt/projects` before next container
restart. Documented in CLAUDE.md and .env.example.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 04:35:59 +00:00
2f6be39efd chore: surface swallowed errors + remove dead session_renamed paths
Swallowed-error logging (audit Feature 3):
- file_index.ts:36-37 (git mtime probes): comment — best-effort, project
  may not be a git repo.
- useUserEvents.ts:44 / 53 (ws.close on error / unmount): comments —
  best-effort, socket may already be closing.
- RightRail.tsx:38 (localStorage write): comment — best-effort, quota or
  private mode.
- App.tsx:21 (api.sessions.get for RightRail projectId): replaced silent
  catch with console.warn.
- Session.tsx:38, 41 (session fetch + project list for breadcrumb):
  replaced silent catches with console.warn.

H1: ProjectSidebar.tsx:189 — dropped the local sessionEvents.emit
({type:'session_renamed'}) after PATCH. Server publishes via
broker.publishUser since v1.4; useUserEvents forwards.

H2: useSessionStream.ts session_renamed case removed (dead — no
server code path publishes session_renamed on the per-session WS
channel; only user channel via broker.publishUser). Also dropped the
session_renamed variant from WsFrame (in apps/web/src/api/types.ts)
to keep the discriminated-union switch exhaustive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 04:35:49 +00:00
1ecb79476e test: vitest harness + unit tests for security-critical pure functions
Adds vitest 3.x (pinned to ^3 because vitest 4 requires Vite 6, while the
web app pins Vite 5). Tests live under src/**/__tests__/**.

Three target functions:
- sanitizeFolderName (project_bootstrap.ts): 8 cases covering happy path,
  path-traversal stripping, empty-after-sanitize, control chars, truncation
  at 64, null bytes, leading/trailing dot/slash stripping.
- resolveProjectPath (projects.ts): 7 cases including symlink-escape via
  realpath, outside-whitelist rejection, nonexistent path, AND a flagged
  BEHAVIOR GAP: passing the whitelist path itself currently returns success
  rather than erroring out (function early-exits the scope check when
  real === whitelistReal). Test asserts current behavior with explicit
  comment flagging the spec violation — function NOT silently patched.
  Function made exportable for testing (single keyword change).
- buildMessagesPayload (inference.ts): 8 cases for compact-marker logic
  (no marker, marker present, multiple compacts, tool-message position).

tsconfig.json excludes __tests__ + *.test.ts from emit so dist/ stays clean.

pnpm -C apps/server test => 23 passed in ~340ms.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 04:35:31 +00:00
9436a81b5f refactor: split FileBrowserPane / Workspace / runAssistantTurn
- FileBrowserPane.tsx: deleted (unreferenced post-v1.4 PaneTab.tsx removal;
  the legacy file_browser pane kind isn't part of the active WorkspacePane
  taxonomy).
- Workspace.tsx (524 -> 172 lines): extracted useWorkspacePanes(sessionId)
  and useSessionChats(sessionId) hooks. Workspace is layout-only composition
  now. localStorage key + WS frame handling + drag semantics unchanged.
- inference.ts runAssistantTurn (~265 -> 48 lines): bundled args into
  TurnArgs interface, extracted executeStreamPhase / executeToolPhase /
  finalizeCompletion / handleAbortOrError. All WS publish ordering preserved
  byte-for-byte (mentally traced for tool / non-tool / abort / error /
  depth-exceeded paths). flushPromise chain + setImmediate + signal
  propagation unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 04:35:20 +00:00
59fe6f0522 v1.4-fork-header: fork from message + delete message + header polish + housekeeping
- Fork: POST /api/chats/:id/fork creates a new chat in the same session,
  copies messages up to target (status=complete) with row-offset
  clock_timestamp() for stable ordering. Client emits open_chat_in_active_pane
  event; Workspace opens it in the active pane. No maybeAutoNameChat on forks.
- Delete: DELETE /api/chats/:id/messages/:message_id with 409 if the chat is
  currently streaming. Cascading-forward delete (created_at >= target).
  MessageBubble Trash button + confirm Dialog.
- Header: Projects -> Project -> Session breadcrumb, model badge pill,
  inline session rename, active file path via new useActivePane() hook.
  Server now publishes session_renamed on PATCH /api/sessions/:id;
  client-side dup emit removed from Session.tsx.
- Housekeeping: NOW() -> clock_timestamp() in schema.sql defaults, dead
  PaneTab.tsx and panes/PaneShell.tsx removed, session_panes backfill
  INSERT removed (CREATE TABLE retained), Tailnet trust comment near
  app.listen().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 04:12:01 +00:00
eabef7671e docs: CLAUDE.md updates from v1.3 audit — Fastify empty-body parser, event dedup discipline, CHECK migration order, deploy one-liner, stale pane refs cleaned
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 03:44:58 +00:00
72 changed files with 5240 additions and 1829 deletions

View File

@@ -3,5 +3,6 @@ PORT=3000
DATABASE_URL=postgres://boocode:CHANGE_ME@boocode_db:5432/boocode
LLAMA_SWAP_URL=http://100.101.41.16:8401
PROJECT_ROOT_WHITELIST=/opt
BOOTSTRAP_ROOT=/opt/projects
DEFAULT_MODEL=qwen3.6-35b-a3b-mxfp4
POSTGRES_PASSWORD=CHANGE_ME

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@ dist
.DS_Store
.vite
coverage
secrets/

197
AGENTS.md Normal file
View File

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

View File

@@ -31,7 +31,7 @@ npx tsc -p apps/web/tsconfig.app.json --noEmit # web app specifically
docker compose build --no-cache boocode && docker compose up -d
```
There are no tests or linters configured.
Tests: `pnpm -C apps/server test` runs 23 vitest tests. No test harness on `apps/web` (adding it requires installing vitest as a new devDep). Vitest pinned to `^3` because Vite 5 / vitest 4 are incompatible. No linters configured.
## Architecture
@@ -44,7 +44,7 @@ There are no tests or linters configured.
- **Zod** for request validation and config parsing.
Key services:
- **`services/inference.ts`** — Streams LLM responses, executes tool loops (max 5 depth), flushes to DB every 500ms. Publishes `InferenceFrame` events through the broker.
- **`services/inference.ts`** — Streams LLM responses, executes tool loops (max depth 15, see `MAX_TOOL_LOOP_DEPTH`), flushes to DB every 500ms. Publishes `InferenceFrame` events through the broker.
- **`services/broker.ts`** — In-memory pub/sub with two channel types: per-session (message streaming) and per-user (sidebar updates). No persistence; clients reconnect on restart.
- **`services/tools.ts`** — Four read-only file tools exposed as OpenAI function-calling schemas. All file access goes through `path_guard.ts` which resolves against project root.
- **`services/file_ops.ts`** — Shared file operation implementations used by both inference tools and HTTP routes.
@@ -57,13 +57,13 @@ Route registration: all routes registered in `index.ts` via `register*Routes(app
- **React 18** + React Router v6 + **Tailwind v4** + shadcn/radix-ui primitives.
- **Shiki** for syntax highlighting (async `codeToHtml` in `CodeBlock.tsx` and `FileViewer` in `FileBrowserPane.tsx`).
- Path alias: `@/` maps to `src/`.
- **Mobile interaction primitives** (post-v1.6): `useViewport` (matchMedia, breakpoints mobile <768 / tablet 7681023 / desktop ≥1024), `useSidebarDrawer` / `useRightRailDrawer` (Context + auto-close on `useLocation().pathname` change), `useLongPress` (500ms timer, dispatches synthetic `contextmenu` on `[data-tab-id]`), `usePullToRefresh` (80px threshold, 600ms hold), `SwipeablePaneTab` (60px close, 30px vertical bail). Tap-target convention: `max-md:min-h-[44px] max-md:min-w-[44px]`. Mobile headers: `border-b px-3 sm:px-4 py-2` + `style={{ paddingTop: 'max(0.5rem, env(safe-area-inset-top))' }}`. Hamburger left, FolderTree right.
Key patterns:
- **`hooks/sessionEvents.ts`** — Module-singleton event bus (Set of listeners). Used for cross-component communication: session renames, file-open events, attachment dispatch. 9 event types in the discriminated union. When adding a new event type to the `SessionEvent` union, you must also add a case to the `applyEvent` switch in `useSidebar.ts` (even if it's a no-op `return prev`).
- **`hooks/useSessionStream.ts`** — WebSocket per session, `applyFrame` reducer builds message list from streaming frames.
- **`hooks/useUserEvents.ts`** — Single app-level WS to `/api/ws/user` with exponential backoff reconnect. Forwards frames onto the sessionEvents bus.
- **`hooks/usePanes.ts`** — Per-session pane CRUD with 300ms debounced state PATCH (Map-based coalescing for last-write-wins).
- **`hooks/useSidebar.ts`** — Module-singleton with Set<setState> subscriber pattern. Handles all sessionEvent types to keep sidebar in sync.
- **`hooks/useSidebar.ts`** — Module-singleton with Set<setState> subscriber pattern; one bus subscription guarded by `globalThis.__boocode_sidebar_subscribed` for HMR safety. Every new `SessionEvent` type needs a `case` in the `applyEvent` switch (no-op `return prev` is fine).
- **`api/client.ts`** — Centralized typed fetch wrapper. All endpoints under `api.*` namespace.
### Data flow for chat
@@ -77,23 +77,28 @@ Key patterns:
### Multi-pane workspace
Sessions hold 15 panes (chat or file_browser). `Workspace.tsx` renders tab strip + CSS grid layout. Pane state persisted in `session_panes` table (position + JSONB state). Tab reorder via native HTML5 drag events.
Sessions hold 15 panes (chat / empty / placeholder terminal+agent). Workspace pane state is **client-side only** (localStorage key `boocode.workspace.panes.<sessionId>`); the legacy `session_panes` table and its REST endpoints are deprecated — no `/api/panes/*` routes exist. Each chat lives in at most one pane; tab strip is per-pane and tracks `chatIds[]` + `activeChatIdx`. Sessions 1:N chats; chats own messages. Tab reorder via native HTML5 drag events.
## Database
PostgreSQL 16. Tables: `projects`, `sessions`, `messages`, `settings`, `session_panes`. Schema applied idempotently on startup via `applySchema()`. Use `clock_timestamp()` (not `NOW()`) inside transactions for accurate per-statement timestamps.
PostgreSQL 16. Tables: `projects`, `sessions`, `chats`, `messages`, `settings`, `session_panes` (deprecated). Schema applied idempotently on startup via `applySchema()`. Use `clock_timestamp()` (not `NOW()`) inside transactions. CHECK constraints in place: `projects_status_chk` ('open'|'archived'), `sessions_status_chk` (same), `chats_status_chk` (same), `messages_role_chk`, `messages_status_chk` — keep in sync with the `*_STATUSES` const arrays in `apps/server/src/types/api.ts`.
Position-shift pattern for panes: negate-and-restore to avoid UNIQUE(session_id, position) collisions during reorder/insert/delete. Sentinel value -100 for the moving pane.
Schema CHECK migration order when renaming allowed values: (1) `ALTER TABLE ... DROP CONSTRAINT IF EXISTS <system_name>` (inline `CREATE TABLE` checks get `<table>_<column>_check`), (2) `UPDATE` rows to new values, (3) wrap new constraint ADD in `DO $$ ... pg_constraint` guard — that block is the only way to get `ADD CONSTRAINT IF NOT EXISTS`.
Position-shift pattern for panes (legacy `session_panes` table): negate-and-restore to avoid UNIQUE(session_id, position) collisions during reorder/insert/delete. Sentinel value -100 for the moving pane.
## Environment
Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0.0.0), `PROJECT_ROOT_WHITELIST` (/opt), `DEFAULT_MODEL`, `LOG_LEVEL`.
Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0.0.0), `PROJECT_ROOT_WHITELIST` (/opt, read-only scope for add-existing path resolution), `BOOTSTRAP_ROOT` (/opt/projects, writable scope for create-new-project bootstrap mkdir target — host must `mkdir -p /opt/projects` before container start), `DEFAULT_MODEL`, `LOG_LEVEL`.
## Workflow
- Sam reviews all diffs and commits manually. Do not commit unless explicitly asked.
- Deploy: `cd /opt/boocode && docker compose build --no-cache boocode && docker compose up -d`
- Deploy: `cd /opt/boocode && docker compose up --build -d` (or `docker compose build --no-cache boocode && docker compose up -d` if you suspect a layer-cache issue).
- Git push to Gitea: `GIT_SSH_COMMAND="ssh -i /opt/boocode/secrets/boocode_gitea -o IdentitiesOnly=yes" git push origin <branch>`. The default agent identity is rejected; the in-repo deploy key (`secrets/`, gitignored) is the working one. Transient `Connection reset by peer` retries cleanly after `sleep 5`.
- Don't accumulate `.bak-*` files. Clean them up in the same batch or immediately after merge.
- Fastify global JSON parser tolerates empty bodies (overridden in `index.ts`); bodyless POSTs (archive, unarchive, stop) work without setting `Content-Type` tricks on the client.
- Event dedup discipline: for any mutation the server publishes via `broker.publishUser`, do NOT add a local `sessionEvents.emit(...)` after the API call — `useUserEvents` forwards the WS frame onto the bus. Frontend mutation handlers must be idempotent (dedup by id, no-op on already-present).
## Conventions

View File

@@ -19,7 +19,8 @@ RUN pnpm deploy --filter=@boocode/server --prod --legacy /out/server
FROM node:20-alpine AS runtime
RUN apk add --no-cache ripgrep
RUN apk add --no-cache ripgrep git openssh-client
RUN mkdir -p /root/.ssh && ssh-keyscan -p 2222 -H 100.114.205.53 git.indifferentketchup.com >> /root/.ssh/known_hosts && chmod 700 /root/.ssh && chmod 600 /root/.ssh/known_hosts
WORKDIR /app
COPY --from=builder /out/server ./

View File

@@ -7,7 +7,8 @@
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc && node -e \"import('node:fs').then(fs=>fs.copyFileSync('src/schema.sql','dist/schema.sql'))\"",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@fastify/static": "^7.0.4",
@@ -21,6 +22,7 @@
"@types/node": "^20.14.10",
"@types/ws": "^8.5.10",
"tsx": "^4.16.2",
"typescript": "^5.5.0"
"typescript": "^5.5.0",
"vitest": "^3.2.4"
}
}

View File

@@ -7,6 +7,7 @@ const ConfigSchema = z.object({
DATABASE_URL: z.string().url(),
LLAMA_SWAP_URL: z.string().url(),
PROJECT_ROOT_WHITELIST: z.string().default('/opt'),
BOOTSTRAP_ROOT: z.string().default('/opt/projects'),
DEFAULT_MODEL: z.string().default('qwen3.6-35b-a3b-mxfp4'),
LOG_LEVEL: z.string().default('info'),
GITEA_BASE_URL: z.string().url().default('https://git.indifferentketchup.com'),

View File

@@ -14,6 +14,7 @@ import { registerChatRoutes } from './routes/chats.js';
import { registerSidebarRoutes } from './routes/sidebar.js';
import { registerWebSocket } from './routes/ws.js';
import { registerModelRoutes } from './routes/models.js';
import { registerAgentRoutes } from './routes/agents.js';
import { createInferenceRunner } from './services/inference.js';
import { createBroker } from './services/broker.js';
@@ -57,6 +58,7 @@ async function main() {
registerSessionRoutes(app, sql, config, broker);
registerSettingsRoutes(app, sql);
registerModelRoutes(app, config);
registerAgentRoutes(app, sql);
registerSidebarRoutes(app, sql);
registerChatRoutes(app, sql, broker);
@@ -83,6 +85,7 @@ async function main() {
cancelInference: async (sessionId, chatId) => {
return inference.cancel(sessionId, chatId);
},
hasActiveInference: (chatId) => inference.hasActive(chatId),
publishUserMessage: (sessionId, chatId, userMessageId, content) => {
broker.publish(sessionId, {
type: 'message_started',
@@ -144,6 +147,9 @@ async function main() {
process.on('SIGINT', () => void shutdown('SIGINT'));
process.on('SIGTERM', () => void shutdown('SIGTERM'));
// Bound to 0.0.0.0 intentionally. Public access goes through Caddy → Authelia.
// Direct Tailscale access (100.114.205.53:9500) is unauthenticated by design;
// the threat model treats Tailnet membership as the trust boundary.
await app.listen({ port: config.PORT, host: config.HOST });
app.log.info(`boocode server listening on http://${config.HOST}:${config.PORT}`);
}

View File

@@ -0,0 +1,92 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { mkdtemp, mkdir, rm, realpath, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { resolveProjectPath } from '../projects.js';
describe('resolveProjectPath', () => {
let scratch: string;
let whitelist: string;
let outside: string;
beforeAll(async () => {
// mkdtemp returns a real path on most platforms, but symlink-resolve it
// anyway to defeat /tmp -> /private/tmp style indirection on macOS, and
// keep this stable for the comparisons below.
scratch = await realpath(await mkdtemp(join(tmpdir(), 'boocode-projects-test-')));
whitelist = join(scratch, 'wl');
outside = join(scratch, 'other');
await mkdir(whitelist, { recursive: true });
await mkdir(outside, { recursive: true });
});
afterAll(async () => {
if (scratch) {
await rm(scratch, { recursive: true, force: true });
}
});
it('returns real path and basename for a valid subdirectory under the whitelist', async () => {
const projectDir = join(whitelist, 'my-project');
await mkdir(projectDir);
const result = await resolveProjectPath(projectDir, whitelist);
expect('error' in result).toBe(false);
if ('error' in result) return; // narrow
expect(result.real).toBe(projectDir);
expect(result.name).toBe('my-project');
});
it('rejects a path outside the whitelist', async () => {
const projectDir = join(outside, 'foo');
await mkdir(projectDir);
const result = await resolveProjectPath(projectDir, whitelist);
expect('error' in result).toBe(true);
if (!('error' in result)) return;
expect(result.error.toLowerCase()).toContain('path must be under');
});
it('rejects relative paths', async () => {
const result = await resolveProjectPath('relative/path', whitelist);
expect('error' in result).toBe(true);
if (!('error' in result)) return;
expect(result.error.toLowerCase()).toContain('absolute');
});
it('rejects nonexistent paths', async () => {
const result = await resolveProjectPath('/nonexistent/foo-boocode-test', whitelist);
expect('error' in result).toBe(true);
if (!('error' in result)) return;
expect(result.error.toLowerCase()).toContain('does not exist');
});
it('rejects symlink escapes (realpath resolution catches traversal)', async () => {
// Create a symlink INSIDE the whitelist that points OUTSIDE. realpath
// should resolve through it and the resulting real path should fail the
// whitelist scope check.
const escapeLink = join(whitelist, 'escape-link');
await symlink(outside, escapeLink);
const result = await resolveProjectPath(escapeLink, whitelist);
expect('error' in result).toBe(true);
if (!('error' in result)) return;
expect(result.error.toLowerCase()).toContain('path must be under');
});
it('rejects the whitelist directory itself as a project root', async () => {
// A project's parent can't be the project. The scope check must require
// the candidate path to be strictly below the whitelist (whitelist + sep
// prefix), not just equal to it.
const result = await resolveProjectPath(whitelist, whitelist);
expect('error' in result).toBe(true);
if (!('error' in result)) return;
expect(result.error.toLowerCase()).toContain('path must be under');
});
it('rejects non-directory targets (file under whitelist)', async () => {
const filePath = join(whitelist, 'a-file.txt');
await writeFile(filePath, 'content', 'utf8');
const result = await resolveProjectPath(filePath, whitelist);
expect('error' in result).toBe(true);
if (!('error' in result)) return;
expect(result.error.toLowerCase()).toContain('not a directory');
});
});

View File

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

View File

@@ -12,6 +12,11 @@ const PatchBody = z.object({
name: z.string().min(1).max(200),
});
const ForkBody = z.object({
message_id: z.string().uuid(),
name: z.string().min(1).max(200).optional(),
});
export function registerChatRoutes(
app: FastifyInstance,
sql: Sql,
@@ -181,6 +186,78 @@ export function registerChatRoutes(
}
);
app.post<{ Params: { id: string } }>(
'/api/chats/:id/fork',
async (req, reply) => {
const parsed = ForkBody.safeParse(req.body ?? {});
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const sourceRows = await sql<Chat[]>`
SELECT id, session_id, name, status, created_at, updated_at
FROM chats WHERE id = ${req.params.id}
`;
if (sourceRows.length === 0) {
reply.code(404);
return { error: 'chat not found' };
}
const source = sourceRows[0]!;
const targetRows = await sql<{ created_at: string; status: string }[]>`
SELECT created_at, status FROM messages
WHERE chat_id = ${source.id} AND id = ${parsed.data.message_id}
`;
if (targetRows.length === 0) {
reply.code(404);
return { error: 'message not found in chat' };
}
const target = targetRows[0]!;
if (target.status !== 'complete') {
reply.code(400);
return { error: 'can only fork from completed messages' };
}
const newName = parsed.data.name ?? `${source.name ?? 'Chat'} (fork)`;
const newChat = await sql.begin(async (tx) => {
const [chat] = await tx<Chat[]>`
INSERT INTO chats (session_id, name, status)
VALUES (${source.session_id}, ${newName}, 'open')
RETURNING id, session_id, name, status, created_at, updated_at
`;
await tx`
INSERT INTO messages (
session_id, chat_id, role, content, kind, tool_calls, tool_results,
status, tokens_used, ctx_used, ctx_max, started_at, finished_at,
created_at
)
SELECT
${source.session_id}, ${chat!.id}, role, content, kind,
tool_calls, tool_results, status,
tokens_used, ctx_used, ctx_max, started_at, finished_at,
clock_timestamp() + (
ROW_NUMBER() OVER (ORDER BY created_at ASC, id ASC) * INTERVAL '1 microsecond'
)
FROM messages
WHERE chat_id = ${source.id}
AND created_at <= ${target.created_at}::timestamptz
AND status = 'complete'
`;
return chat!;
});
broker.publishUser('default', {
type: 'chat_created',
chat: newChat,
session_id: source.session_id,
});
reply.code(201);
return newChat;
}
);
app.get<{ Params: { id: string } }>(
'/api/chats/:id/messages',
async (req, reply) => {

View File

@@ -18,6 +18,7 @@ interface MessageHandlers {
) => void;
publishMessagesDeleted: (sessionId: string, chatId: string, messageIds: string[]) => void;
cancelInference: (sessionId: string, chatId: string) => Promise<boolean>;
hasActiveInference: (chatId: string) => boolean;
}
export function registerMessageRoutes(
@@ -156,6 +157,53 @@ export function registerMessageRoutes(
}
);
app.delete<{ Params: { id: string; message_id: string } }>(
'/api/chats/:id/messages/:message_id',
async (req, reply) => {
const { id: chatId, message_id: messageId } = req.params;
const chatRows = await sql<Chat[]>`
SELECT id, session_id FROM chats WHERE id = ${chatId}
`;
if (chatRows.length === 0) {
reply.code(404);
return { error: 'chat not found' };
}
const chat = chatRows[0]!;
if (handlers.hasActiveInference(chatId)) {
reply.code(409);
return { error: 'chat is currently streaming; stop it first' };
}
const deletedIds = await sql.begin(async (tx) => {
const deletedRows = await tx<{ id: string }[]>`
DELETE FROM messages
WHERE chat_id = ${chatId}
AND created_at >= (
SELECT created_at FROM messages
WHERE id = ${messageId} AND chat_id = ${chatId}
)
RETURNING id
`;
if (deletedRows.length > 0) {
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`;
}
return deletedRows.map((r) => r.id);
});
if (deletedIds.length === 0) {
reply.code(404);
return { error: 'message not found' };
}
handlers.publishMessagesDeleted(chat.session_id, chatId, deletedIds);
reply.code(204);
return null;
}
);
app.post<{ Params: { id: string } }>(
'/api/chats/:id/compact',
async (req, reply) => {

View File

@@ -9,6 +9,7 @@ import type { Project, AvailableProject } from '../types/api.js';
import { resolveProjectRoot, PathScopeError } from '../services/path_guard.js';
import { listDir, viewFile } from '../services/file_ops.js';
import { getProjectFiles } from '../services/file_index.js';
import { getGitMeta } from '../services/git_meta.js';
import {
bootstrapProject,
BootstrapNameError,
@@ -41,7 +42,7 @@ async function isDir(path: string): Promise<boolean> {
}
}
async function resolveProjectPath(
export async function resolveProjectPath(
raw: string,
whitelist: string
): Promise<{ real: string; name: string } | { error: string }> {
@@ -53,7 +54,7 @@ async function resolveProjectPath(
return { error: 'path does not exist' };
}
const whitelistReal = await realpath(whitelist);
if (real !== whitelistReal && !real.startsWith(whitelistReal + sep)) {
if (!real.startsWith(whitelistReal + sep)) {
return { error: `path must be under ${whitelist}` };
}
if (!(await isDir(real))) return { error: 'path is not a directory' };
@@ -381,6 +382,38 @@ export function registerProjectRoutes(
}
);
// GET /api/projects/:id/git
// v1.8 mobile-tabs: feeds the header branch indicator and is the same
// resolver the model's git_status tool uses. Returns 200 with branch=null
// for non-git directories (not 404) so the UI can degrade gracefully.
app.get<{ Params: { id: string } }>(
'/api/projects/:id/git',
async (req, reply) => {
const { id } = req.params;
const rows = await sql<Project[]>`
SELECT id, name, path, added_at, last_session_id, status, gitea_remote
FROM projects WHERE id = ${id}
`;
if (rows.length === 0) {
reply.code(404);
return { error: 'not found' };
}
const project = rows[0]!;
let projectRoot: string;
try {
projectRoot = await resolveProjectRoot(project.path);
} catch (err) {
if (err instanceof PathScopeError) {
reply.code(404);
return { error: err.message };
}
throw err;
}
const meta = await getGitMeta(projectRoot);
return meta ?? { branch: null, is_dirty: false, ahead: 0, behind: 0 };
}
);
// GET /api/projects/:id/files
app.get<{ Params: { id: string } }>(
'/api/projects/:id/files',

View File

@@ -5,17 +5,20 @@ import type { Config } from '../config.js';
import type { Broker } from '../services/broker.js';
import type { Session } from '../types/api.js';
import { getSetting } from './settings.js';
import { getAgentsForProject } from '../services/agents.js';
const CreateBody = z.object({
name: z.string().min(1).max(200).optional(),
model: z.string().min(1).max(200).optional(),
system_prompt: z.string().max(8000).optional(),
agent_id: z.string().min(1).max(200).nullable().optional(),
});
const PatchBody = z.object({
name: z.string().min(1).max(200).optional(),
model: z.string().min(1).max(200).optional(),
system_prompt: z.string().max(8000).optional(),
agent_id: z.string().min(1).max(200).nullable().optional(),
});
async function resolveDefaultModel(sql: Sql, config: Config): Promise<string> {
@@ -24,6 +27,13 @@ async function resolveDefaultModel(sql: Sql, config: Config): Promise<string> {
return config.DEFAULT_MODEL;
}
// First agent in the project's effective list (file-defined or builtin),
// or null if somehow none exist.
async function resolveDefaultAgent(projectPath: string): Promise<string | null> {
const { agents } = await getAgentsForProject(projectPath);
return agents[0]?.id ?? null;
}
export function registerSessionRoutes(
app: FastifyInstance,
sql: Sql,
@@ -40,7 +50,7 @@ export function registerSessionRoutes(
}
const status = req.query.status === 'archived' ? 'archived' : 'open';
const rows = await sql<Session[]>`
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id
FROM sessions
WHERE project_id = ${req.params.id} AND status = ${status}
ORDER BY updated_at DESC
@@ -57,11 +67,14 @@ export function registerSessionRoutes(
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const project = await sql`SELECT id FROM projects WHERE id = ${req.params.id}`;
const project = await sql<{ id: string; path: string }[]>`
SELECT id, path FROM projects WHERE id = ${req.params.id}
`;
if (project.length === 0) {
reply.code(404);
return { error: 'project not found' };
}
const projectPath = project[0]!.path;
let model = parsed.data.model;
if (!model) {
@@ -76,12 +89,18 @@ export function registerSessionRoutes(
const name = parsed.data.name ?? 'New session';
const systemPrompt = parsed.data.system_prompt ?? '';
// If the client provided agent_id (string or null), use it; otherwise
// resolve to the project's first agent (file-defined or builtin), or null.
const agentId =
parsed.data.agent_id !== undefined
? parsed.data.agent_id
: await resolveDefaultAgent(projectPath);
const row = await sql.begin(async (tx) => {
const [session] = await tx<Session[]>`
INSERT INTO sessions (project_id, name, model, system_prompt)
VALUES (${req.params.id}, ${name}, ${model}, ${systemPrompt})
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at
INSERT INTO sessions (project_id, name, model, system_prompt, agent_id)
VALUES (${req.params.id}, ${name}, ${model}, ${systemPrompt}, ${agentId})
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id
`;
await tx`
INSERT INTO chats (session_id, name, status)
@@ -101,7 +120,7 @@ export function registerSessionRoutes(
app.get<{ Params: { id: string } }>('/api/sessions/:id', async (req, reply) => {
const rows = await sql<Session[]>`
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id
FROM sessions WHERE id = ${req.params.id}
`;
if (rows.length === 0) {
@@ -120,21 +139,43 @@ export function registerSessionRoutes(
return { error: 'invalid body', details: parsed.error.flatten() };
}
const { name, model, system_prompt } = parsed.data;
// agent_id is tri-state on the wire: omitted = no change, null = clear,
// string = set. CASE WHEN inside SET handles all three atomically.
const agentIdProvided = parsed.data.agent_id !== undefined;
const newAgentId = parsed.data.agent_id ?? null;
// Read the prior name so the post-update publish can skip no-op renames
// (PATCH { name: "Foo" } where the session is already "Foo"). The window
// between SELECT and UPDATE is sub-millisecond in the same request handler;
// a concurrent rename in that gap would just mean one stale publish, which
// existing clients dedup by id.
const before = await sql<{ name: string }[]>`
SELECT name FROM sessions WHERE id = ${req.params.id}
`;
const priorName = before[0]?.name;
const rows = await sql<Session[]>`
UPDATE sessions
SET
name = COALESCE(${name ?? null}, name),
model = COALESCE(${model ?? null}, model),
system_prompt = COALESCE(${system_prompt ?? null}, system_prompt),
agent_id = CASE WHEN ${agentIdProvided} THEN ${newAgentId} ELSE agent_id END,
updated_at = clock_timestamp()
WHERE id = ${req.params.id}
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id
`;
if (rows.length === 0) {
reply.code(404);
return { error: 'session not found' };
}
return rows[0];
const session = rows[0]!;
if (name !== undefined && session.name !== priorName) {
broker.publishUser('default', {
type: 'session_renamed',
session_id: session.id,
name: session.name,
});
}
return session;
}
);
@@ -166,7 +207,7 @@ export function registerSessionRoutes(
const rows = await sql<Session[]>`
UPDATE sessions SET status = 'open', updated_at = clock_timestamp()
WHERE id = ${req.params.id} AND status = 'archived'
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id
`;
if (rows.length === 0) {
reply.code(404);

View File

@@ -2,7 +2,7 @@ CREATE TABLE IF NOT EXISTS projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
path TEXT NOT NULL UNIQUE,
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
added_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
last_session_id UUID
);
@@ -12,8 +12,8 @@ CREATE TABLE IF NOT EXISTS sessions (
name TEXT NOT NULL,
model TEXT NOT NULL,
system_prompt TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
);
CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_id, updated_at DESC);
@@ -27,7 +27,7 @@ CREATE TABLE IF NOT EXISTS messages (
tool_results JSONB,
status TEXT NOT NULL DEFAULT 'complete',
last_seq INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
);
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at);
@@ -60,14 +60,9 @@ CREATE TABLE IF NOT EXISTS session_panes (
);
CREATE INDEX IF NOT EXISTS idx_session_panes_session ON session_panes (session_id);
-- Backfill: ensure every session has at least one pane (default Chat).
-- Idempotent: skipped on subsequent runs because session_panes rows already exist.
INSERT INTO session_panes (session_id, position, kind, state)
SELECT s.id, 0, 'chat', '{}'::jsonb
FROM sessions s
WHERE NOT EXISTS (
SELECT 1 FROM session_panes p WHERE p.session_id = s.id
);
-- v1.4: backfill removed. Pane layout is client-side (localStorage) since v1.2-batch4.
-- The CREATE TABLE above is retained for additive-schema discipline; drop is a
-- future destructive migration.
-- v1.2: sessions.status (open | archived)
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'open';
@@ -158,3 +153,8 @@ BEGIN
CHECK (status IN ('open', 'archived'));
END IF;
END $$;
-- v1.x-batch9: per-session agent reference. Agent definitions are not stored in
-- the DB; they live in builtins (services/agents.ts) and a per-project AGENTS.md.
-- agent_id is the slugified agent name. NULL means "use BooCode defaults".
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS agent_id TEXT;

View File

@@ -0,0 +1,237 @@
import { describe, it, expect } from 'vitest';
import { buildMessagesPayload } from '../inference.js';
import type {
Message,
MessageRole,
Project,
Session,
ToolCall,
ToolResult,
} from '../../types/api.js';
// ---- fixtures ---------------------------------------------------------------
function makeSession(overrides: Partial<Session> = {}): Session {
return {
id: 'sess',
project_id: 'proj',
name: 'test session',
model: 'test-model',
system_prompt: '',
status: 'open',
created_at: new Date(0).toISOString(),
updated_at: new Date(0).toISOString(),
...overrides,
};
}
function makeProject(overrides: Partial<Project> = {}): Project {
return {
id: 'proj',
name: 'test project',
path: '/tmp/proj',
added_at: new Date(0).toISOString(),
last_session_id: null,
status: 'open',
gitea_remote: null,
...overrides,
};
}
let counter = 0;
function makeMessage(
role: MessageRole,
content: string,
overrides: Partial<Message> = {}
): Message {
counter += 1;
return {
id: `m${counter}`,
session_id: 'sess',
chat_id: 'chat',
role,
content,
kind: 'message',
tool_calls: null,
tool_results: null,
status: 'complete',
last_seq: 0,
tokens_used: null,
ctx_used: null,
ctx_max: null,
started_at: null,
finished_at: null,
created_at: new Date(counter * 1000).toISOString(),
...overrides,
};
}
// ---- tests ------------------------------------------------------------------
describe('buildMessagesPayload', () => {
it('prepends a system prompt containing the project path', () => {
const session = makeSession();
const project = makeProject({ path: '/tmp/my-proj' });
const result = buildMessagesPayload(session, project, []);
expect(result).toHaveLength(1);
expect(result[0]!.role).toBe('system');
expect(result[0]!.content).toContain('/tmp/my-proj');
});
it('appends session.system_prompt to the system message when set', () => {
const session = makeSession({ system_prompt: 'Be terse.' });
const project = makeProject();
const result = buildMessagesPayload(session, project, []);
expect(result).toHaveLength(1);
expect(result[0]!.role).toBe('system');
expect(result[0]!.content).toContain('Be terse.');
});
it('returns user/assistant messages in order when no compact marker is present', () => {
const session = makeSession();
const project = makeProject();
const history: Message[] = [
makeMessage('user', 'hi'),
makeMessage('assistant', 'hello'),
makeMessage('user', 'how are you'),
makeMessage('assistant', 'great'),
];
const result = buildMessagesPayload(session, project, history);
// 1 system + 4 history messages
expect(result).toHaveLength(5);
expect(result[0]!.role).toBe('system');
expect(result[1]).toMatchObject({ role: 'user', content: 'hi' });
expect(result[2]).toMatchObject({ role: 'assistant', content: 'hello' });
expect(result[3]).toMatchObject({ role: 'user', content: 'how are you' });
expect(result[4]).toMatchObject({ role: 'assistant', content: 'great' });
});
it('starts from the latest compact marker, emitting it as a system message', () => {
const session = makeSession();
const project = makeProject();
const history: Message[] = [
makeMessage('user', 'old1'),
makeMessage('assistant', 'oldreply1'),
makeMessage('user', 'old2'),
makeMessage('assistant', 'compacted summary text', { kind: 'compact' }),
makeMessage('user', 'new1'),
makeMessage('assistant', 'newreply1'),
];
const result = buildMessagesPayload(session, project, history);
// Expect: leading base-system prompt, then the compact as system, then
// the user/assistant pair following it.
expect(result).toHaveLength(4);
expect(result[0]!.role).toBe('system');
expect(result[1]).toMatchObject({
role: 'system',
content: 'compacted summary text',
});
expect(result[2]).toMatchObject({ role: 'user', content: 'new1' });
expect(result[3]).toMatchObject({ role: 'assistant', content: 'newreply1' });
});
it('uses only the most recent compact when multiple are present', () => {
const session = makeSession();
const project = makeProject();
const history: Message[] = [
makeMessage('user', 'u1'),
makeMessage('assistant', 'first compact summary', { kind: 'compact' }),
makeMessage('user', 'u2'),
makeMessage('assistant', 'second compact summary', { kind: 'compact' }),
makeMessage('user', 'u3'),
makeMessage('assistant', 'final reply'),
];
const result = buildMessagesPayload(session, project, history);
// Expect: base system + latest compact as system + the two messages
// following it. The earlier compact and pre-compact history are dropped.
expect(result).toHaveLength(4);
expect(result[0]!.role).toBe('system');
expect(result[1]).toMatchObject({
role: 'system',
content: 'second compact summary',
});
expect(result[2]).toMatchObject({ role: 'user', content: 'u3' });
expect(result[3]).toMatchObject({ role: 'assistant', content: 'final reply' });
// None of the earlier content should leak through
const concatenated = result.map((m) => m.content ?? '').join(' ');
expect(concatenated).not.toContain('first compact summary');
expect(concatenated).not.toContain('u1');
expect(concatenated).not.toContain('u2');
});
it('skips streaming and cancelled assistant rows', () => {
const session = makeSession();
const project = makeProject();
const history: Message[] = [
makeMessage('user', 'hi'),
makeMessage('assistant', 'partial...', { status: 'streaming' }),
makeMessage('assistant', 'cancelled fragment', { status: 'cancelled' }),
makeMessage('assistant', 'final answer'),
];
const result = buildMessagesPayload(session, project, history);
// 1 system + 1 user + 1 assistant (only the complete one)
expect(result).toHaveLength(3);
expect(result[1]).toMatchObject({ role: 'user', content: 'hi' });
expect(result[2]).toMatchObject({ role: 'assistant', content: 'final answer' });
});
it('round-trips an assistant-with-tool_calls followed by its tool result', () => {
const session = makeSession();
const project = makeProject();
const toolCall: ToolCall = {
id: 'call_abc',
name: 'view_file',
args: { path: 'src/index.ts' },
};
const toolResult: ToolResult = {
tool_call_id: 'call_abc',
output: { contents: 'console.log(1)' },
truncated: false,
};
const history: Message[] = [
makeMessage('user', 'show me the file'),
makeMessage('assistant', '', { tool_calls: [toolCall] }),
makeMessage('tool', '', { tool_results: toolResult }),
makeMessage('assistant', 'here it is'),
];
const result = buildMessagesPayload(session, project, history);
// 1 system + 1 user + 1 assistant(tool_calls) + 1 tool + 1 assistant
expect(result).toHaveLength(5);
expect(result[1]).toMatchObject({ role: 'user', content: 'show me the file' });
expect(result[2]!.role).toBe('assistant');
expect(result[2]!.tool_calls).toBeDefined();
expect(result[2]!.tool_calls).toHaveLength(1);
expect(result[2]!.tool_calls![0]).toMatchObject({
id: 'call_abc',
type: 'function',
function: { name: 'view_file' },
});
// The OpenAI shape stringifies args.
expect(result[2]!.tool_calls![0]!.function.arguments).toBe(
JSON.stringify({ path: 'src/index.ts' })
);
// assistant with empty content should be serialized as content: null
expect(result[2]!.content).toBeNull();
expect(result[3]).toMatchObject({
role: 'tool',
tool_call_id: 'call_abc',
});
// Non-string tool output is JSON-stringified.
expect(result[3]!.content).toBe(JSON.stringify({ contents: 'console.log(1)' }));
expect(result[4]).toMatchObject({ role: 'assistant', content: 'here it is' });
});
it('skips tool rows with no tool_results', () => {
const session = makeSession();
const project = makeProject();
const history: Message[] = [
makeMessage('user', 'do it'),
makeMessage('tool', '', { tool_results: null }),
makeMessage('assistant', 'done'),
];
const result = buildMessagesPayload(session, project, history);
// 1 system + 1 user + 1 assistant; the empty tool row is dropped.
expect(result).toHaveLength(3);
expect(result.find((m) => m.role === 'tool')).toBeUndefined();
});
});

View File

@@ -0,0 +1,47 @@
import { describe, it, expect } from 'vitest';
import { sanitizeFolderName } from '../project_bootstrap.js';
describe('sanitizeFolderName', () => {
it('passes through a normal slug-like name', () => {
expect(sanitizeFolderName('my-project')).toBe('my-project');
});
it('lowercases and replaces whitespace with hyphens', () => {
expect(sanitizeFolderName('Hello World')).toBe('hello-world');
});
it('strips path-traversal characters', () => {
// dots and slashes fall outside [a-z0-9-] and are removed entirely.
expect(sanitizeFolderName('../etc/passwd')).toBe('etcpasswd');
});
it('strips trailing and leading dots and slashes', () => {
expect(sanitizeFolderName('./foo/')).toBe('foo');
});
it('collapses runs of hyphens and strips leading/trailing ones', () => {
expect(sanitizeFolderName('---foo---')).toBe('foo');
});
it('returns empty string when nothing survives sanitization', () => {
// NOTE: sanitizeFolderName itself does NOT throw — it returns ''. The
// BootstrapNameError is raised by the caller (bootstrapProject) when the
// sanitized result fails the SAFE_NAME regex. The spec's "throws" phrasing
// refers to that caller-level validation, not this pure function.
expect(sanitizeFolderName('...')).toBe('');
expect(sanitizeFolderName(' ')).toBe('');
});
it('strips control characters and null bytes', () => {
// Null bytes and control characters are not in [a-z0-9-] so they're
// filtered out (effectively rejected as folder-name content).
expect(sanitizeFolderName('my\x00proj\x01')).toBe('myproj');
expect(sanitizeFolderName('foo\x00bar')).toBe('foobar');
});
it('truncates names longer than 64 characters', () => {
const long = 'a'.repeat(100);
expect(sanitizeFolderName(long)).toBe('a'.repeat(64));
expect(sanitizeFolderName(long)).toHaveLength(64);
});
});

View File

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

View File

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

View File

@@ -33,6 +33,7 @@ async function snapMtimes(root: string): Promise<MtimeSnap> {
const rootStat = await fs.stat(root);
let gitHead: number | null = null;
let gitIndex: number | null = null;
// best-effort; ignore failure because the project may not be a git repo
try { gitHead = (await fs.stat(path.join(root, '.git', 'HEAD'))).mtimeMs; } catch {}
try { gitIndex = (await fs.stat(path.join(root, '.git', 'index'))).mtimeMs; } catch {}
return { root: rootStat.mtimeMs, gitHead, gitIndex };

View File

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

View File

@@ -1,16 +1,17 @@
import type { FastifyBaseLogger } from 'fastify';
import type { Sql } from '../db.js';
import type { Config } from '../config.js';
import type { Message, Project, Session, ToolCall, UserStreamFrame } from '../types/api.js';
import { ALL_TOOLS, TOOLS_BY_NAME, toolJsonSchemas } from './tools.js';
import type { Agent, Message, Project, Session, ToolCall, UserStreamFrame } from '../types/api.js';
import { ALL_TOOLS, TOOLS_BY_NAME, toolJsonSchemas, type ToolJsonSchema } from './tools.js';
import { PathScopeError, resolveProjectRoot } from './path_guard.js';
import { maybeAutoNameChat } from './auto_name.js';
import { getAgentById } from './agents.js';
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.`;
const DB_FLUSH_INTERVAL_MS = 500;
const MAX_TOOL_LOOP_DEPTH = 5;
const MAX_TOOL_LOOP_DEPTH = 15;
export interface InferenceFrame {
type:
@@ -91,16 +92,32 @@ export interface InferenceContext {
publishUser: (frame: UserStreamFrame) => void;
}
// Resolution order: base prompt < agent.system_prompt < session.system_prompt.
// Agent prompts layer on top of the base; session prompt is the most specific
// override and stacks last so callers can append per-session instructions.
export function buildSystemPrompt(
project: Project,
session: Session,
agent: Agent | null
): string {
let out = BASE_SYSTEM_PROMPT(project.path);
if (agent && agent.system_prompt.trim().length > 0) {
out += '\n\n' + agent.system_prompt.trim();
}
if (session.system_prompt && session.system_prompt.trim().length > 0) {
out += '\n\n' + session.system_prompt.trim();
}
return out;
}
export function buildMessagesPayload(
session: Session,
project: Project,
history: Message[]
history: Message[],
agent: Agent | null = null
): OpenAiMessage[] {
const out: OpenAiMessage[] = [];
let systemPrompt = BASE_SYSTEM_PROMPT(project.path);
if (session.system_prompt && session.system_prompt.trim().length > 0) {
systemPrompt += '\n\n' + session.system_prompt.trim();
}
const systemPrompt = buildSystemPrompt(project, session, agent);
out.push({ role: 'system', content: systemPrompt });
// Find the latest compact marker — only send messages from that point onwards
@@ -161,7 +178,7 @@ async function loadContext(
chatId: string
): Promise<{ session: Session; project: Project; history: Message[] } | null> {
const sessionRows = await sql<Session[]>`
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id
FROM sessions WHERE id = ${sessionId}
`;
if (sessionRows.length === 0) return null;
@@ -217,11 +234,18 @@ interface StreamResult {
nCtx: number | null;
}
interface StreamOptions {
// null = omit tools entirely (compact phase); [] = caller stripped all tools
// (rare; we still omit from the request body to avoid OpenAI 400).
tools: ToolJsonSchema[] | null;
temperature?: number;
}
async function streamCompletion(
ctx: InferenceContext,
model: string,
messages: OpenAiMessage[],
includeTools: boolean,
opts: StreamOptions,
onDelta: (content: string) => void,
signal?: AbortSignal
): Promise<StreamResult> {
@@ -231,10 +255,13 @@ async function streamCompletion(
stream: true,
stream_options: { include_usage: true },
};
if (includeTools) {
body['tools'] = toolJsonSchemas();
if (opts.tools && opts.tools.length > 0) {
body['tools'] = opts.tools;
body['tool_choice'] = 'auto';
}
if (typeof opts.temperature === 'number') {
body['temperature'] = opts.temperature;
}
const res = await fetch(`${ctx.config.LLAMA_SWAP_URL}/v1/chat/completions`, {
method: 'POST',
@@ -348,39 +375,28 @@ async function executeToolCall(
}
}
async function runAssistantTurn(
ctx: InferenceContext,
sessionId: string,
chatId: string,
assistantMessageId: string,
depth: number,
signal?: AbortSignal
): Promise<void> {
if (depth > MAX_TOOL_LOOP_DEPTH) {
await ctx.sql`
UPDATE messages
SET status = 'failed',
content = ${'tool loop depth exceeded'},
finished_at = clock_timestamp()
WHERE id = ${assistantMessageId}
`;
ctx.publish(sessionId, {
type: 'error',
message_id: assistantMessageId,
chat_id: chatId,
error: 'tool loop depth exceeded',
});
return;
}
interface TurnArgs {
sessionId: string;
chatId: string;
assistantMessageId: string;
depth: number;
signal: AbortSignal | undefined;
}
const loaded = await loadContext(ctx.sql, sessionId, chatId);
if (!loaded) {
ctx.log.warn({ sessionId }, 'inference: session or project missing');
return;
}
const { session, project, history } = loaded;
const projectRoot = await resolveProjectRoot(project.path);
const messages = buildMessagesPayload(session, project, history);
interface StreamPhaseState {
accumulated: string;
startedAt: string | null;
}
async function executeStreamPhase(
ctx: InferenceContext,
args: TurnArgs,
session: Session,
messages: OpenAiMessage[],
state: StreamPhaseState,
agent: Agent | null
): Promise<StreamResult> {
const { sessionId, chatId, assistantMessageId, signal } = args;
const startedRow = await ctx.sql<{ started_at: string }[]>`
UPDATE messages
@@ -388,7 +404,7 @@ async function runAssistantTurn(
WHERE id = ${assistantMessageId}
RETURNING started_at
`;
const startedAt = startedRow[0]?.started_at ?? null;
state.startedAt = startedRow[0]?.started_at ?? null;
ctx.publish(sessionId, {
type: 'message_started',
@@ -397,7 +413,6 @@ async function runAssistantTurn(
role: 'assistant',
});
let accumulated = '';
let pendingFlushTimer: NodeJS.Timeout | null = null;
let flushPromise: Promise<unknown> = Promise.resolve();
@@ -406,7 +421,7 @@ async function runAssistantTurn(
clearTimeout(pendingFlushTimer);
pendingFlushTimer = null;
}
const snapshot = accumulated;
const snapshot = state.accumulated;
flushPromise = flushPromise.then(() =>
ctx.sql`UPDATE messages SET content = ${snapshot} WHERE id = ${assistantMessageId}`
);
@@ -420,15 +435,22 @@ async function runAssistantTurn(
}, DB_FLUSH_INTERVAL_MS);
};
let result: StreamResult;
// Tool whitelist: if an agent is set, filter the global tool list to only the
// tool names it allows. Unknown names in agent.tools are dropped silently
// (handled here by intersection). When no agent: send all tools.
const effectiveTools: ToolJsonSchema[] = agent
? toolJsonSchemas().filter((t) => agent.tools.includes(t.function.name))
: toolJsonSchemas();
const effectiveTemperature = agent?.temperature;
try {
result = await streamCompletion(
return await streamCompletion(
ctx,
session.model,
messages,
true,
{ tools: effectiveTools, temperature: effectiveTemperature },
(delta) => {
accumulated += delta;
state.accumulated += delta;
ctx.publish(sessionId, {
type: 'delta',
message_id: assistantMessageId,
@@ -440,136 +462,165 @@ async function runAssistantTurn(
},
signal
);
} catch (err) {
} finally {
if (pendingFlushTimer) {
clearTimeout(pendingFlushTimer);
pendingFlushTimer = null;
}
await flushPromise;
const isAbort = err instanceof Error && err.name === 'AbortError';
const finalStatus = isAbort ? 'cancelled' : 'failed';
await ctx.sql`
UPDATE messages
SET status = ${finalStatus},
content = ${accumulated},
finished_at = clock_timestamp()
WHERE id = ${assistantMessageId}
`;
const [failSessRow] = await ctx.sql<{ project_id: string; name: string; updated_at: string }[]>`
UPDATE sessions SET updated_at = clock_timestamp()
WHERE id = ${sessionId}
RETURNING project_id, name, updated_at
`;
ctx.publishUser({ type: 'session_updated', session_id: sessionId, project_id: failSessRow!.project_id, name: failSessRow!.name, updated_at: failSessRow!.updated_at });
if (isAbort) {
ctx.publish(sessionId, {
type: 'message_complete',
message_id: assistantMessageId,
chat_id: chatId,
});
ctx.log.info({ sessionId, chatId, assistantMessageId }, 'inference cancelled');
} else {
const errMsg = err instanceof Error ? err.message : String(err);
ctx.publish(sessionId, {
type: 'error',
message_id: assistantMessageId,
chat_id: chatId,
error: errMsg,
});
ctx.log.error({ err, sessionId, assistantMessageId }, 'inference failed');
}
return;
}
}
if (pendingFlushTimer) {
clearTimeout(pendingFlushTimer);
pendingFlushTimer = null;
}
await flushPromise;
const { content, finishReason, toolCalls, promptTokens, completionTokens, nCtx } = result;
if (toolCalls.length > 0) {
const [updated] = await ctx.sql<
{ tokens_used: number | null; ctx_used: number | null; ctx_max: number | null; finished_at: string | null }[]
>`
UPDATE messages
SET content = ${content},
status = 'complete',
tool_calls = ${ctx.sql.json(toolCalls as never)},
tokens_used = ${completionTokens},
ctx_used = ${promptTokens},
ctx_max = ${nCtx},
finished_at = clock_timestamp()
WHERE id = ${assistantMessageId}
RETURNING tokens_used, ctx_used, ctx_max, finished_at
`;
const [toolSessRow] = await ctx.sql<{ project_id: string; name: string; updated_at: string }[]>`
UPDATE sessions SET updated_at = clock_timestamp()
WHERE id = ${sessionId}
RETURNING project_id, name, updated_at
`;
ctx.publishUser({ type: 'session_updated', session_id: sessionId, project_id: toolSessRow!.project_id, name: toolSessRow!.name, updated_at: toolSessRow!.updated_at });
for (const tc of toolCalls) {
ctx.publish(sessionId, {
type: 'tool_call',
message_id: assistantMessageId,
chat_id: chatId,
tool_call: tc,
});
}
async function handleAbortOrError(
ctx: InferenceContext,
args: TurnArgs,
accumulated: string,
err: unknown
): Promise<void> {
const { sessionId, chatId, assistantMessageId } = args;
const isAbort = err instanceof Error && err.name === 'AbortError';
const finalStatus = isAbort ? 'cancelled' : 'failed';
await ctx.sql`
UPDATE messages
SET status = ${finalStatus},
content = ${accumulated},
finished_at = clock_timestamp()
WHERE id = ${assistantMessageId}
`;
const [failSessRow] = await ctx.sql<{ project_id: string; name: string; updated_at: string }[]>`
UPDATE sessions SET updated_at = clock_timestamp()
WHERE id = ${sessionId}
RETURNING project_id, name, updated_at
`;
ctx.publishUser({ type: 'session_updated', session_id: sessionId, project_id: failSessRow!.project_id, name: failSessRow!.name, updated_at: failSessRow!.updated_at });
// v1.8 mobile-tabs: cancellation is a user-initiated stop, treat as idle;
// genuine errors flip the dot red.
ctx.publishUser({ type: 'chat_status', chat_id: chatId, status: isAbort ? 'idle' : 'error', at: new Date().toISOString() });
if (isAbort) {
ctx.publish(sessionId, {
type: 'message_complete',
message_id: assistantMessageId,
chat_id: chatId,
tokens_used: updated?.tokens_used ?? null,
ctx_used: updated?.ctx_used ?? null,
ctx_max: updated?.ctx_max ?? null,
started_at: startedAt,
finished_at: updated?.finished_at ?? null,
model: session.model,
});
await Promise.all(
toolCalls.map(async (tc) => {
const [toolRow] = await ctx.sql<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'tool', '', 'complete', clock_timestamp())
RETURNING id
`;
const toolMessageId = toolRow!.id;
const tres = await executeToolCall(projectRoot, tc);
const stored = {
tool_call_id: tc.id,
output: tres.output,
truncated: tres.truncated,
...(tres.error ? { error: tres.error } : {}),
};
await ctx.sql`
UPDATE messages
SET tool_results = ${ctx.sql.json(stored as never)}
WHERE id = ${toolMessageId}
`;
ctx.publish(sessionId, {
type: 'tool_result',
tool_message_id: toolMessageId,
chat_id: chatId,
tool_call_id: tc.id,
output: tres.output,
truncated: tres.truncated,
...(tres.error ? { error: tres.error } : {}),
});
})
);
const [nextAssistant] = await ctx.sql<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
RETURNING id
`;
await runAssistantTurn(ctx, sessionId, chatId, nextAssistant!.id, depth + 1, signal);
return;
ctx.log.info({ sessionId, chatId, assistantMessageId }, 'inference cancelled');
} else {
const errMsg = err instanceof Error ? err.message : String(err);
ctx.publish(sessionId, {
type: 'error',
message_id: assistantMessageId,
chat_id: chatId,
error: errMsg,
});
ctx.log.error({ err, sessionId, assistantMessageId }, 'inference failed');
}
}
async function executeToolPhase(
ctx: InferenceContext,
args: TurnArgs,
result: StreamResult,
startedAt: string | null,
session: Session,
projectRoot: string
): Promise<void> {
const { sessionId, chatId, assistantMessageId, depth, signal } = args;
const { content, toolCalls, promptTokens, completionTokens, nCtx } = result;
const [updated] = await ctx.sql<
{ tokens_used: number | null; ctx_used: number | null; ctx_max: number | null; finished_at: string | null }[]
>`
UPDATE messages
SET content = ${content},
status = 'complete',
tool_calls = ${ctx.sql.json(toolCalls as never)},
tokens_used = ${completionTokens},
ctx_used = ${promptTokens},
ctx_max = ${nCtx},
finished_at = clock_timestamp()
WHERE id = ${assistantMessageId}
RETURNING tokens_used, ctx_used, ctx_max, finished_at
`;
const [toolSessRow] = await ctx.sql<{ project_id: string; name: string; updated_at: string }[]>`
UPDATE sessions SET updated_at = clock_timestamp()
WHERE id = ${sessionId}
RETURNING project_id, name, updated_at
`;
ctx.publishUser({ type: 'session_updated', session_id: sessionId, project_id: toolSessRow!.project_id, name: toolSessRow!.name, updated_at: toolSessRow!.updated_at });
for (const tc of toolCalls) {
ctx.publish(sessionId, {
type: 'tool_call',
message_id: assistantMessageId,
chat_id: chatId,
tool_call: tc,
});
}
ctx.publish(sessionId, {
type: 'message_complete',
message_id: assistantMessageId,
chat_id: chatId,
tokens_used: updated?.tokens_used ?? null,
ctx_used: updated?.ctx_used ?? null,
ctx_max: updated?.ctx_max ?? null,
started_at: startedAt,
finished_at: updated?.finished_at ?? null,
model: session.model,
});
await Promise.all(
toolCalls.map(async (tc) => {
const [toolRow] = await ctx.sql<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'tool', '', 'complete', clock_timestamp())
RETURNING id
`;
const toolMessageId = toolRow!.id;
const tres = await executeToolCall(projectRoot, tc);
const stored = {
tool_call_id: tc.id,
output: tres.output,
truncated: tres.truncated,
...(tres.error ? { error: tres.error } : {}),
};
await ctx.sql`
UPDATE messages
SET tool_results = ${ctx.sql.json(stored as never)}
WHERE id = ${toolMessageId}
`;
ctx.publish(sessionId, {
type: 'tool_result',
tool_message_id: toolMessageId,
chat_id: chatId,
tool_call_id: tc.id,
output: tres.output,
truncated: tres.truncated,
...(tres.error ? { error: tres.error } : {}),
});
})
);
const [nextAssistant] = await ctx.sql<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
RETURNING id
`;
await runAssistantTurn(ctx, {
sessionId,
chatId,
assistantMessageId: nextAssistant!.id,
depth: depth + 1,
signal,
});
}
async function finalizeCompletion(
ctx: InferenceContext,
args: TurnArgs,
result: StreamResult,
startedAt: string | null,
session: Session
): Promise<void> {
const { sessionId, chatId, assistantMessageId } = args;
const { content, finishReason, promptTokens, completionTokens, nCtx } = result;
const [updated] = await ctx.sql<
{ tokens_used: number | null; ctx_used: number | null; ctx_max: number | null; finished_at: string | null }[]
@@ -590,6 +641,7 @@ async function runAssistantTurn(
RETURNING project_id, name, updated_at
`;
ctx.publishUser({ type: 'session_updated', session_id: sessionId, project_id: completeSessRow!.project_id, name: completeSessRow!.name, updated_at: completeSessRow!.updated_at });
ctx.publishUser({ type: 'chat_status', chat_id: chatId, status: 'idle', at: new Date().toISOString() });
ctx.publish(sessionId, {
type: 'message_complete',
message_id: assistantMessageId,
@@ -615,6 +667,62 @@ async function runAssistantTurn(
);
}
async function runAssistantTurn(
ctx: InferenceContext,
args: TurnArgs,
): Promise<void> {
const { sessionId, chatId, assistantMessageId, depth } = args;
if (depth > MAX_TOOL_LOOP_DEPTH) {
await ctx.sql`
UPDATE messages
SET status = 'failed',
content = ${'tool loop depth exceeded'},
finished_at = clock_timestamp()
WHERE id = ${assistantMessageId}
`;
ctx.publish(sessionId, {
type: 'error',
message_id: assistantMessageId,
chat_id: chatId,
error: 'tool loop depth exceeded',
});
ctx.publishUser({ type: 'chat_status', chat_id: chatId, status: 'error', at: new Date().toISOString() });
return;
}
const loaded = await loadContext(ctx.sql, sessionId, chatId);
if (!loaded) {
ctx.log.warn({ sessionId }, 'inference: session or project missing');
return;
}
const { session, project, history } = loaded;
const projectRoot = await resolveProjectRoot(project.path);
// Agent resolution is per-turn so PATCH agent_id mid-conversation takes
// effect on the next message. Unknown agent_id returns null silently —
// session falls back to base prompt + all tools + default temperature.
const agent = session.agent_id
? await getAgentById(project.path, session.agent_id)
: null;
const messages = buildMessagesPayload(session, project, history, agent);
const state: StreamPhaseState = { accumulated: '', startedAt: null };
let result: StreamResult;
try {
result = await executeStreamPhase(ctx, args, session, messages, state, agent);
} catch (err) {
await handleAbortOrError(ctx, args, state.accumulated, err);
return;
}
if (result.toolCalls.length > 0) {
await executeToolPhase(ctx, args, result, state.startedAt, session, projectRoot);
return;
}
await finalizeCompletion(ctx, args, result, state.startedAt, session);
}
export async function runInference(
ctx: InferenceContext,
sessionId: string,
@@ -622,7 +730,7 @@ export async function runInference(
assistantMessageId: string,
signal?: AbortSignal
): Promise<void> {
return runAssistantTurn(ctx, sessionId, chatId, assistantMessageId, 0, signal);
return runAssistantTurn(ctx, { sessionId, chatId, assistantMessageId, depth: 0, signal });
}
const COMPACT_SYSTEM_PROMPT =
@@ -659,7 +767,7 @@ async function runCompact(
ctx,
session.model,
messagesForSummary,
false,
{ tools: null },
(delta) => {
content += delta;
ctx.publish(sessionId, {
@@ -717,6 +825,9 @@ export function createInferenceRunner(
...ctx,
publishUser: (frame) => publishUserFn(user, frame),
};
// v1.8 mobile-tabs: announce working before the async loop starts so
// every device subscribed to the user channel sees the amber dot.
callCtx.publishUser({ type: 'chat_status', chat_id: chatId, status: 'working', at: new Date().toISOString() });
const controller = new AbortController();
let resolveCompleted!: () => void;
const completed = new Promise<void>((res) => { resolveCompleted = res; });
@@ -764,6 +875,10 @@ export function createInferenceRunner(
await reg.completed.catch(() => {});
return true;
},
hasActive(chatId: string): boolean {
return registry.has(chatId);
},
};
}

View File

@@ -82,11 +82,13 @@ export async function bootstrapProject(
throw new BootstrapNameError(`invalid name after sanitization: "${folder}"`);
}
// Whitelist resolution
const whitelistReal = await realpath(config.PROJECT_ROOT_WHITELIST);
const fullPath = resolve(whitelistReal, folder);
if (!fullPath.startsWith(whitelistReal + sep)) {
throw new BootstrapPathError('path escapes whitelist');
// Bootstrap target resolution. Uses BOOTSTRAP_ROOT (writable), not
// PROJECT_ROOT_WHITELIST (which may be a wider read-only scope for
// add-existing flow).
const bootstrapReal = await realpath(config.BOOTSTRAP_ROOT);
const fullPath = resolve(bootstrapReal, folder);
if (!fullPath.startsWith(bootstrapReal + sep)) {
throw new BootstrapPathError('path escapes bootstrap root');
}
if (existsSync(fullPath)) {
throw new BootstrapCollisionError(`path already exists: ${fullPath}`);
@@ -144,7 +146,8 @@ export async function bootstrapProject(
// Step 6: git remote add + push
try {
await execFileAsync('git', ['remote', 'add', 'origin', repo.ssh_url], { cwd: fullPath });
const sshUrl = repo.ssh_url.replace('git.indifferentketchup.com', '100.114.205.53');
await execFileAsync('git', ['remote', 'add', 'origin', sshUrl], { cwd: fullPath });
await execFileAsync('git', ['push', '-u', 'origin', 'main'], { cwd: fullPath });
gitea_pushed = true;
log.info({ folder }, 'project_bootstrap: pushed to gitea');

View File

@@ -3,6 +3,7 @@ import { resolve, basename, relative } from 'node:path';
import { z } from 'zod';
import { pathGuard, PathScopeError } from './path_guard.js';
import { grep as fileOpsGrep, findFiles as fileOpsFindFiles } from './file_ops.js';
import { getGitMeta } from './git_meta.js';
const MAX_FILE_BYTES = 5 * 1024 * 1024;
const DEFAULT_VIEW_LINES = 200;
@@ -266,11 +267,45 @@ export const findFiles: ToolDef<FindFilesInputT> = {
},
};
// v1.8 Level 1 branch awareness: gives the model a read-only view of the
// project's git state. No path input — operates on the inference-resolved
// project root via getGitMeta. Subprocess runs with a 2s timeout (see git_meta).
const GitStatusInput = z.object({}).strict();
type GitStatusInputT = z.infer<typeof GitStatusInput>;
export const gitStatus: ToolDef<GitStatusInputT> = {
name: 'git_status',
description:
"Returns the current git branch, whether the working tree is dirty, and ahead/behind counts vs upstream. Read-only. Use when you need to know which branch the user is currently working on.",
inputSchema: GitStatusInput,
jsonSchema: {
type: 'function',
function: {
name: 'git_status',
description:
'Returns the current git branch, dirty flag, and ahead/behind counts vs upstream. Read-only.',
parameters: {
type: 'object',
properties: {},
additionalProperties: false,
},
},
},
async execute(_input, projectRoot) {
const meta = await getGitMeta(projectRoot);
if (meta === null) {
return { repo: false, branch: null, is_dirty: false, ahead: 0, behind: 0 };
}
return { repo: true, ...meta };
},
};
export const ALL_TOOLS: ReadonlyArray<ToolDef<unknown>> = [
viewFile as ToolDef<unknown>,
listDir as ToolDef<unknown>,
grep as ToolDef<unknown>,
findFiles as ToolDef<unknown>,
gitStatus as ToolDef<unknown>,
];
export const TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries(

View File

@@ -28,6 +28,36 @@ export interface Session {
status: SessionStatus;
created_at: string;
updated_at: string;
agent_id: string | null;
}
// v1.8.1: agents come from two sources. 'global' = /data/AGENTS.md (always
// loaded inside the container), 'project' = per-project override at
// <root>/AGENTS.md. Project entries override global by name (case-sensitive).
export type AgentSource = 'global' | 'project';
export interface Agent {
id: string; // slug of name; stable handle stored in sessions.agent_id
name: string;
description: string;
system_prompt: string;
temperature: number;
tools: string[]; // whitelist of tool names; empty = no tools allowed
model: string | null; // null means "session.model wins"
source: AgentSource;
}
// One entry per malformed `## Name` block. Per-block errors don't fail the
// whole file — the loader returns parsed-successfully agents AND the list of
// skipped ones so the UI can show a non-blocking warning chip.
export interface AgentParseError {
agent_name: string;
reason: string;
}
export interface AgentsResponse {
agents: Agent[];
errors: AgentParseError[];
}
// KEEP IN SYNC: apps/server/src/schema.sql chats_status_chk
@@ -176,6 +206,11 @@ export interface SessionUpdatedFrame {
name: string;
updated_at: string;
}
export interface SessionRenamedFrame {
type: 'session_renamed';
session_id: string;
name: string;
}
export interface SessionArchivedFrame {
type: 'session_archived';
session_id: string;
@@ -220,12 +255,21 @@ export interface ProjectUpdatedFrame {
project_id: string;
name: string;
}
// v1.8 mobile-tabs: server can't know about client-side panes, so status
// is keyed by chat_id. Frontend dot derives pane status from pane.activeChatId.
export interface ChatStatusFrame {
type: 'chat_status';
chat_id: string;
status: 'working' | 'idle' | 'error';
at: string;
}
export type UserStreamFrame =
| ProjectCreatedFrame
| ProjectDeletedFrame
| SessionCreatedFrame
| SessionDeletedFrame
| SessionUpdatedFrame
| SessionRenamedFrame
| SessionArchivedFrame
| ChatCreatedFrame
| ChatUpdatedFrame
@@ -234,4 +278,5 @@ export type UserStreamFrame =
| ChatDeletedFrame
| ProjectArchivedFrame
| ProjectUnarchivedFrame
| ProjectUpdatedFrame;
| ProjectUpdatedFrame
| ChatStatusFrame;

View File

@@ -10,5 +10,6 @@
"declaration": false,
"sourceMap": true
},
"include": ["src/**/*"]
"include": ["src/**/*"],
"exclude": ["src/**/__tests__/**", "**/*.test.ts"]
}

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
globals: false,
include: ['src/**/__tests__/**/*.test.ts'],
},
});

View File

@@ -8,6 +8,9 @@ import { Project } from '@/pages/Project';
import { Session } from '@/pages/Session';
import { Toaster } from '@/components/ui/sonner';
import { useUserEvents } from '@/hooks/useUserEvents';
import { SidebarDrawerProvider, useSidebarDrawer } from '@/hooks/useSidebarDrawer';
import { RightRailDrawerProvider, useRightRailDrawer } from '@/hooks/useRightRailDrawer';
import { useViewport } from '@/hooks/useViewport';
function SessionRightRail() {
const { id } = useParams<{ id: string }>();
@@ -18,17 +21,51 @@ function SessionRightRail() {
function RightRailForSession({ sessionId }: { sessionId: string }) {
const [projectId, setProjectId] = useState<string | null>(null);
useEffect(() => {
api.sessions.get(sessionId).then((s) => setProjectId(s.project_id)).catch(() => {});
api.sessions
.get(sessionId)
.then((s) => setProjectId(s.project_id))
.catch((err) => console.warn('RightRail: failed to fetch session', err));
}, [sessionId]);
if (!projectId) return null;
// v1.6.2: rendered on all viewports. On mobile, RightRail itself renders as
// a right-side drawer toggled by the header's FolderTree button (via
// useRightRailDrawer). On desktop, it renders inline as before with its
// own internal open/close state.
return <RightRail projectId={projectId} />;
}
function MobileBackdrop() {
const { open, setOpen } = useSidebarDrawer();
const { isMobile } = useViewport();
if (!isMobile || !open) return null;
return (
<div
className="fixed inset-0 z-30 bg-black/40 md:hidden"
onClick={() => setOpen(false)}
aria-hidden="true"
/>
);
}
function MobileRightRailBackdrop() {
const { open, setOpen } = useRightRailDrawer();
const { isMobile } = useViewport();
if (!isMobile || !open) return null;
return (
<div
className="fixed inset-0 z-30 bg-black/40 md:hidden"
onClick={() => setOpen(false)}
aria-hidden="true"
/>
);
}
function AppShell() {
useUserEvents();
return (
<div className="dark h-screen flex bg-background text-foreground">
<ProjectSidebar />
<MobileBackdrop />
<main className="flex-1 flex flex-col min-w-0">
<Routes>
<Route path="/" element={<Home />} />
@@ -36,6 +73,7 @@ function AppShell() {
<Route path="/session/:id" element={<Session />} />
</Routes>
</main>
<MobileRightRailBackdrop />
<Routes>
<Route path="/session/:id" element={<SessionRightRail />} />
</Routes>
@@ -47,7 +85,11 @@ function AppShell() {
export default function App() {
return (
<BrowserRouter>
<AppShell />
<SidebarDrawerProvider>
<RightRailDrawerProvider>
<AppShell />
</RightRailDrawerProvider>
</SidebarDrawerProvider>
</BrowserRouter>
);
}

View File

@@ -8,6 +8,8 @@ import type {
SidebarResponse,
ListDirResult,
ViewFileResult,
AgentsResponse,
GitMeta,
} from './types';
export class ApiError extends Error {
@@ -86,6 +88,8 @@ export const api = {
request<ViewFileResult>(`/api/projects/${id}/view_file?path=${encodeURIComponent(path)}`),
files: (id: string) =>
request<{ files: string[] }>(`/api/projects/${id}/files`),
git: (id: string) =>
request<GitMeta>(`/api/projects/${id}/git`),
},
sessions: {
@@ -93,7 +97,7 @@ export const api = {
request<Session[]>(`/api/projects/${projectId}/sessions${status ? `?status=${status}` : ''}`),
create: (
projectId: string,
body: { name?: string; model?: string; system_prompt?: string }
body: { name?: string; model?: string; system_prompt?: string; agent_id?: string | null }
) =>
request<Session>(`/api/projects/${projectId}/sessions`, {
method: 'POST',
@@ -102,7 +106,7 @@ export const api = {
get: (id: string) => request<Session>(`/api/sessions/${id}`),
update: (
id: string,
body: Partial<Pick<Session, 'name' | 'model' | 'system_prompt'>>
body: Partial<Pick<Session, 'name' | 'model' | 'system_prompt' | 'agent_id'>>
) =>
request<Session>(`/api/sessions/${id}`, {
method: 'PATCH',
@@ -148,6 +152,11 @@ export const api = {
`/api/chats/${chatId}/force_send`,
{ method: 'POST', body: JSON.stringify({ content }) }
),
fork: (chatId: string, body: { messageId: string; name?: string }) =>
request<Chat>(`/api/chats/${chatId}/fork`, {
method: 'POST',
body: JSON.stringify({ message_id: body.messageId, name: body.name }),
}),
},
messages: {
@@ -166,10 +175,19 @@ export const api = {
`/api/chats/${chatId}/messages/${messageId}/regenerate`,
{ method: 'POST' }
),
remove: (chatId: string, messageId: string) =>
request<void>(`/api/chats/${chatId}/messages/${messageId}`, {
method: 'DELETE',
}),
},
models: () => request<ModelInfo[]>('/api/models'),
agents: {
list: (projectId: string) =>
request<AgentsResponse>(`/api/projects/${projectId}/agents`),
},
settings: {
get: () => request<Record<string, unknown>>('/api/settings'),
patch: (body: Record<string, unknown>) =>

View File

@@ -27,6 +27,33 @@ export interface Session {
status: SessionStatus;
created_at: string;
updated_at: string;
agent_id: string | null;
}
// v1.8.1: 'global' = /data/AGENTS.md (always-on), 'project' = per-project
// override at <root>/AGENTS.md. In-code builtins were retired; the seed file
// lives at /data/AGENTS.md.
export type AgentSource = 'global' | 'project';
export interface Agent {
id: string;
name: string;
description: string;
system_prompt: string;
temperature: number;
tools: string[];
model: string | null;
source: AgentSource;
}
export interface AgentParseError {
agent_name: string;
reason: string;
}
export interface AgentsResponse {
agents: Agent[];
errors: AgentParseError[];
}
export const CHAT_STATUSES = ['open', 'archived'] as const;
@@ -156,6 +183,15 @@ export interface PaneUpdateRequest {
position?: number;
}
// v1.8 mobile-tabs: shape returned by GET /api/projects/:id/git. Mirrors
// services/git_meta.ts on the server. branch=null means "not a git repo".
export interface GitMeta {
branch: string | null;
is_dirty: boolean;
ahead: number;
behind: number;
}
export type WorkspacePaneKind = 'chat' | 'terminal' | 'agent' | 'empty';
export interface WorkspacePane {
@@ -191,6 +227,5 @@ export type WsFrame =
finished_at?: string | null;
}
| { type: 'messages_deleted'; message_ids: string[]; chat_id?: string }
| { type: 'session_renamed'; session_id: string; name: string; chat_id?: string }
| { type: 'chat_renamed'; chat_id: string; name: string }
| { type: 'error'; message_id?: string; chat_id?: string; error: string };

View File

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

View File

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

View File

@@ -0,0 +1,55 @@
import type { ChatContextStats } from '@/hooks/useChatContextStats';
interface Props {
stats: ChatContextStats | null;
}
/**
* Formats a token count into a compact k/m-suffix string.
* - < 1_000 → raw integer (e.g. "42")
* - 1_000999_999 → "Nk" or "N.Nk" (e.g. "30k", "12.5k", "100k")
* - >= 1_000_000 → "Nm" or "N.Nm" (e.g. "1m", "1.5m", "100m")
*
* Drops a trailing ".0" so we get "30k" instead of "30.0k".
*/
function formatTokens(n: number): string {
if (n < 1000) return String(n);
if (n < 1_000_000) {
const k = n / 1000;
return k >= 100 ? `${Math.round(k)}k` : `${k.toFixed(1).replace(/\.0$/, '')}k`;
}
const m = n / 1_000_000;
return m >= 100 ? `${Math.round(m)}m` : `${m.toFixed(1).replace(/\.0$/, '')}m`;
}
/**
* Color thresholds:
* - > 85% → text-destructive
* - >= 60% → text-amber-500
* - else → text-muted-foreground
* (85% itself falls into the amber band.)
*/
function percentColorClass(percent: number): string {
if (percent > 85) return 'text-destructive';
if (percent >= 60) return 'text-amber-500';
return 'text-muted-foreground';
}
export function ChatContextPopover({ stats }: Props) {
if (!stats) return null;
return (
<div className="absolute bottom-full right-4 mb-4 z-20 pointer-events-none">
<div className="rounded-md border border-border bg-card text-card-foreground shadow-sm px-3 py-2 text-xs min-w-[140px]">
<div className="text-muted-foreground/80 text-[10px] uppercase tracking-wide mb-0.5">
Context window
</div>
<div className={`text-base font-medium ${percentColorClass(stats.percent)}`}>
{stats.percent}% used
</div>
<div className="text-muted-foreground text-[10px] font-mono">
{formatTokens(stats.used)} / {formatTokens(stats.max)} tokens
</div>
</div>
</div>
);
}

View File

@@ -1,27 +1,47 @@
import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from 'react';
import { useCallback, useEffect, useRef, useState, type DragEvent, type KeyboardEvent } from 'react';
import { Send } from 'lucide-react';
import { toast } from 'sonner';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import { flattenToMessage, inferLanguage, type Attachment } from '@/lib/attachments';
import {
flattenToMessage,
inferLanguage,
looksBinary,
MAX_FILE_SIZE_BYTES,
PASTE_INLINE_MAX_LINES,
type Attachment,
} from '@/lib/attachments';
import { AttachmentChip } from '@/components/AttachmentChip';
import { AttachmentPreviewModal } from '@/components/AttachmentPreviewModal';
import { FileMentionPopover } from '@/components/FileMentionPopover';
import { DropOverlay } from '@/components/DropOverlay';
import { AgentPicker } from '@/components/AgentPicker';
import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents';
import { useViewport } from '@/hooks/useViewport';
const MAX_ATTACHMENTS = 10;
interface Props {
disabled?: boolean;
projectId: string;
// Batch 9: optional so callers that pre-date the agent picker still compile.
// When omitted, the toolbar row is hidden entirely.
agentId?: string | null;
onAgentChange?: (agentId: string | null) => void | Promise<void>;
onSend: (content: string) => void | Promise<void>;
onForceSend?: (content: string) => void | Promise<void>;
}
export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
export function ChatInput({ disabled, projectId, agentId, onAgentChange, onSend, onForceSend }: Props) {
const { isMobile } = useViewport();
const [value, setValue] = useState('');
const [busy, setBusy] = useState(false);
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [previewAttachment, setPreviewAttachment] = useState<Attachment | null>(null);
const [isDraggingOver, setIsDraggingOver] = useState(false);
const dropRootRef = useRef<HTMLDivElement | null>(null);
const pasteCounterRef = useRef(0);
const [mentionState, setMentionState] = useState<{
open: boolean;
query: string;
@@ -33,8 +53,8 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
function addAttachment(a: Attachment) {
setAttachments(prev => {
if (prev.length >= 10) {
toast.error('Max 10 attachments per message');
if (prev.length >= MAX_ATTACHMENTS) {
toast.error(`Max ${MAX_ATTACHMENTS} attachments per message`);
return prev;
}
return [...prev, a];
@@ -183,8 +203,169 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
const closeMention = useCallback(() => setMentionState(null), []);
// ---- Drag & drop (F1 + F3 + F4) ----------------------------------------
// The drop zone is the outer ChatInput container (ref'd as dropRootRef).
// onDragLeave only clears the highlight when the cursor leaves the
// container, not when it crosses into a child element.
async function processDroppedFile(file: File) {
// Size gate
if (file.size > MAX_FILE_SIZE_BYTES) {
const mb = (file.size / (1024 * 1024)).toFixed(1);
toast.error(`File ${file.name} is too large (${mb} MB). Limit is 5 MB.`);
return;
}
// Read once as ArrayBuffer so we can do byte-level binary detection
// before deciding whether to decode as text.
let buf: ArrayBuffer;
try {
buf = await file.arrayBuffer();
} catch (err) {
toast.error(`Failed to read ${file.name}: ${err instanceof Error ? err.message : String(err)}`);
return;
}
if (looksBinary(buf)) {
toast.error(`${file.name} appears to be binary.`);
return;
}
const text = new TextDecoder('utf-8', { fatal: false }).decode(buf);
addAttachment({
id: crypto.randomUUID(),
kind: 'file',
filename: file.name,
language: inferLanguage(file.name),
content: text,
source: 'drop',
});
}
function isFolderItem(item: DataTransferItem | undefined): boolean {
if (!item) return false;
// webkitGetAsEntry is non-standard but supported in Chromium + Safari.
// If unavailable, we conservatively treat the entry as a file.
const entry =
typeof item.webkitGetAsEntry === 'function' ? item.webkitGetAsEntry() : null;
if (entry && entry.isDirectory) return true;
// Heuristic fallback: folders dragged from Finder have type === '' and
// a 0-byte File. The empty-type alone isn't reliable for files (some
// plaintext drops also lack a type), so we only flag when the entry
// explicitly says directory.
return false;
}
async function handleDroppedItems(dt: DataTransfer) {
// Snapshot items first because reading files inside the loop can
// detach the DataTransfer between awaits.
const itemsArray: { file: File | null; isFolder: boolean }[] = [];
if (dt.items && dt.items.length > 0) {
for (let i = 0; i < dt.items.length; i++) {
const it = dt.items[i];
if (!it || it.kind !== 'file') continue;
const folder = isFolderItem(it);
const file = folder ? null : it.getAsFile();
itemsArray.push({ file, isFolder: folder });
}
} else {
for (let i = 0; i < dt.files.length; i++) {
const f = dt.files[i];
if (f) itemsArray.push({ file: f, isFolder: false });
}
}
let remainingSlots = MAX_ATTACHMENTS - attachments.length;
let folderRejected = false;
for (const { file, isFolder } of itemsArray) {
if (isFolder) {
if (!folderRejected) {
toast.error('Folders are not supported');
folderRejected = true;
}
continue;
}
if (!file) continue;
if (remainingSlots <= 0) {
toast.error(`Attachment limit reached (${MAX_ATTACHMENTS}).`);
return;
}
await processDroppedFile(file);
remainingSlots -= 1;
}
}
function onDragEnter(e: DragEvent<HTMLDivElement>) {
if (disabled || busy) return;
e.preventDefault();
setIsDraggingOver(true);
}
function onDragOver(e: DragEvent<HTMLDivElement>) {
if (disabled || busy) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
}
function onDragLeave(e: DragEvent<HTMLDivElement>) {
// Only clear when the cursor actually leaves the root container.
// relatedTarget is the element being entered; if it's inside the root,
// ignore — we're just crossing into a child.
const root = dropRootRef.current;
if (!root) return;
const related = e.relatedTarget as Node | null;
if (related && root.contains(related)) return;
setIsDraggingOver(false);
}
function onDrop(e: DragEvent<HTMLDivElement>) {
e.preventDefault();
setIsDraggingOver(false);
if (disabled || busy) return;
void handleDroppedItems(e.dataTransfer);
}
// ---- end Drag & drop -----------------------------------------------------
// ---- Paste-as-attachment (F2) -------------------------------------------
// Pasting >PASTE_INLINE_MAX_LINES lines of text becomes a chip rather than
// inline content. Image pastes are rejected with a toast. If both text and
// image are present (e.g. screenshot tool that sets both), prefer text.
function onPaste(e: React.ClipboardEvent<HTMLTextAreaElement>) {
const cd = e.clipboardData;
if (!cd) return;
const text = cd.getData('text/plain');
const hasImage = Array.from(cd.items ?? []).some((it) =>
it.type.startsWith('image/'),
);
if (text) {
const lineCount = text.split('\n').length;
if (lineCount > PASTE_INLINE_MAX_LINES) {
e.preventDefault();
pasteCounterRef.current += 1;
addAttachment({
id: crypto.randomUUID(),
kind: 'paste',
filename: `pasted-${pasteCounterRef.current}.txt`,
language: 'plaintext',
content: text,
source: 'paste',
});
}
// <= threshold: let default paste insert inline.
return;
}
if (hasImage) {
e.preventDefault();
toast.error('Image paste is not supported. Drop a file or paste text.');
}
}
// ---- end Paste-as-attachment --------------------------------------------
function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
if (mentionState?.open) return;
// IME safety: never act on Enter while an IME composition is in flight
// (CJK input methods commit composition via Enter). Without this, the
// first Enter of a Japanese/Chinese/Korean composition would submit
// instead of finalizing the candidate.
if (e.nativeEvent.isComposing) return;
if (e.key === 'Enter' && e.shiftKey && (e.metaKey || e.ctrlKey) && onForceSend) {
e.preventDefault();
void forceSubmit();
@@ -195,7 +376,9 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
void submit();
return;
}
if (e.key === 'Enter' && !e.shiftKey) {
// Bare Enter: sends on desktop, inserts a newline on mobile (per spec —
// send is via the dedicated button on touch devices).
if (e.key === 'Enter' && !e.shiftKey && !isMobile) {
e.preventDefault();
void submit();
}
@@ -219,7 +402,16 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
}
return (
<div className="border-t">
<div
ref={dropRootRef}
className="border-t relative"
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
onDragEnter={onDragEnter}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
>
<DropOverlay visible={isDraggingOver} />
<div className="max-w-[1000px] mx-auto w-full">
{attachments.length > 0 && (
<div className="flex flex-wrap gap-1.5 px-4 pt-3">
@@ -233,13 +425,30 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
))}
</div>
)}
{/* Batch 9 toolbar — agent picker. Sits above the input row so it
doesn't compete with the send button for vertical alignment.
When Batch 7 lands, ModelPicker and the + button join this row. */}
{onAgentChange && (
<div className="px-4 pt-2 flex items-center gap-1.5">
<AgentPicker
projectId={projectId}
value={agentId ?? null}
onChange={onAgentChange}
/>
</div>
)}
<div className="px-4 py-3 flex items-end gap-2">
<Textarea
ref={textareaRef}
value={value}
onChange={handleChange}
onKeyDown={onKeyDown}
placeholder="Ask about this project. Enter to send, Shift+Enter for newline."
onPaste={onPaste}
placeholder={
isMobile
? 'Ask about this project. Tap send to submit.'
: 'Ask about this project. Enter to send · Shift+Enter for newline.'
}
disabled={disabled || busy}
rows={3}
className="resize-none min-h-[68px] max-h-[240px]"

View File

@@ -1,6 +1,7 @@
import { useState } from 'react';
import { History, MessageSquare, Plus, X } from 'lucide-react';
import type { Chat, WorkspacePane } from '@/api/types';
import { StatusDot } from '@/components/StatusDot';
import {
ContextMenu,
ContextMenuContent,
@@ -8,6 +9,7 @@ import {
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu';
import { useLongPress } from '@/hooks/useLongPress';
import { cn } from '@/lib/utils';
interface Props {
@@ -40,6 +42,18 @@ export function ChatTabBar({
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
// Long-press: dispatch a synthetic contextmenu event on the tab so the
// existing Radix ContextMenuTrigger opens at the touch coordinates. Works
// because asChild composition makes the tab div the trigger element.
const longPress = useLongPress(({ clientX, clientY, target }) => {
if (!target || !(target instanceof Element)) return;
const tab = target.closest('[data-tab-id]') as HTMLElement | null;
if (!tab) return;
tab.dispatchEvent(
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX, clientY }),
);
});
function startRename(chatId: string, currentName: string | null) {
setRenamingId(chatId);
setRenameValue(currentName ?? '');
@@ -53,7 +67,7 @@ export function ChatTabBar({
}
return (
<div className="flex items-center border-b border-border bg-muted/20 h-8 shrink-0 overflow-x-auto">
<div className="flex items-center border-b border-border bg-muted/20 h-8 shrink-0 overflow-x-auto max-md:hidden">
{tabs.map((chat, tabIdx) => {
const isActive = tabIdx === pane.activeChatIdx;
const isLast = tabIdx === tabs.length - 1;
@@ -63,7 +77,13 @@ export function ChatTabBar({
<ContextMenu key={chat.id}>
<ContextMenuTrigger asChild>
<div
data-tab-id={chat.id}
onClick={() => onSwitchTab(tabIdx)}
onTouchStart={longPress.onTouchStart}
onTouchMove={longPress.onTouchMove}
onTouchEnd={longPress.onTouchEnd}
onTouchCancel={longPress.onTouchCancel}
style={{ WebkitTouchCallout: 'none' }}
className={cn(
'group flex items-center gap-1.5 px-3 py-1.5 text-xs border-r border-border cursor-default select-none shrink-0',
isActive
@@ -72,6 +92,7 @@ export function ChatTabBar({
)}
>
<MessageSquare size={12} className="shrink-0" />
<StatusDot chatId={chat.id} />
{renamingId === chat.id ? (
<input
autoFocus
@@ -96,7 +117,7 @@ export function ChatTabBar({
e.stopPropagation();
onRemoveTab(chat.id);
}}
className="p-0.5 hover:bg-muted rounded opacity-0 group-hover:opacity-60 hover:!opacity-100 shrink-0"
className="inline-flex items-center justify-center p-0.5 hover:bg-muted rounded opacity-0 group-hover:opacity-60 hover:!opacity-100 shrink-0 max-md:min-h-[44px] max-md:min-w-[44px] max-md:opacity-100"
aria-label="Close tab"
>
<X size={10} />
@@ -104,6 +125,10 @@ export function ChatTabBar({
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={() => onNewChat()}>
New chat
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onSelect={() => startRename(chat.id, chat.name)}>
Rename
</ContextMenuItem>
@@ -142,7 +167,7 @@ export function ChatTabBar({
<button
type="button"
onClick={onNewChat}
className="p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="New chat"
title="New chat"
>
@@ -152,7 +177,7 @@ export function ChatTabBar({
type="button"
onClick={onShowHistory}
className={cn(
'p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground',
'inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]',
pane.kind === 'empty' && 'text-foreground bg-muted/50'
)}
aria-label="Session history"
@@ -164,7 +189,7 @@ export function ChatTabBar({
<button
type="button"
onClick={onRemovePane}
className="p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Close pane"
title="Close pane"
>

View File

@@ -103,7 +103,7 @@ export function CreateProjectModal({ open, onOpenChange }: Props) {
/>
{name && (
<div className="text-xs text-muted-foreground font-mono">
Folder: /opt/{folderPreview || <span className="text-destructive">(empty after sanitization)</span>}
Folder: /opt/projects/{folderPreview || <span className="text-destructive">(empty after sanitization)</span>}
</div>
)}
</div>

View File

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

View File

@@ -2,13 +2,22 @@ import { Children, cloneElement, isValidElement, useState } from 'react';
import type { ReactElement, ReactNode } from 'react';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw } from 'lucide-react';
import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import type { Chat, Message } from '@/api/types';
import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents';
import { ToolCallCard } from './ToolCallCard';
import { CodeBlock } from './CodeBlock';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
// Match path-shaped substrings ending in `.ext`. Additionally require a `/`
// in the match to reduce false positives in prose (e.g. plain `foo.ts` won't
@@ -198,6 +207,9 @@ function ActionRow({
}) {
const [justCopied, setJustCopied] = useState(false);
const [regenerating, setRegenerating] = useState(false);
const [forking, setForking] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
async function copy() {
try {
@@ -221,33 +233,114 @@ function ActionRow({
}
}
async function fork() {
if (forking || message.status !== 'complete') return;
setForking(true);
try {
const chat = await api.chats.fork(message.chat_id, { messageId: message.id });
sessionEvents.emit({ type: 'open_chat_in_active_pane', chat_id: chat.id });
} catch (err) {
toast.error(err instanceof Error ? err.message : 'fork failed');
} finally {
setForking(false);
}
}
async function confirmDelete() {
if (deleting) return;
setDeleting(true);
try {
await api.messages.remove(message.chat_id, message.id);
setDeleteOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'delete failed');
} finally {
setDeleting(false);
}
}
const isAssistant = message.role === 'assistant';
const canRegen = isAssistant && message.status !== 'streaming';
const canFork = message.status === 'complete';
const canDelete = message.status !== 'streaming';
return (
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={() => void copy()}
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="Copy message"
title="Copy"
>
{justCopied ? <Check className="size-3" /> : <Copy className="size-3" />}
</button>
{isAssistant && (
<>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity max-md:opacity-100">
<button
type="button"
onClick={() => void regenerate()}
disabled={!canRegen || regenerating}
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"
aria-label="Regenerate message"
title="Regenerate"
onClick={() => void copy()}
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="Copy message"
title="Copy"
>
<RefreshCw className={`size-3 ${regenerating ? 'animate-spin' : ''}`} />
{justCopied ? <Check className="size-3" /> : <Copy className="size-3" />}
</button>
)}
</div>
{isAssistant && (
<button
type="button"
onClick={() => void regenerate()}
disabled={!canRegen || regenerating}
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Regenerate message"
title="Regenerate"
>
<RefreshCw className={`size-3 ${regenerating ? 'animate-spin' : ''}`} />
</button>
)}
<button
type="button"
onClick={() => void fork()}
disabled={!canFork || forking}
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Fork from here"
title="Fork from here"
>
<GitFork className="size-3" />
</button>
<button
type="button"
onClick={() => setDeleteOpen(true)}
disabled={!canDelete}
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-destructive disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Delete message"
title="Delete message"
>
<Trash2 className="size-3" />
</button>
</div>
<Dialog
open={deleteOpen}
onOpenChange={(open) => {
if (!deleting) setDeleteOpen(open);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete this message and all messages after it?</DialogTitle>
<DialogDescription>
This removes the selected message and every later message in this chat. This cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteOpen(false)}
disabled={deleting}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => void confirmDelete()}
disabled={deleting}
>
{deleting ? 'Deleting…' : 'Delete'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -383,7 +476,7 @@ export function MessageBubble({ message, sessionChats }: Props) {
if (message.role === 'user') {
return (
<div className="group flex flex-col items-end gap-1">
<div className="max-w-[80%] rounded-lg bg-primary text-primary-foreground px-3 py-2 text-sm whitespace-pre-wrap">
<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>
<ActionRow message={message} />
@@ -402,7 +495,7 @@ export function MessageBubble({ message, sessionChats }: Props) {
<ToolCallCard key={tc.id} toolCall={tc} />
))}
{(hasContent || (!hasToolCalls && isStreaming)) && (
<div className="max-w-[90%] text-sm leading-relaxed space-y-2">
<div className="max-w-[90%] text-sm leading-relaxed space-y-2 break-words min-w-0">
{hasContent ? <MarkdownBody content={message.content} /> : null}
{isStreaming && (
<span className="inline-block w-1.5 h-3.5 align-baseline bg-muted-foreground/60 animate-pulse" />

View File

@@ -0,0 +1,207 @@
import { useState } from 'react';
import {
Bot,
ChevronDown,
Edit2,
MessageSquare,
MoreHorizontal,
Terminal,
X,
} from 'lucide-react';
import { toast } from 'sonner';
import type { Chat, WorkspacePane } from '@/api/types';
import { BottomSheet } from '@/components/BottomSheet';
import { StatusDot } from '@/components/StatusDot';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useLongPress } from '@/hooks/useLongPress';
import { cn } from '@/lib/utils';
interface Props {
panes: WorkspacePane[];
activePaneIdx: number;
chats: Chat[];
onSwitchPane: (idx: number) => void;
onRemovePane: (idx: number) => void;
onRenameChat: (chatId: string, name: string) => Promise<void>;
}
function paneIcon(kind: WorkspacePane['kind']) {
if (kind === 'terminal') return <Terminal size={14} />;
if (kind === 'agent') return <Bot size={14} />;
return <MessageSquare size={14} />;
}
function paneActiveChatId(pane: WorkspacePane | undefined): string | null {
if (!pane) return null;
if (pane.chatId) return pane.chatId;
const idx = pane.activeChatIdx;
if (idx < 0 || idx >= pane.chatIds.length) return null;
return pane.chatIds[idx] ?? null;
}
function paneLabel(pane: WorkspacePane, chats: Chat[]): string {
const cid = paneActiveChatId(pane);
if (cid) {
const c = chats.find((x) => x.id === cid);
if (c) return c.name ?? 'New chat';
}
if (pane.kind === 'chat') return 'Chat';
if (pane.kind === 'terminal') return 'Terminal';
if (pane.kind === 'agent') return 'Agent';
return 'Empty';
}
export function MobileTabSwitcher({
panes,
activePaneIdx,
chats,
onSwitchPane,
onRemovePane,
onRenameChat,
}: Props) {
const [open, setOpen] = useState(false);
const [renamingChatId, setRenamingChatId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
const active = panes[activePaneIdx];
const activeLabel = active ? paneLabel(active, chats) : 'Empty';
const activeChatId = paneActiveChatId(active);
// Long-press mirrors ChatTabBar: synthesize a contextmenu event on the row
// so the trailing kebab's Radix DropdownMenu opens at the touch point.
const longPress = useLongPress(({ clientX, clientY, target }) => {
if (!target || !(target instanceof Element)) return;
const row = target.closest('[data-pane-id]') as HTMLElement | null;
if (!row) return;
const trigger = row.querySelector('[data-pane-kebab]') as HTMLElement | null;
if (trigger) {
trigger.click();
return;
}
row.dispatchEvent(
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX, clientY }),
);
});
function startRename(chatId: string, currentName: string | null) {
setRenamingChatId(chatId);
setRenameValue(currentName ?? '');
}
async function finishRename() {
if (renamingChatId && renameValue.trim()) {
try {
await onRenameChat(renamingChatId, renameValue.trim());
} catch (err) {
toast.error(err instanceof Error ? err.message : 'rename failed');
}
}
setRenamingChatId(null);
}
function handleSwitchPane(idx: number) {
onSwitchPane(idx);
setOpen(false);
}
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="flex-1 inline-flex items-center gap-1.5 min-h-[44px] px-3 text-sm rounded-full bg-muted/40 hover:bg-muted/70 text-foreground min-w-0"
aria-label="Switch pane"
>
<span className="shrink-0 text-muted-foreground">{paneIcon(active?.kind ?? 'chat')}</span>
<StatusDot chatId={activeChatId} />
<span className="truncate flex-1 text-left">{activeLabel}</span>
<ChevronDown size={14} className="opacity-60 shrink-0" />
</button>
<BottomSheet open={open} onClose={() => setOpen(false)} title="Panes">
<ul className="px-2 py-2 space-y-1">
{panes.map((pane, idx) => {
const isActive = idx === activePaneIdx;
const cid = paneActiveChatId(pane);
const chat = cid ? chats.find((c) => c.id === cid) ?? null : null;
const label = paneLabel(pane, chats);
return (
<li
key={pane.id}
data-pane-id={pane.id}
onTouchStart={longPress.onTouchStart}
onTouchMove={longPress.onTouchMove}
onTouchEnd={longPress.onTouchEnd}
onTouchCancel={longPress.onTouchCancel}
onClick={() => handleSwitchPane(idx)}
style={{ WebkitTouchCallout: 'none' }}
className={cn(
'flex items-center gap-2 px-3 py-2 rounded min-h-[48px] cursor-default select-none',
isActive
? 'bg-accent/40 border-l-2 border-primary'
: 'hover:bg-muted/50',
)}
>
<span className="shrink-0 text-muted-foreground">{paneIcon(pane.kind)}</span>
<StatusDot chatId={cid ?? null} />
{renamingChatId === cid && cid ? (
<input
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={() => void finishRename()}
onKeyDown={(e) => {
if (e.key === 'Enter') void finishRename();
if (e.key === 'Escape') setRenamingChatId(null);
}}
onClick={(e) => e.stopPropagation()}
className="bg-transparent border-b border-border text-sm outline-none flex-1 min-w-0"
/>
) : (
<span className="truncate flex-1 text-sm">{label}</span>
)}
{isActive && (
<span aria-hidden="true" className="text-primary text-xs shrink-0">
</span>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
data-pane-kebab
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground min-h-[44px] min-w-[44px]"
aria-label="Pane options"
>
<MoreHorizontal size={14} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{chat && (
<DropdownMenuItem onSelect={() => startRename(chat.id, chat.name)}>
<Edit2 size={14} /> Rename chat
</DropdownMenuItem>
)}
<DropdownMenuItem
disabled={panes.length <= 1}
onSelect={() => onRemovePane(idx)}
>
<X size={14} /> Close pane
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</li>
);
})}
</ul>
{/* v1.8: New-pane button moved out of the sheet to the header row 2
(see NewPaneMenu). Sheet is for switching only. */}
</BottomSheet>
</>
);
}

View File

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

View File

@@ -1,116 +0,0 @@
import type { DragEvent } from 'react';
import { FolderOpen, MessageSquare, X } from 'lucide-react';
import type { Pane, PaneKind } from '@/api/types';
import { cn } from '@/lib/utils';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from '@/components/ui/context-menu';
interface Props {
pane: Pane;
isActive: boolean;
onClick: () => void;
onClose: () => void;
onSplit: (kind: PaneKind) => void;
onCloseOthers: () => void;
onCloseToRight: () => void;
onCloseAll: () => void;
onDragStart: (e: DragEvent<HTMLDivElement>) => void;
onDragOver: (e: DragEvent<HTMLDivElement>) => void;
onDrop: (e: DragEvent<HTMLDivElement>) => void;
}
function basename(path: string): string {
if (!path) return '';
const parts = path.split('/');
return parts[parts.length - 1] ?? path;
}
function labelFor(pane: Pane): string {
if (pane.kind === 'chat') return 'Chat';
const openFile = pane.state.open_file;
if (openFile) return basename(openFile);
return 'Files';
}
export function PaneTab({
pane,
isActive,
onClick,
onClose,
onSplit,
onCloseOthers,
onCloseToRight,
onCloseAll,
onDragStart,
onDragOver,
onDrop,
}: Props) {
const Icon = pane.kind === 'chat' ? MessageSquare : FolderOpen;
const label = labelFor(pane);
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<div
draggable
onDragStart={onDragStart}
onDragOver={onDragOver}
onDrop={onDrop}
onClick={onClick}
className={cn(
'group flex items-center gap-1.5 px-3 py-1.5 text-xs border-r border-border cursor-default select-none',
isActive
? 'bg-background text-foreground'
: 'bg-muted/30 text-muted-foreground hover:bg-muted/60'
)}
role="tab"
aria-selected={isActive}
>
<Icon size={12} className="shrink-0" />
<span className="truncate max-w-[160px]" title={label}>
{label}
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
className="p-0.5 hover:bg-muted rounded opacity-60 hover:opacity-100 shrink-0"
aria-label="Close tab"
>
<X size={10} />
</button>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuSub>
<ContextMenuSubTrigger>Split</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem onSelect={() => onSplit('chat')}>
<MessageSquare /> Chat
</ContextMenuItem>
<ContextMenuItem onSelect={() => onSplit('file_browser')}>
<FolderOpen /> File Browser
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSeparator />
<ContextMenuItem onSelect={onClose}>Close</ContextMenuItem>
<ContextMenuItem onSelect={onCloseOthers}>Close others</ContextMenuItem>
<ContextMenuItem onSelect={onCloseToRight}>
Close to the right
</ContextMenuItem>
<ContextMenuItem onSelect={onCloseAll}>Close all</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}

View File

@@ -19,8 +19,10 @@ import {
} from '@/components/ui/dialog';
import { AddProjectModal } from './AddProjectModal';
import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents';
import { useSidebar } from '@/hooks/useSidebar';
import { useSidebarDrawer } from '@/hooks/useSidebarDrawer';
import { useViewport } from '@/hooks/useViewport';
import { usePullToRefresh } from '@/hooks/usePullToRefresh';
import type { SidebarProject } from '@/api/types';
import { giteaUrlFor } from '@/lib/projectUrls';
import { cn } from '@/lib/utils';
@@ -186,7 +188,8 @@ export function ProjectSidebar() {
if (!trimmed) return;
try {
await api.sessions.update(sessionId, { name: trimmed });
sessionEvents.emit({ type: 'session_renamed', session_id: sessionId, name: trimmed });
// Server publishes session_renamed via broker.publishUser; useUserEvents
// forwards onto the bus. No local emit needed.
} catch (err) {
toast.error(err instanceof Error ? err.message : 'failed to rename session');
}
@@ -195,8 +198,24 @@ export function ProjectSidebar() {
const rowCls = (active: boolean) =>
active ? 'bg-sidebar-accent text-sidebar-accent-foreground' : 'hover:bg-sidebar-accent/60';
const { open: drawerOpen } = useSidebarDrawer();
const { isMobile } = useViewport();
const pull = usePullToRefresh(() => retry(), { enabled: isMobile });
// On mobile the sidebar is a slide-in drawer (fixed, z-40, off-screen by
// default). On desktop it sits inline as a normal flex column. The
// backdrop is rendered by AppShell; drawer-open state lives in
// SidebarDrawerProvider.
const asideCls = isMobile
? cn(
'fixed inset-y-0 left-0 z-40 w-60 border-r bg-sidebar text-sidebar-foreground flex flex-col',
'transition-transform duration-200 ease-out',
drawerOpen ? 'translate-x-0' : '-translate-x-full',
)
: 'w-60 shrink-0 border-r bg-sidebar text-sidebar-foreground flex flex-col h-screen';
return (
<aside className="w-60 shrink-0 border-r bg-sidebar text-sidebar-foreground flex flex-col h-screen">
<aside className={asideCls}>
<div className="px-4 py-3 border-b flex items-center justify-between">
<NavLink to="/" className="font-semibold tracking-tight text-base">
BooCode
@@ -206,7 +225,30 @@ export function ProjectSidebar() {
</Button>
</div>
<nav className="flex-1 overflow-y-auto py-2">
{isMobile && (pull.pullDist > 0 || pull.refreshing) && (
<div
className="flex items-center justify-center text-[10px] uppercase tracking-wide text-muted-foreground border-b overflow-hidden shrink-0"
style={{
height: pull.refreshing ? 32 : Math.min(pull.pullDist, 80),
transition: pull.pullDist === 0 && !pull.refreshing ? 'height 0.2s ease' : undefined,
}}
aria-live="polite"
>
{pull.refreshing
? 'Refreshing…'
: pull.pullDist >= 80
? 'Release to refresh'
: 'Pull to refresh'}
</div>
)}
<nav
className="flex-1 overflow-y-auto py-2"
onTouchStart={isMobile ? pull.onTouchStart : undefined}
onTouchMove={isMobile ? pull.onTouchMove : undefined}
onTouchEnd={isMobile ? pull.onTouchEnd : undefined}
onTouchCancel={isMobile ? pull.onTouchEnd : undefined}
>
{loading && data == null && (
<div className="space-y-2 px-2">
{[0, 1, 2, 3].map((i) => (

View File

@@ -4,8 +4,11 @@ import { api } from '@/api/client';
import type { FileEntry } from '@/api/types';
import { inferLanguage } from '@/lib/attachments';
import { sessionEvents } from '@/hooks/sessionEvents';
import { useRightRailDrawer } from '@/hooks/useRightRailDrawer';
import { useViewport } from '@/hooks/useViewport';
import { FileViewerOverlay } from '@/components/FileViewerOverlay';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
interface Props {
projectId: string;
@@ -25,6 +28,8 @@ function joinPath(parent: string, name: string): string {
}
export function RightRail({ projectId }: Props) {
const { isMobile } = useViewport();
const { open: drawerOpen, setOpen: setDrawerOpen } = useRightRailDrawer();
const [open, setOpen] = useState(() => {
try { return localStorage.getItem(`${STORAGE_KEY}.open`) !== 'false'; } catch { return true; }
});
@@ -34,7 +39,21 @@ export function RightRail({ projectId }: Props) {
const [fullFileList, setFullFileList] = useState<string[] | null>(null);
const [viewerFile, setViewerFile] = useState<{ path: string; content: string } | null>(null);
// Combined open state: on mobile use the global drawer state (toggled by
// the Session header's FolderTree button); on desktop use the persistent
// internal state.
const isOpen = isMobile ? drawerOpen : open;
const closeRail = useCallback(() => {
if (isMobile) setDrawerOpen(false);
else setOpen(false);
}, [isMobile, setDrawerOpen]);
const openRail = useCallback(() => {
if (isMobile) setDrawerOpen(true);
else setOpen(true);
}, [isMobile, setDrawerOpen]);
useEffect(() => {
// best-effort; ignore failure because localStorage may be unavailable (quota, private mode)
try { localStorage.setItem(`${STORAGE_KEY}.open`, String(open)); } catch {}
}, [open]);
@@ -55,9 +74,9 @@ export function RightRail({ projectId }: Props) {
}, [projectId]);
useEffect(() => {
if (!open) return;
if (!isOpen) return;
if (!cache.has('')) void loadDir('');
}, [open, cache, loadDir]);
}, [isOpen, cache, loadDir]);
function toggleDir(dirPath: string) {
setExpandedDirs((prev) => {
@@ -107,12 +126,14 @@ export function RightRail({ projectId }: Props) {
useEffect(() => {
return sessionEvents.subscribe((event) => {
if (event.type !== 'open_file_in_browser') return;
if (!open) setOpen(true);
if (!isOpen) openRail();
void openFile(event.path);
});
}, [open, projectId]);
}, [isOpen, openRail, projectId]);
if (!open) {
// Desktop closed state: render the floating chevron handle. Mobile never
// shows the handle — the toggle lives in the Session header on mobile.
if (!isMobile && !open) {
return (
<button
type="button"
@@ -127,15 +148,25 @@ export function RightRail({ projectId }: Props) {
const rootEntries = cache.get('') ?? [];
// Mobile: render as fixed-position right-side drawer (always mounted so
// the transform transition can animate in/out). Desktop: inline aside.
const asideCls = isMobile
? cn(
'fixed inset-y-0 right-0 z-40 w-[85vw] max-w-sm border-l bg-sidebar flex flex-col overflow-hidden',
'transition-transform duration-200 ease-out',
drawerOpen ? 'translate-x-0' : 'translate-x-full',
)
: 'w-64 shrink-0 border-l bg-sidebar flex flex-col h-full overflow-hidden';
return (
<>
<aside className="w-64 shrink-0 border-l bg-sidebar flex flex-col h-full overflow-hidden">
<aside className={asideCls}>
<div className="flex items-center gap-2 px-3 py-2 border-b shrink-0">
<span className="text-xs font-medium flex-1">Files</span>
<button
type="button"
onClick={() => setOpen(false)}
className="p-1 rounded hover:bg-muted text-muted-foreground"
onClick={closeRail}
className="p-1 rounded hover:bg-muted text-muted-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Close file browser"
>
<PanelRightClose size={14} />

View File

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

View File

@@ -0,0 +1,103 @@
import { useRef, useState } from 'react';
import type { TouchEvent } from 'react';
import { cn } from '@/lib/utils';
interface Props {
label: string;
isActive: boolean;
onTap: () => void;
onClose: () => void;
canClose: boolean;
}
const CLOSE_THRESHOLD = 60;
const MAX_TRAVEL = 120;
const VERTICAL_BAIL = 30;
// Pane tab with horizontal swipe-to-close (mobile only). Tracks horizontal
// finger movement; if vertical exceeds VERTICAL_BAIL the gesture is cancelled
// (so vertical scroll still works). On release past CLOSE_THRESHOLD, the
// onClose callback fires. Otherwise the tab snaps back. Hand-rolled per spec.
export function SwipeablePaneTab({ label, isActive, onTap, onClose, canClose }: Props) {
const [translateX, setTranslateX] = useState(0);
const [dragging, setDragging] = useState(false);
const startRef = useRef<{ x: number; y: number; bailed: boolean } | null>(null);
const onTouchStart = (e: TouchEvent) => {
if (!canClose) return;
const t = e.touches[0];
if (!t) return;
startRef.current = { x: t.clientX, y: t.clientY, bailed: false };
setDragging(true);
};
const onTouchMove = (e: TouchEvent) => {
const start = startRef.current;
if (!start || start.bailed) return;
const t = e.touches[0];
if (!t) return;
const dx = t.clientX - start.x;
const dy = t.clientY - start.y;
if (Math.abs(dy) > VERTICAL_BAIL) {
start.bailed = true;
setTranslateX(0);
setDragging(false);
return;
}
if (dx < 0) {
setTranslateX(Math.max(dx, -MAX_TRAVEL));
} else {
setTranslateX(0);
}
};
const onTouchEnd = () => {
const start = startRef.current;
startRef.current = null;
setDragging(false);
if (!start || start.bailed) {
setTranslateX(0);
return;
}
const tx = translateX;
if (tx <= -CLOSE_THRESHOLD) {
onClose();
// Don't reset translateX; the parent will unmount this tab.
} else {
setTranslateX(0);
}
};
// Opacity fades from 1 -> 0.4 as the tab approaches the close threshold.
const opacity =
translateX < 0
? Math.max(0.4, 1 - (Math.abs(translateX) / CLOSE_THRESHOLD) * 0.6)
: 1;
return (
<button
type="button"
onClick={onTap}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
onTouchCancel={onTouchEnd}
style={{
transform: `translateX(${translateX}px)`,
opacity,
// Only animate when releasing (snap-back); during drag the transform
// tracks the finger 1:1 for a tight feel.
transition: dragging ? undefined : 'transform 0.15s ease, opacity 0.15s ease',
}}
className={cn(
'shrink-0 px-3 py-2 text-xs rounded min-h-[44px] min-w-[44px]',
isActive
? 'bg-background text-foreground border'
: 'text-muted-foreground hover:bg-muted/40',
)}
aria-current={isActive ? 'true' : undefined}
>
<span className="truncate max-w-[140px] inline-block">{label}</span>
</button>
);
}

View File

@@ -1,10 +1,8 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { DragEvent } from 'react';
import { PanelRight, MessageSquare, Terminal, Bot } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents';
import type { Chat, WorkspacePane } from '@/api/types';
import { MAX_PANES, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes';
import type { UseSessionChatsResult } from '@/hooks/useSessionChats';
import { useViewport } from '@/hooks/useViewport';
import { ChatPane } from '@/components/panes/ChatPane';
import { ChatTabBar } from '@/components/ChatTabBar';
import { SessionLandingPage } from '@/components/SessionLandingPage';
@@ -19,378 +17,55 @@ import { cn } from '@/lib/utils';
interface Props {
sessionId: string;
projectId: string;
// Batch 9: threaded down to ChatPane → ChatInput → AgentPicker.
agentId?: string | null;
onAgentChange?: (agentId: string | null) => void | Promise<void>;
// v1.8: panes + chats hoisted into Session.tsx so the mobile header pill
// (MobileTabSwitcher) can share state with the pane grid.
panesHook: UseWorkspacePanesResult;
chatsHook: UseSessionChatsResult;
}
const MAX_PANES = 5;
const STORAGE_KEY = 'boocode.workspace.panes';
export function Workspace({
sessionId,
projectId,
agentId,
onAgentChange,
panesHook,
chatsHook,
}: Props) {
const {
panes,
activePaneIdx,
setActivePaneIdx,
openChatInPane,
switchTab,
removeTab,
closeOtherTabs,
closeTabsToRight,
closeAllTabs,
showLandingPage,
addSplitPane,
removePane,
handlePaneDragStart,
handlePaneDragOver,
handlePaneDragLeave,
handlePaneDrop,
handlePaneDragEnd,
dragOverIdx,
draggingIdxRef,
} = panesHook;
const {
chats,
createChat,
archiveChat,
unarchiveChat,
deleteChat,
renameChat,
handleLandingSend,
} = chatsHook;
function generateId(): string {
return crypto.randomUUID();
}
function emptyPane(): WorkspacePane {
return { id: generateId(), kind: 'empty', chatIds: [], activeChatIdx: -1 };
}
function chatPane(chatId: string): WorkspacePane {
return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 };
}
function loadPanes(sessionId: string): WorkspacePane[] | null {
try {
const raw = localStorage.getItem(`${STORAGE_KEY}.${sessionId}`);
if (!raw) return null;
const parsed = JSON.parse(raw) as WorkspacePane[];
if (!Array.isArray(parsed) || parsed.length === 0) return null;
return parsed;
} catch {
return null;
}
}
function savePanes(sessionId: string, panes: WorkspacePane[]): void {
try {
localStorage.setItem(`${STORAGE_KEY}.${sessionId}`, JSON.stringify(panes));
} catch { /* quota or disabled */ }
}
export function Workspace({ sessionId, projectId }: Props) {
const [panes, setPanes] = useState<WorkspacePane[]>(() => {
return loadPanes(sessionId) ?? [emptyPane()];
});
const [activePaneIdx, setActivePaneIdx] = useState(0);
const [chats, setChats] = useState<Chat[]>([]);
const chatsRef = useRef<Chat[]>([]);
chatsRef.current = chats;
const draggingIdxRef = useRef<number | null>(null);
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
useEffect(() => {
let cancelled = false;
api.chats.listForSession(sessionId).then((list) => {
if (cancelled) return;
setChats(list);
const openChat = list.find((c) => c.status === 'open');
if (openChat) {
setPanes((prev) => {
if (prev.length === 1 && prev[0]!.kind === 'empty') {
return [chatPane(openChat.id)];
}
return prev;
});
}
}).catch(() => {});
return () => { cancelled = true; };
}, [sessionId]);
useEffect(() => {
savePanes(sessionId, panes);
}, [sessionId, panes]);
useEffect(() => {
return sessionEvents.subscribe((event) => {
if (event.type === 'chat_created' && event.session_id === sessionId) {
setChats((prev) => {
if (prev.some((c) => c.id === event.chat.id)) return prev;
return [event.chat, ...prev];
});
}
if (event.type === 'chat_updated') {
setChats((prev) => prev.map((c) =>
c.id === event.chat_id ? { ...c, name: event.name, updated_at: event.updated_at } : c
));
}
if (event.type === 'chat_archived') {
setChats((prev) => prev.map((c) =>
c.id === event.chat_id ? { ...c, status: 'archived' as const } : c
));
removeChatFromPanes(event.chat_id);
}
if (event.type === 'chat_unarchived') {
setChats((prev) => {
if (prev.some((c) => c.id === event.chat.id)) {
return prev.map((c) => c.id === event.chat.id ? { ...c, status: 'open' as const } : c);
}
return [event.chat, ...prev];
});
}
if (event.type === 'chat_deleted') {
setChats((prev) => prev.filter((c) => c.id !== event.chat_id));
removeChatFromPanes(event.chat_id);
}
});
}, [sessionId]);
function removeChatFromPanes(chatId: string) {
setPanes((prev) => prev.map((p) => {
const idx = p.chatIds.indexOf(chatId);
if (idx < 0) return p;
const nextIds = p.chatIds.filter((id) => id !== chatId);
if (nextIds.length === 0) {
return { ...p, kind: 'empty' as const, chatId: undefined, chatIds: [], activeChatIdx: -1 };
}
const nextActiveIdx = Math.min(p.activeChatIdx, nextIds.length - 1);
return {
...p,
chatIds: nextIds,
activeChatIdx: nextActiveIdx,
chatId: nextIds[nextActiveIdx],
};
}));
}
const openChatInPane = useCallback((paneIdx: number, chatId: string) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
const existing = pane.chatIds.indexOf(chatId);
if (existing >= 0) {
next[paneIdx] = { ...pane, kind: 'chat', chatId, activeChatIdx: existing };
} else {
const newIds = [...pane.chatIds, chatId];
next[paneIdx] = {
...pane,
kind: 'chat',
chatId,
chatIds: newIds,
activeChatIdx: newIds.length - 1,
};
}
return next;
});
setActivePaneIdx(paneIdx);
}, []);
const switchTab = useCallback((paneIdx: number, tabIdx: number) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
const chatId = pane.chatIds[tabIdx];
if (!chatId) return prev;
next[paneIdx] = { ...pane, chatId, activeChatIdx: tabIdx };
return next;
});
}, []);
const removeTab = useCallback((paneIdx: number, chatId: string) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
const nextIds = pane.chatIds.filter((id) => id !== chatId);
if (nextIds.length === 0) {
next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined, chatIds: [], activeChatIdx: -1 };
} else {
const nextActiveIdx = Math.min(pane.activeChatIdx, nextIds.length - 1);
next[paneIdx] = {
...pane,
chatIds: nextIds,
activeChatIdx: nextActiveIdx,
chatId: nextIds[nextActiveIdx],
};
}
return next;
});
}, []);
// Keep only the right-clicked tab open in this pane.
const closeOtherTabs = useCallback((paneIdx: number, keepChatId: string) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
const keepIdx = pane.chatIds.indexOf(keepChatId);
if (keepIdx < 0) return prev;
next[paneIdx] = {
...pane,
kind: 'chat',
chatId: keepChatId,
chatIds: [keepChatId],
activeChatIdx: 0,
};
return next;
});
}, []);
// Close every tab to the right of the right-clicked one.
const closeTabsToRight = useCallback((paneIdx: number, pivotChatId: string) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
const pivotIdx = pane.chatIds.indexOf(pivotChatId);
if (pivotIdx < 0 || pivotIdx === pane.chatIds.length - 1) return prev;
const nextIds = pane.chatIds.slice(0, pivotIdx + 1);
const nextActiveIdx = Math.min(pane.activeChatIdx, nextIds.length - 1);
next[paneIdx] = {
...pane,
chatIds: nextIds,
activeChatIdx: nextActiveIdx,
chatId: nextIds[nextActiveIdx],
};
return next;
});
}, []);
// Close every tab in this pane; land on landing page.
const closeAllTabs = useCallback((paneIdx: number) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined, chatIds: [], activeChatIdx: -1 };
return next;
});
}, []);
const createChat = useCallback(async (paneIdx: number) => {
try {
const chat = await api.chats.create(sessionId);
// Optimistic local insert; the WS chat_created echo will be deduped by id.
setChats((prev) => {
if (prev.some((c) => c.id === chat.id)) return prev;
return [chat, ...prev];
});
openChatInPane(paneIdx, chat.id);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to create chat');
}
}, [sessionId, openChatInPane]);
const archiveChat = useCallback(async (chatId: string) => {
try {
await api.chats.archive(chatId);
// Server publishes chat_archived; bus forwarder updates state.
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to archive chat');
}
}, []);
const unarchiveChat = useCallback(async (chatId: string) => {
try {
await api.chats.unarchive(chatId);
// Server publishes chat_unarchived.
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to restore chat');
}
}, []);
const deleteChat = useCallback(async (chatId: string) => {
try {
await api.chats.remove(chatId);
setChats((prev) => prev.filter((c) => c.id !== chatId));
removeChatFromPanes(chatId);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to delete chat');
}
}, []);
const renameChat = useCallback(async (chatId: string, name: string) => {
try {
await api.chats.update(chatId, { name });
setChats((prev) => prev.map((c) =>
c.id === chatId ? { ...c, name } : c
));
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to rename chat');
}
}, []);
const showLandingPage = useCallback((paneIdx: number) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined };
return next;
});
}, []);
const addSplitPane = useCallback((kind: 'chat' | 'terminal' | 'agent') => {
if (kind === 'terminal') {
toast('Terminal panes coming in BooTerm');
return;
}
if (kind === 'agent') {
toast('Agent panes coming in BooCoder');
return;
}
setPanes((prev) => {
if (prev.length >= MAX_PANES) {
toast.error(`Maximum ${MAX_PANES} panes`);
return prev;
}
const next = [...prev, emptyPane()];
setActivePaneIdx(next.length - 1);
return next;
});
}, []);
const removePane = useCallback((idx: number) => {
setPanes((prev) => {
if (prev.length <= 1) return prev;
const next = prev.filter((_, i) => i !== idx);
setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
return next;
});
}, []);
const handlePaneDragStart = useCallback(
(idx: number) => (e: DragEvent<HTMLDivElement>) => {
draggingIdxRef.current = idx;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(idx));
},
[]
);
const handlePaneDragOver = useCallback(
(idx: number) => (e: DragEvent<HTMLDivElement>) => {
if (draggingIdxRef.current === null) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (dragOverIdx !== idx) setDragOverIdx(idx);
},
[dragOverIdx]
);
const handlePaneDragLeave = useCallback(() => {
setDragOverIdx(null);
}, []);
const handlePaneDrop = useCallback(
(targetIdx: number) => (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
const fromIdx = draggingIdxRef.current;
draggingIdxRef.current = null;
setDragOverIdx(null);
if (fromIdx === null || fromIdx === targetIdx) return;
setPanes((prev) => {
const next = [...prev];
const [moved] = next.splice(fromIdx, 1);
if (!moved) return prev;
next.splice(targetIdx, 0, moved);
// Keep active selection on the same logical pane (the one being dragged).
setActivePaneIdx(targetIdx);
return next;
});
},
[]
);
const handlePaneDragEnd = useCallback(() => {
draggingIdxRef.current = null;
setDragOverIdx(null);
}, []);
const handleLandingSend = useCallback(async (paneIdx: number, content: string) => {
try {
const chat = await api.chats.create(sessionId);
setChats((prev) => {
if (prev.some((c) => c.id === chat.id)) return prev;
return [chat, ...prev];
});
openChatInPane(paneIdx, chat.id);
await api.messages.send(chat.id, content);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to send');
}
}, [sessionId, openChatInPane]);
const { isMobile } = useViewport();
function chatsForPane(pane: WorkspacePane): Chat[] {
return pane.chatIds
@@ -400,78 +75,101 @@ export function Workspace({ sessionId, projectId }: Props) {
return (
<div className="flex flex-col h-full min-h-0">
<div className="flex items-center gap-2 border-b border-border bg-muted/20 px-3 py-1.5 shrink-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={panes.length >= MAX_PANES}
className={cn(
'flex items-center gap-1 text-xs px-2 py-1 rounded hover:bg-muted',
panes.length >= MAX_PANES && 'opacity-40 cursor-not-allowed hover:bg-transparent'
)}
>
<PanelRight size={14} />
Split
</button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onSelect={() => addSplitPane('chat')}>
<MessageSquare size={14} /> Chat
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => addSplitPane('terminal')}>
<Terminal size={14} /> Terminal
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => addSplitPane('agent')}>
<Bot size={14} /> Agent
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{!isMobile && (
<div className="flex items-center gap-2 border-b border-border bg-muted/20 px-3 py-1.5 shrink-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={panes.length >= MAX_PANES}
className={cn(
'flex items-center gap-1 text-xs px-2 py-1 rounded hover:bg-muted',
panes.length >= MAX_PANES && 'opacity-40 cursor-not-allowed hover:bg-transparent'
)}
>
<PanelRight size={14} />
Split
</button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onSelect={() => addSplitPane('chat')}>
<MessageSquare size={14} /> Chat
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => addSplitPane('terminal')}>
<Terminal size={14} /> Terminal
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => addSplitPane('agent')}>
<Bot size={14} /> Agent
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
{/* v1.8: mobile multi-pane SwipeablePaneTab strip removed; the header
pill (MobileTabSwitcher) is the mobile pane switcher. */}
<div
className="flex-1 grid min-h-0"
style={{
gridTemplateColumns: `repeat(${panes.length}, minmax(0, 1fr))`,
}}
className={cn('flex-1 min-h-0', isMobile ? 'flex' : 'grid')}
style={
isMobile
? undefined
: { gridTemplateColumns: `repeat(${panes.length}, minmax(0, 1fr))` }
}
>
{panes.map((pane, idx) => (
{panes.map((pane, idx) => {
const visible = !isMobile || idx === activePaneIdx;
if (!visible) return null;
return (
<div
key={pane.id}
className={cn(
'flex flex-col h-full min-h-0 border-r border-border last:border-r-0 relative',
idx === activePaneIdx && 'ring-1 ring-inset ring-ring/20',
dragOverIdx === idx && draggingIdxRef.current !== idx &&
isMobile ? 'flex-1 w-full' : undefined,
!isMobile && idx === activePaneIdx && 'ring-1 ring-inset ring-ring/20',
!isMobile && dragOverIdx === idx && draggingIdxRef.current !== idx &&
'before:absolute before:inset-y-0 before:left-0 before:w-0.5 before:bg-primary before:z-10'
)}
onClick={() => setActivePaneIdx(idx)}
onDragOver={panes.length > 1 ? handlePaneDragOver(idx) : undefined}
onDragLeave={panes.length > 1 ? handlePaneDragLeave : undefined}
onDrop={panes.length > 1 ? handlePaneDrop(idx) : undefined}
onDragOver={!isMobile && panes.length > 1 ? handlePaneDragOver(idx) : undefined}
onDragLeave={!isMobile && panes.length > 1 ? handlePaneDragLeave : undefined}
onDrop={!isMobile && panes.length > 1 ? handlePaneDrop(idx) : undefined}
>
<div
draggable={panes.length > 1}
onDragStart={panes.length > 1 ? handlePaneDragStart(idx) : undefined}
onDragEnd={panes.length > 1 ? handlePaneDragEnd : undefined}
draggable={!isMobile && panes.length > 1}
onDragStart={!isMobile && panes.length > 1 ? handlePaneDragStart(idx) : undefined}
onDragEnd={!isMobile && panes.length > 1 ? handlePaneDragEnd : undefined}
>
<ChatTabBar
pane={pane}
tabs={chatsForPane(pane)}
onSwitchTab={(tabIdx) => switchTab(idx, tabIdx)}
onRemoveTab={(chatId) => removeTab(idx, chatId)}
onCloseOthers={(chatId) => closeOtherTabs(idx, chatId)}
onCloseToRight={(chatId) => closeTabsToRight(idx, chatId)}
onCloseAll={() => closeAllTabs(idx)}
onNewChat={() => void createChat(idx)}
onShowHistory={() => showLandingPage(idx)}
onRename={renameChat}
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
/>
{/* Hidden on mobile per v1.8: chat-within-pane navigation
is not exposed on small screens; users switch panes via
the header pill instead. */}
{!isMobile && (
<ChatTabBar
pane={pane}
tabs={chatsForPane(pane)}
onSwitchTab={(tabIdx) => switchTab(idx, tabIdx)}
onRemoveTab={(chatId) => removeTab(idx, chatId)}
onCloseOthers={(chatId) => closeOtherTabs(idx, chatId)}
onCloseToRight={(chatId) => closeTabsToRight(idx, chatId)}
onCloseAll={() => closeAllTabs(idx)}
onNewChat={() => void createChat(idx)}
onShowHistory={() => showLandingPage(idx)}
onRename={renameChat}
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
/>
)}
</div>
<div className="flex-1 min-h-0 overflow-hidden">
{pane.kind === 'chat' && pane.chatId ? (
<ChatPane sessionId={sessionId} chatId={pane.chatId} projectId={projectId} sessionChats={chats} />
<ChatPane
sessionId={sessionId}
chatId={pane.chatId}
projectId={projectId}
agentId={agentId}
onAgentChange={onAgentChange}
sessionChats={chats}
/>
) : (
<SessionLandingPage
sessionId={sessionId}
@@ -490,7 +188,8 @@ export function Workspace({ sessionId, projectId }: Props) {
)}
</div>
</div>
))}
);
})}
</div>
</div>
);

View File

@@ -3,8 +3,10 @@ import { ChevronDown, Square, X } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import { useSessionStream } from '@/hooks/useSessionStream';
import { useChatContextStats } from '@/hooks/useChatContextStats';
import { MessageList } from '@/components/MessageList';
import { ChatInput } from '@/components/ChatInput';
import { ChatContextPopover } from '@/components/ChatContextPopover';
import {
DropdownMenu,
DropdownMenuContent,
@@ -16,10 +18,13 @@ interface Props {
sessionId: string;
chatId: string;
projectId: string;
// Batch 9: optional, threaded down to ChatInput's agent picker.
agentId?: string | null;
onAgentChange?: (agentId: string | null) => void | Promise<void>;
sessionChats?: import('@/api/types').Chat[];
}
export function ChatPane({ sessionId, chatId, projectId, sessionChats }: Props) {
export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange, sessionChats }: Props) {
const stream = useSessionStream(sessionId);
const lastErrorRef = useRef<string | null>(null);
const [queue, setQueue] = useState<string[]>([]);
@@ -37,6 +42,7 @@ export function ChatPane({ sessionId, chatId, projectId, sessionChats }: Props)
const chatMessages = stream.messages.filter((m) => m.chat_id === chatId);
const streaming = chatMessages.some((m) => m.status === 'streaming');
const contextStats = useChatContextStats(chatId, chatMessages);
// Auto-send next queued message when streaming completes
useEffect(() => {
@@ -117,7 +123,7 @@ export function ChatPane({ sessionId, chatId, projectId, sessionChats }: Props)
<DropdownMenuTrigger asChild>
<button
type="button"
className="p-0.5 hover:bg-muted rounded shrink-0"
className="inline-flex items-center justify-center p-0.5 hover:bg-muted rounded shrink-0 max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Queued message options"
>
<ChevronDown size={12} />
@@ -135,7 +141,7 @@ export function ChatPane({ sessionId, chatId, projectId, sessionChats }: Props)
<button
type="button"
onClick={() => removeQueued(i)}
className="p-0.5 hover:bg-muted rounded shrink-0"
className="inline-flex items-center justify-center p-0.5 hover:bg-muted rounded shrink-0 max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Cancel queued message"
>
<X size={12} />
@@ -153,7 +159,7 @@ export function ChatPane({ sessionId, chatId, projectId, sessionChats }: Props)
<button
type="button"
onClick={() => void handleStop()}
className="flex items-center gap-1.5 text-xs px-3 py-1 rounded-full border hover:bg-muted text-muted-foreground hover:text-foreground"
className="flex items-center gap-1.5 text-xs px-3 py-1 rounded-full border hover:bg-muted text-muted-foreground hover:text-foreground max-md:min-h-[44px] max-md:px-5"
>
<Square size={10} className="fill-current" />
Stop generating
@@ -162,7 +168,17 @@ export function ChatPane({ sessionId, chatId, projectId, sessionChats }: Props)
</div>
)}
<ChatInput disabled={false} projectId={projectId} onSend={handleSend} onForceSend={streaming ? handleForceSend : undefined} />
<div className="relative">
<ChatContextPopover stats={contextStats} />
<ChatInput
disabled={false}
projectId={projectId}
agentId={agentId}
onAgentChange={onAgentChange}
onSend={handleSend}
onForceSend={streaming ? handleForceSend : undefined}
/>
</div>
</div>
);
}

View File

@@ -1,865 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { KeyboardEvent } from 'react';
import { Check, ChevronRight, ChevronDown, Copy, FileText, Folder, X } from 'lucide-react';
import { codeToHtml } from 'shiki';
import { api, ApiError } from '@/api/client';
import type {
FileBrowserPaneState,
FileEntry,
Pane,
ViewFileResult,
} from '@/api/types';
import { inferLanguage } from '@/lib/attachments';
import { sessionEvents } from '@/hooks/sessionEvents';
import { cn } from '@/lib/utils';
interface Props {
pane: Pane & { kind: 'file_browser' };
projectId: string;
onStateChange: (state: FileBrowserPaneState) => void;
}
const SHIKI_THEME = 'github-dark';
function splitShikiLines(html: string): string[] {
const match = html.match(/<code[^>]*>([\s\S]*)<\/code>/);
if (!match) return [];
const inner = match[1]!;
const lines = inner.split(/(?=<span class="line">)/);
return lines.filter(l => l.trim().length > 0);
}
interface FileViewerProps {
code: string;
lang: string | null;
selectedLines: Set<number>;
onLineClick: (lineNo: number, shiftKey: boolean) => void;
}
function FileViewer({ code, lang, selectedLines, onLineClick }: FileViewerProps) {
const [copied, setCopied] = useState(false);
const [lineHtmls, setLineHtmls] = useState<string[] | null>(null);
useEffect(() => {
let cancelled = false;
if (!lang) {
setLineHtmls(null);
return;
}
(async () => {
try {
const result = await codeToHtml(code, { lang, theme: SHIKI_THEME });
if (cancelled) return;
const lines = splitShikiLines(result);
setLineHtmls(lines.length > 0 ? lines : null);
} catch (err) {
console.warn('shiki failed', err);
if (!cancelled) setLineHtmls(null);
}
})();
return () => {
cancelled = true;
};
}, [code, lang]);
async function copy() {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 1200);
} catch {
/* ignore */
}
}
const plainLines = code.split('\n');
const totalLines = lineHtmls ? lineHtmls.length : plainLines.length;
return (
<div className="text-sm font-mono">
<div className="flex items-center justify-between px-2 py-1 border-b text-xs text-muted-foreground">
<span className="font-mono">{lang || 'code'}</span>
<button
type="button"
onClick={() => void copy()}
className="flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted text-foreground"
aria-label="Copy code"
>
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
<span>{copied ? 'Copied' : 'Copy'}</span>
</button>
</div>
<div className="overflow-x-auto">
{Array.from({ length: totalLines }, (_, i) => {
const lineNo = i + 1;
const isSelected = selectedLines.has(lineNo);
return (
<div
key={lineNo}
className={cn(
'flex',
isSelected && 'bg-blue-500/10'
)}
>
<button
type="button"
className="shrink-0 w-[3ch] text-right pr-2 text-xs text-muted-foreground select-none cursor-pointer hover:text-foreground"
style={{ fontVariantNumeric: 'tabular-nums' }}
onClick={(e) => onLineClick(lineNo, e.shiftKey)}
>
{lineNo}
</button>
{lineHtmls ? (
<div
className="flex-1 min-w-0 text-xs leading-relaxed [&>.line]:!bg-transparent"
// eslint-disable-next-line react/no-danger -- Shiki generates sanitized HTML spans, not user content
dangerouslySetInnerHTML={{ __html: lineHtmls[i] ?? '' }}
/>
) : (
<span className="flex-1 min-w-0 text-xs leading-relaxed whitespace-pre">
{plainLines[i] ?? ''}
</span>
)}
</div>
);
})}
</div>
</div>
);
}
function basename(path: string): string {
if (!path) return '';
const parts = path.split('/');
return parts[parts.length - 1] ?? path;
}
function joinPath(parent: string, name: string): string {
if (!parent || parent === '.' || parent === '') return name;
return `${parent}/${name}`;
}
interface TreeNodeProps {
parentPath: string; // '' for root children
entries: FileEntry[];
cache: Map<string, FileEntry[]>;
expanded: Set<string>;
openFile: string | null;
highlightedPath: string | null;
depth: number;
onToggleDir: (dirPath: string) => void;
onSelectFile: (path: string) => void;
setHighlightedPath: (p: string) => void;
}
function TreeNode({
parentPath,
entries,
cache,
expanded,
openFile,
highlightedPath,
depth,
onToggleDir,
onSelectFile,
setHighlightedPath,
}: TreeNodeProps) {
// Sort: dirs first, then files; alphabetical within each.
const sorted = useMemo(() => {
const copy = [...entries];
copy.sort((a, b) => {
if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1;
return a.name.localeCompare(b.name);
});
return copy;
}, [entries]);
return (
<ul className="list-none">
{sorted.map((entry) => {
const fullPath = joinPath(parentPath, entry.name);
const isExpanded = entry.kind === 'dir' && expanded.has(fullPath);
const isActive = entry.kind === 'file' && openFile === fullPath;
const isHighlight = highlightedPath === fullPath;
return (
<li key={fullPath}>
<div
data-path={fullPath}
data-kind={entry.kind}
className={cn(
'flex items-center gap-1 px-1 py-0.5 text-xs cursor-default rounded hover:bg-muted/60',
isActive && 'bg-muted',
isHighlight && 'ring-1 ring-ring/40'
)}
style={{ paddingLeft: 4 + depth * 12 }}
onClick={() => {
setHighlightedPath(fullPath);
if (entry.kind === 'dir') {
onToggleDir(fullPath);
} else {
onSelectFile(fullPath);
}
}}
>
{entry.kind === 'dir' ? (
<button
type="button"
aria-label={isExpanded ? 'Collapse' : 'Expand'}
className="p-0.5 hover:bg-muted rounded shrink-0"
onClick={(e) => {
e.stopPropagation();
setHighlightedPath(fullPath);
onToggleDir(fullPath);
}}
>
{isExpanded ? (
<ChevronDown size={10} />
) : (
<ChevronRight size={10} />
)}
</button>
) : (
<span className="w-[16px] shrink-0" />
)}
{entry.kind === 'dir' ? (
<Folder size={12} className="text-muted-foreground shrink-0" />
) : (
<FileText size={12} className="text-muted-foreground shrink-0" />
)}
<span className="truncate">{entry.name}</span>
</div>
{entry.kind === 'dir' && isExpanded && cache.has(fullPath) && (
<TreeNode
parentPath={fullPath}
entries={cache.get(fullPath) ?? []}
cache={cache}
expanded={expanded}
openFile={openFile}
highlightedPath={highlightedPath}
depth={depth + 1}
onToggleDir={onToggleDir}
onSelectFile={onSelectFile}
setHighlightedPath={setHighlightedPath}
/>
)}
</li>
);
})}
</ul>
);
}
export function FileBrowserPane({ pane, projectId, onStateChange }: Props) {
const openFile = pane.state.open_file ?? null;
const filter = pane.state.filter ?? '';
const expandedDirs = useMemo(
() => pane.state.expanded_dirs ?? [],
[pane.state.expanded_dirs]
);
// Local filter (debounced 100ms before pushing to onStateChange)
const [filterDraft, setFilterDraft] = useState(filter);
const filterDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Track previous external filter so we can sync local draft when the
// canonical state changes from outside (e.g. server snapshot, other tab).
const lastExternalFilter = useRef(filter);
useEffect(() => {
if (filter !== lastExternalFilter.current) {
lastExternalFilter.current = filter;
setFilterDraft(filter);
}
}, [filter]);
function onFilterInput(value: string) {
setFilterDraft(value);
if (filterDebounceRef.current !== null) {
clearTimeout(filterDebounceRef.current);
}
filterDebounceRef.current = setTimeout(() => {
filterDebounceRef.current = null;
lastExternalFilter.current = value;
onStateChange({
...pane.state,
filter: value,
open_file: openFile,
expanded_dirs: expandedDirs,
});
}, 100);
}
useEffect(() => {
return () => {
if (filterDebounceRef.current !== null) {
clearTimeout(filterDebounceRef.current);
}
};
}, []);
// Full file list fetched once on mount for filter mode (covers unexpanded dirs)
const [fullFileList, setFullFileList] = useState<string[] | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const result = await api.projects.files(projectId);
if (!cancelled) setFullFileList(result.files);
} catch {
// Silently ignore; filter will fall back to cache-based list
}
})();
return () => {
cancelled = true;
};
// Intentionally run once per mount (projectId is stable per pane)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId]);
// Directory cache: dirPath -> entries
const [cache, setCache] = useState<Map<string, FileEntry[]>>(new Map());
const [loadingDirs, setLoadingDirs] = useState<Set<string>>(new Set());
const [dirErrors, setDirErrors] = useState<Map<string, string>>(new Map());
const loadDir = useCallback(
async (dirPath: string) => {
// dirPath '' is root; server expects '.'
const apiPath = dirPath === '' ? '.' : dirPath;
setLoadingDirs((prev) => {
if (prev.has(dirPath)) return prev;
const next = new Set(prev);
next.add(dirPath);
return next;
});
try {
const result = await api.projects.listDir(projectId, apiPath);
setCache((prev) => {
const next = new Map(prev);
next.set(dirPath, result.entries);
return next;
});
setDirErrors((prev) => {
if (!prev.has(dirPath)) return prev;
const next = new Map(prev);
next.delete(dirPath);
return next;
});
} catch (err) {
const msg = err instanceof Error ? err.message : 'failed to list directory';
setDirErrors((prev) => {
const next = new Map(prev);
next.set(dirPath, msg);
return next;
});
} finally {
setLoadingDirs((prev) => {
if (!prev.has(dirPath)) return prev;
const next = new Set(prev);
next.delete(dirPath);
return next;
});
}
},
[projectId]
);
// Load root on mount + any expanded dirs from server state.
useEffect(() => {
if (!cache.has('')) {
void loadDir('');
}
for (const dir of expandedDirs) {
if (!cache.has(dir)) {
void loadDir(dir);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId]);
// When expandedDirs grows (e.g. user expands), ensure new dir is loaded.
useEffect(() => {
for (const dir of expandedDirs) {
if (!cache.has(dir) && !loadingDirs.has(dir)) {
void loadDir(dir);
}
}
}, [expandedDirs, cache, loadingDirs, loadDir]);
const expandedSet = useMemo(() => new Set(expandedDirs), [expandedDirs]);
function toggleDir(dirPath: string) {
let nextDirs: string[];
if (expandedSet.has(dirPath)) {
nextDirs = expandedDirs.filter((d) => d !== dirPath);
} else {
nextDirs = [...expandedDirs, dirPath];
}
onStateChange({
...pane.state,
open_file: openFile,
filter: filterDraft,
expanded_dirs: nextDirs,
});
}
function selectFile(path: string) {
onStateChange({
...pane.state,
open_file: path,
filter: filterDraft,
expanded_dirs: expandedDirs,
});
}
function closeOpenFile() {
onStateChange({
...pane.state,
open_file: null,
filter: filterDraft,
expanded_dirs: expandedDirs,
});
}
// Build a flat list of all entries reachable through the loaded cache,
// for filter results and keyboard navigation.
interface FlatEntry {
path: string;
name: string;
kind: 'file' | 'dir';
}
const flattenedVisible = useMemo<FlatEntry[]>(() => {
const result: FlatEntry[] = [];
function walk(dirPath: string) {
const entries = cache.get(dirPath);
if (!entries) return;
const sorted = [...entries].sort((a, b) => {
if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1;
return a.name.localeCompare(b.name);
});
for (const e of sorted) {
const full = joinPath(dirPath, e.name);
result.push({ path: full, name: e.name, kind: e.kind });
if (e.kind === 'dir' && expandedSet.has(full)) {
walk(full);
}
}
}
walk('');
return result;
}, [cache, expandedSet]);
const flattenedAll = useMemo<FlatEntry[]>(() => {
const result: FlatEntry[] = [];
function walk(dirPath: string) {
const entries = cache.get(dirPath);
if (!entries) return;
for (const e of entries) {
const full = joinPath(dirPath, e.name);
result.push({ path: full, name: e.name, kind: e.kind });
if (e.kind === 'dir') walk(full);
}
}
walk('');
return result;
}, [cache]);
const trimmedFilter = filterDraft.trim();
const filterActive = trimmedFilter.length > 0;
interface FilterResult {
path: string;
name: string;
}
const filterResults = useMemo<FilterResult[]>(() => {
if (!filterActive) return [];
const needle = trimmedFilter.toLowerCase();
if (fullFileList !== null) {
// Use complete file list from API; rank filename matches above path-only matches
const filenameMatches: string[] = [];
const pathOnlyMatches: string[] = [];
for (const p of fullFileList) {
const lp = p.toLowerCase();
if (!lp.includes(needle)) continue;
const bn = basename(p).toLowerCase();
if (bn.includes(needle)) {
filenameMatches.push(p);
} else {
pathOnlyMatches.push(p);
}
}
filenameMatches.sort((a, b) => a.localeCompare(b));
pathOnlyMatches.sort((a, b) => a.localeCompare(b));
return [...filenameMatches, ...pathOnlyMatches]
.slice(0, 50)
.map((p) => ({ path: p, name: basename(p) }));
}
// Fallback: use cache-based flat list (only loaded directories, files only)
return flattenedAll
.filter((e) => e.kind === 'file' && e.path.toLowerCase().includes(needle))
.slice(0, 50)
.map((e) => ({ path: e.path, name: e.name }));
}, [filterActive, trimmedFilter, fullFileList, flattenedAll]);
// Keyboard navigation
const [highlightedPath, setHighlightedPath] = useState<string | null>(null);
const treeRef = useRef<HTMLDivElement | null>(null);
// Reset highlight if it falls out of the current list (e.g. when filter
// changes or dirs collapse).
useEffect(() => {
if (!highlightedPath) return;
const list = filterActive ? filterResults : flattenedVisible;
if (!list.some((e) => e.path === highlightedPath)) {
setHighlightedPath(null);
}
}, [highlightedPath, filterActive, filterResults, flattenedVisible]);
function onTreeKeyDown(e: KeyboardEvent<HTMLDivElement>) {
if (filterActive) {
if (filterResults.length === 0) return;
const idx = highlightedPath
? filterResults.findIndex((entry) => entry.path === highlightedPath)
: -1;
if (e.key === 'ArrowDown') {
e.preventDefault();
const next = idx < 0 ? 0 : Math.min(filterResults.length - 1, idx + 1);
const target = filterResults[next];
if (target) setHighlightedPath(target.path);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
const next = idx <= 0 ? 0 : idx - 1;
const target = filterResults[next];
if (target) setHighlightedPath(target.path);
return;
}
if (e.key === 'Enter') {
if (idx < 0) return;
const target = filterResults[idx];
if (!target) return;
e.preventDefault();
// Filter results are always files (API returns only files)
selectFile(target.path);
}
return;
}
// Tree mode: use flattenedVisible which has kind info
const list = flattenedVisible;
if (list.length === 0) return;
const idx = highlightedPath
? list.findIndex((entry) => entry.path === highlightedPath)
: -1;
if (e.key === 'ArrowDown') {
e.preventDefault();
const next = idx < 0 ? 0 : Math.min(list.length - 1, idx + 1);
const target = list[next];
if (target) setHighlightedPath(target.path);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
const next = idx <= 0 ? 0 : idx - 1;
const target = list[next];
if (target) setHighlightedPath(target.path);
return;
}
if (e.key === 'Enter') {
if (idx < 0) return;
const target = list[idx];
if (!target) return;
e.preventDefault();
if (target.kind === 'dir') {
toggleDir(target.path);
} else {
selectFile(target.path);
}
}
}
// Line selection state
const [selectedLines, setSelectedLines] = useState<Set<number>>(new Set());
const [selectionAnchor, setSelectionAnchor] = useState<number | null>(null);
function handleLineClick(lineNo: number, shiftKey: boolean) {
if (shiftKey && selectionAnchor !== null) {
const start = Math.min(selectionAnchor, lineNo);
const end = Math.max(selectionAnchor, lineNo);
const range = new Set<number>();
for (let i = start; i <= end; i++) range.add(i);
setSelectedLines(range);
} else {
setSelectedLines(prev => {
const next = new Set(prev);
if (next.has(lineNo)) {
next.delete(lineNo);
} else {
next.add(lineNo);
}
return next;
});
setSelectionAnchor(lineNo);
}
}
// Viewer state
const [viewer, setViewer] = useState<{
path: string;
state: 'loading' | 'ready' | 'error';
result?: ViewFileResult;
error?: string;
} | null>(null);
useEffect(() => {
if (!openFile) {
setViewer(null);
return;
}
let cancelled = false;
setViewer({ path: openFile, state: 'loading' });
(async () => {
try {
const result = await api.projects.viewFile(projectId, openFile);
if (cancelled) return;
setViewer({ path: openFile, state: 'ready', result });
} catch (err) {
if (cancelled) return;
let message: string;
if (err instanceof ApiError) {
const apiMsg =
typeof err.body === 'object' &&
err.body !== null &&
'error' in err.body
? String((err.body as { error: unknown }).error)
: err.message;
if (err.status === 404) {
message = 'File not found';
} else if (apiMsg.toLowerCase().includes('too large')) {
message = 'File too large to view';
} else if (
apiMsg.toLowerCase().includes('outside') ||
apiMsg.toLowerCase().includes('not a file') ||
apiMsg.toLowerCase().includes('path')
) {
message = 'Cannot view files outside project';
} else {
message = apiMsg;
}
} else if (err instanceof Error) {
message = err.message;
} else {
message = 'Failed to load file';
}
setViewer({ path: openFile, state: 'error', error: message });
}
})();
return () => {
cancelled = true;
};
}, [openFile, projectId]);
// Clear line selection when open file changes
useEffect(() => {
setSelectedLines(new Set());
setSelectionAnchor(null);
}, [openFile]);
// Compute selection range for the floating action bar (loop avoids call-stack limit on spread)
let selectionMin = 0;
let selectionMax = 0;
if (selectedLines.size > 0) {
for (const n of selectedLines) {
if (selectionMin === 0 || n < selectionMin) selectionMin = n;
if (n > selectionMax) selectionMax = n;
}
}
function handleAttachLines() {
if (!openFile || !viewer?.result || selectedLines.size === 0) return;
const min = selectionMin;
const max = selectionMax;
const selectedContent = viewer.result.content
.split('\n')
.slice(min - 1, max)
.join('\n');
sessionEvents.emit({
type: 'attach_chat_file',
attachment: {
kind: 'lines',
filename: openFile,
language: inferLanguage(openFile) ?? null,
content: selectedContent,
range: [min, max],
source: 'line-select',
},
});
setSelectedLines(new Set());
setSelectionAnchor(null);
}
// Root errors / loading
const rootEntries = cache.get('');
const rootLoading = loadingDirs.has('') && !rootEntries;
const rootError = dirErrors.get('');
return (
<div className="flex flex-col h-full min-h-0">
<div className="px-2 py-1.5 border-b border-border bg-muted/20">
<input
type="text"
value={filterDraft}
onChange={(e) => onFilterInput(e.target.value)}
placeholder="Filter files..."
className="w-full px-2 py-1 text-xs bg-background border border-border rounded outline-none focus:border-ring"
aria-label="Filter files"
/>
</div>
<div className="flex-1 min-h-0 grid grid-cols-[minmax(0,260px)_1fr]">
<div
ref={treeRef}
tabIndex={0}
onKeyDown={onTreeKeyDown}
className="overflow-y-auto border-r border-border outline-none focus:ring-1 focus:ring-inset focus:ring-ring/40"
role="tree"
aria-label="Project files"
>
{rootLoading && (
<div className="text-xs text-muted-foreground px-2 py-1.5">
Loading...
</div>
)}
{rootError && (
<div className="text-xs text-destructive px-2 py-1.5">
{rootError}
</div>
)}
{!rootLoading && !rootError && filterActive && (
<ul className="list-none">
{filterResults.length === 0 ? (
<li className="text-xs text-muted-foreground px-2 py-1.5">
No matches
</li>
) : (
filterResults.map((entry) => {
const isActive = openFile === entry.path;
const isHighlight = highlightedPath === entry.path;
return (
<li key={entry.path}>
<div
className={cn(
'flex items-center gap-1 px-2 py-0.5 text-xs cursor-default rounded hover:bg-muted/60',
isActive && 'bg-muted',
isHighlight && 'ring-1 ring-ring/40'
)}
onClick={() => {
setHighlightedPath(entry.path);
selectFile(entry.path);
}}
>
<FileText size={12} className="text-muted-foreground shrink-0" />
<span className="truncate">
<span className="font-bold">{entry.name}</span>
<span className="text-muted-foreground ml-1">{entry.path}</span>
</span>
</div>
</li>
);
})
)}
</ul>
)}
{!rootLoading && !rootError && !filterActive && rootEntries && (
<TreeNode
parentPath=""
entries={rootEntries}
cache={cache}
expanded={expandedSet}
openFile={openFile}
highlightedPath={highlightedPath}
depth={0}
onToggleDir={toggleDir}
onSelectFile={selectFile}
setHighlightedPath={setHighlightedPath}
/>
)}
</div>
<div className="flex flex-col min-h-0">
{!openFile && (
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground">
Select a file to view
</div>
)}
{openFile && (
<>
<div className="flex items-center justify-between px-2 py-1 border-b border-border bg-muted/20 shrink-0">
<span
className="text-xs font-mono truncate"
title={openFile}
>
{basename(openFile)}
</span>
<button
type="button"
onClick={closeOpenFile}
className="p-0.5 hover:bg-muted rounded shrink-0"
aria-label="Close file"
>
<X size={12} />
</button>
</div>
<div className="flex-1 min-h-0 overflow-y-auto relative">
{viewer?.state === 'loading' && (
<div className="text-xs text-muted-foreground px-2 py-1.5">
Loading...
</div>
)}
{viewer?.state === 'error' && (
<div className="text-xs text-destructive px-2 py-1.5">
{viewer.error}
</div>
)}
{viewer?.state === 'ready' && viewer.result && (
<div className="p-2">
{selectedLines.size > 0 && (
<div className="sticky top-0 z-10 bg-muted border-b border-border flex items-center justify-between px-2 py-1 mb-2 rounded-t">
<span className="text-xs text-muted-foreground">
{selectedLines.size === 1
? `Attach line ${selectionMin} to chat`
: `Attach lines ${selectionMin}${selectionMax} to chat`}
</span>
<button
type="button"
className="text-xs font-medium text-primary hover:underline"
onClick={handleAttachLines}
>
Attach
</button>
</div>
)}
{viewer.result.truncated && (
<div className="text-[11px] text-muted-foreground mb-1 px-2 py-1 rounded bg-muted/40 border border-border">
Showing first {viewer.result.bytes_returned} bytes; file is {viewer.result.total_bytes} bytes total.
</div>
)}
<FileViewer
code={viewer.result.content}
lang={inferLanguage(openFile)}
selectedLines={selectedLines}
onLineClick={handleLineClick}
/>
</div>
)}
</div>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,31 +0,0 @@
import type { ReactNode } from 'react';
import type { Pane } from '@/api/types';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
interface Props {
pane: Pane;
onClose: () => void;
className?: string;
children: ReactNode;
}
export function PaneShell({ pane, onClose, className, children }: Props) {
const label = pane.kind === 'chat' ? 'Chat' : 'Files';
return (
<div className={cn('flex flex-col h-full min-h-0 border-r border-border last:border-r-0', className)}>
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border bg-muted/30">
<span className="text-xs font-medium text-muted-foreground">{label}</span>
<button
type="button"
onClick={onClose}
className="p-0.5 hover:bg-muted rounded"
aria-label="Close pane"
>
<X size={12} />
</button>
</div>
<div className="flex-1 min-h-0 overflow-hidden">{children}</div>
</div>
);
}

View File

@@ -57,6 +57,11 @@ export interface AttachChatFileEvent {
attachment: Omit<Attachment, 'id'>;
}
export interface OpenChatInActivePaneEvent {
type: 'open_chat_in_active_pane';
chat_id: string;
}
export interface SessionArchivedEvent {
type: 'session_archived';
session_id: string;
@@ -110,6 +115,16 @@ export interface ProjectUpdatedEvent {
name: string;
}
// v1.8 mobile-tabs: broadcast on user channel from inference.ts so any device
// subscribed sees a chat working/idle/error. Frontend stores per-chat; panes
// derive their dot from pane.activeChatId.
export interface ChatStatusEvent {
type: 'chat_status';
chat_id: string;
status: 'working' | 'idle' | 'error';
at: string;
}
export type SessionEvent =
| SessionRenamedEvent
| ProjectCreatedEvent
@@ -120,6 +135,7 @@ export type SessionEvent =
| SessionLoadedEvent
| OpenFileInBrowserEvent
| AttachChatFileEvent
| OpenChatInActivePaneEvent
| SessionArchivedEvent
| ChatCreatedEvent
| ChatUpdatedEvent
@@ -128,7 +144,8 @@ export type SessionEvent =
| ChatDeletedEvent
| ProjectArchivedEvent
| ProjectUnarchivedEvent
| ProjectUpdatedEvent;
| ProjectUpdatedEvent
| ChatStatusEvent;
type Listener = (event: SessionEvent) => void;
const listeners = new Set<Listener>();

View File

@@ -0,0 +1,61 @@
import { useEffect, useState } from 'react';
import type { WorkspacePaneKind } from '@/api/types';
export interface ActivePaneSnapshot {
sessionId: string | null;
paneId: string | null;
kind: WorkspacePaneKind | null;
activeFile: string | null;
}
const EMPTY: ActivePaneSnapshot = {
sessionId: null,
paneId: null,
kind: null,
activeFile: null,
};
let current: ActivePaneSnapshot = EMPTY;
const subs = new Set<() => void>();
function notify(): void {
for (const sub of subs) {
try {
sub();
} catch {
// swallow — one bad listener shouldn't break others
}
}
}
function isSame(a: ActivePaneSnapshot, b: ActivePaneSnapshot): boolean {
return (
a.sessionId === b.sessionId &&
a.paneId === b.paneId &&
a.kind === b.kind &&
a.activeFile === b.activeFile
);
}
export function setActivePaneInfo(next: ActivePaneSnapshot): void {
if (isSame(current, next)) return;
current = next;
notify();
}
export function clearActivePane(): void {
setActivePaneInfo(EMPTY);
}
export function useActivePane(): ActivePaneSnapshot {
const [snap, setSnap] = useState<ActivePaneSnapshot>(current);
useEffect(() => {
const sub = () => setSnap(current);
subs.add(sub);
sub();
return () => {
subs.delete(sub);
};
}, []);
return snap;
}

View File

@@ -0,0 +1,37 @@
import { useMemo } from 'react';
import type { Message } from '@/api/types';
export interface ChatContextStats {
used: number;
max: number;
percent: number;
}
/**
* Returns the latest context-window usage for the given chat, derived from the
* assistant message (with both ctx_used and ctx_max populated) having the most
* recent created_at. Returns null when no such message exists.
*
* Re-evaluates whenever the `messages` reference or `chatId` changes, which
* matches the cadence of streaming updates from `useSessionStream`.
*/
export function useChatContextStats(
chatId: string,
messages: Message[],
): ChatContextStats | null {
return useMemo(() => {
let latest: Message | null = null;
for (const m of messages) {
if (m.chat_id !== chatId) continue;
if (m.role !== 'assistant') continue;
if (m.ctx_used == null || m.ctx_max == null) continue;
if (!latest || m.created_at > latest.created_at) latest = m;
}
if (!latest || latest.ctx_used == null || latest.ctx_max == null) return null;
const used = latest.ctx_used;
const max = latest.ctx_max;
if (max <= 0) return null;
const percent = Math.round((used / max) * 100);
return { used, max, percent };
}, [chatId, messages]);
}

View File

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

View File

@@ -0,0 +1,75 @@
import { useCallback, useRef } from 'react';
import type { TouchEvent } from 'react';
interface LongPressHandlers {
onTouchStart: (e: TouchEvent) => void;
onTouchMove: (e: TouchEvent) => void;
onTouchEnd: (e: TouchEvent) => void;
onTouchCancel: (e: TouchEvent) => void;
}
interface Options {
ms?: number;
// Suppress the synthetic click that follows touchend when long-press fired.
suppressClickOnFire?: boolean;
}
// Hand-rolled long-press detector. Starts a timer on touchstart; cancels on
// touchmove or early touchend; fires the callback on timer expiry. Caller is
// expected to suppress text-selection callout via CSS (-webkit-touch-callout).
export function useLongPress(
callback: (touch: { clientX: number; clientY: number; target: EventTarget | null }) => void,
{ ms = 500, suppressClickOnFire = true }: Options = {},
): LongPressHandlers {
const timerRef = useRef<number | null>(null);
const firedRef = useRef(false);
const clear = useCallback(() => {
if (timerRef.current !== null) {
window.clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
const onTouchStart = useCallback(
(e: TouchEvent) => {
firedRef.current = false;
const touch = e.touches[0];
if (!touch) return;
const x = touch.clientX;
const y = touch.clientY;
const target = e.target;
clear();
timerRef.current = window.setTimeout(() => {
firedRef.current = true;
callback({ clientX: x, clientY: y, target });
}, ms);
},
[callback, ms, clear],
);
const onTouchMove = useCallback(() => {
clear();
}, [clear]);
const onTouchEnd = useCallback(
(e: TouchEvent) => {
clear();
if (firedRef.current && suppressClickOnFire) {
// Block the synthetic click that follows touchend; the long-press
// already handled the gesture.
e.preventDefault();
}
},
[clear, suppressClickOnFire],
);
const onTouchCancel = useCallback(
(_e: TouchEvent) => {
clear();
},
[clear],
);
return { onTouchStart, onTouchMove, onTouchEnd, onTouchCancel };
}

View File

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

View File

@@ -0,0 +1,77 @@
import { useCallback, useRef, useState } from 'react';
import type { TouchEvent } from 'react';
interface Options {
threshold?: number;
enabled?: boolean;
maxPull?: number;
}
interface Handlers {
onTouchStart: (e: TouchEvent<HTMLElement>) => void;
onTouchMove: (e: TouchEvent<HTMLElement>) => void;
onTouchEnd: () => void;
pullDist: number;
refreshing: boolean;
}
// Hand-rolled pull-to-refresh: records the initial Y on touchstart only if
// the target is scrolled to the top, then tracks downward pull on touchmove.
// On touchend, fires onRefresh if the pull exceeded the threshold.
export function usePullToRefresh(
onRefresh: () => void | Promise<void>,
{ threshold = 80, enabled = true, maxPull = 120 }: Options = {},
): Handlers {
const [pullDist, setPullDist] = useState(0);
const [refreshing, setRefreshing] = useState(false);
const startYRef = useRef<number | null>(null);
const onTouchStart = useCallback(
(e: TouchEvent<HTMLElement>) => {
if (!enabled || refreshing) return;
const target = e.currentTarget as HTMLElement;
if (target.scrollTop > 0) return;
const t = e.touches[0];
if (!t) return;
startYRef.current = t.clientY;
},
[enabled, refreshing],
);
const onTouchMove = useCallback(
(e: TouchEvent<HTMLElement>) => {
if (!enabled || refreshing || startYRef.current === null) return;
const t = e.touches[0];
if (!t) return;
const delta = t.clientY - startYRef.current;
if (delta > 0) {
setPullDist(Math.min(delta, maxPull));
} else {
setPullDist(0);
}
},
[enabled, refreshing, maxPull],
);
const onTouchEnd = useCallback(() => {
if (!enabled || refreshing) {
startYRef.current = null;
setPullDist(0);
return;
}
const fired = pullDist >= threshold && startYRef.current !== null;
startYRef.current = null;
setPullDist(0);
if (fired) {
setRefreshing(true);
Promise.resolve(onRefresh())
.catch(() => {})
.finally(() => {
// Hold the indicator briefly so the action feels intentional.
window.setTimeout(() => setRefreshing(false), 600);
});
}
}, [enabled, refreshing, pullDist, threshold, onRefresh]);
return { onTouchStart, onTouchMove, onTouchEnd, pullDist, refreshing };
}

View File

@@ -0,0 +1,35 @@
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import type { ReactNode } from 'react';
import { useLocation } from 'react-router-dom';
interface RightRailDrawerState {
open: boolean;
setOpen: (open: boolean) => void;
toggle: () => void;
}
const Ctx = createContext<RightRailDrawerState | null>(null);
export function RightRailDrawerProvider({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false);
const location = useLocation();
// Auto-close on route change. Same pattern as useSidebarDrawer — keeps the
// drawer from leaking between sessions when the user navigates.
useEffect(() => {
setOpen(false);
}, [location.pathname]);
const toggle = useCallback(() => setOpen((v) => !v), []);
return <Ctx.Provider value={{ open, setOpen, toggle }}>{children}</Ctx.Provider>;
}
export function useRightRailDrawer(): RightRailDrawerState {
const ctx = useContext(Ctx);
if (!ctx) {
// Soft fallback so consumers don't crash if rendered outside a provider.
return { open: false, setOpen: () => {}, toggle: () => {} };
}
return ctx;
}

View File

@@ -0,0 +1,175 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import type { Chat } from '@/api/types';
import { sessionEvents } from '@/hooks/sessionEvents';
export interface UseSessionChatsOpts {
removeChatFromPanes: (chatId: string) => void;
openChatInPane: (paneIdx: number, chatId: string) => void;
// Thin wrapper around openChatInPane(activePaneIdxRef.current, chatId);
// built by Workspace and passed in so this hook doesn't need to know
// about pane indexing.
openChatInActivePane: (chatId: string) => void;
initializeFirstChatIfEmpty: (chatId: string) => void;
}
export interface UseSessionChatsResult {
chats: Chat[];
setChats: React.Dispatch<React.SetStateAction<Chat[]>>;
createChat: (paneIdx: number) => Promise<void>;
archiveChat: (chatId: string) => Promise<void>;
unarchiveChat: (chatId: string) => Promise<void>;
deleteChat: (chatId: string) => Promise<void>;
renameChat: (chatId: string, name: string) => Promise<void>;
handleLandingSend: (paneIdx: number, content: string) => Promise<void>;
}
export function useSessionChats(
sessionId: string,
opts: UseSessionChatsOpts,
): UseSessionChatsResult {
const [chats, setChats] = useState<Chat[]>([]);
const chatsRef = useRef<Chat[]>([]);
chatsRef.current = chats;
// Stable refs to opts callbacks so the subscription effect — which only
// re-runs on sessionId change — always sees the latest closures without
// unsubscribe/resubscribe churn.
const removeChatFromPanesRef = useRef(opts.removeChatFromPanes);
removeChatFromPanesRef.current = opts.removeChatFromPanes;
const openChatInPaneRef = useRef(opts.openChatInPane);
openChatInPaneRef.current = opts.openChatInPane;
const openChatInActivePaneRef = useRef(opts.openChatInActivePane);
openChatInActivePaneRef.current = opts.openChatInActivePane;
const initializeFirstChatIfEmptyRef = useRef(opts.initializeFirstChatIfEmpty);
initializeFirstChatIfEmptyRef.current = opts.initializeFirstChatIfEmpty;
useEffect(() => {
let cancelled = false;
api.chats.listForSession(sessionId).then((list) => {
if (cancelled) return;
setChats(list);
const openChat = list.find((c) => c.status === 'open');
if (openChat) {
initializeFirstChatIfEmptyRef.current(openChat.id);
}
}).catch(() => {});
return () => { cancelled = true; };
}, [sessionId]);
useEffect(() => {
return sessionEvents.subscribe((event) => {
if (event.type === 'chat_created' && event.session_id === sessionId) {
setChats((prev) => {
if (prev.some((c) => c.id === event.chat.id)) return prev;
return [event.chat, ...prev];
});
}
if (event.type === 'chat_updated') {
setChats((prev) => prev.map((c) =>
c.id === event.chat_id ? { ...c, name: event.name, updated_at: event.updated_at } : c
));
}
if (event.type === 'chat_archived') {
setChats((prev) => prev.map((c) =>
c.id === event.chat_id ? { ...c, status: 'archived' as const } : c
));
removeChatFromPanesRef.current(event.chat_id);
}
if (event.type === 'chat_unarchived') {
setChats((prev) => {
if (prev.some((c) => c.id === event.chat.id)) {
return prev.map((c) => c.id === event.chat.id ? { ...c, status: 'open' as const } : c);
}
return [event.chat, ...prev];
});
}
if (event.type === 'chat_deleted') {
setChats((prev) => prev.filter((c) => c.id !== event.chat_id));
removeChatFromPanesRef.current(event.chat_id);
}
if (event.type === 'open_chat_in_active_pane') {
openChatInActivePaneRef.current(event.chat_id);
}
});
}, [sessionId]);
const createChat = useCallback(async (paneIdx: number) => {
try {
const chat = await api.chats.create(sessionId);
// Optimistic local insert; the WS chat_created echo will be deduped by id.
setChats((prev) => {
if (prev.some((c) => c.id === chat.id)) return prev;
return [chat, ...prev];
});
openChatInPaneRef.current(paneIdx, chat.id);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to create chat');
}
}, [sessionId]);
const archiveChat = useCallback(async (chatId: string) => {
try {
await api.chats.archive(chatId);
// Server publishes chat_archived; bus forwarder updates state.
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to archive chat');
}
}, []);
const unarchiveChat = useCallback(async (chatId: string) => {
try {
await api.chats.unarchive(chatId);
// Server publishes chat_unarchived.
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to restore chat');
}
}, []);
const deleteChat = useCallback(async (chatId: string) => {
try {
await api.chats.remove(chatId);
setChats((prev) => prev.filter((c) => c.id !== chatId));
removeChatFromPanesRef.current(chatId);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to delete chat');
}
}, []);
const renameChat = useCallback(async (chatId: string, name: string) => {
try {
await api.chats.update(chatId, { name });
setChats((prev) => prev.map((c) =>
c.id === chatId ? { ...c, name } : c
));
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to rename chat');
}
}, []);
const handleLandingSend = useCallback(async (paneIdx: number, content: string) => {
try {
const chat = await api.chats.create(sessionId);
setChats((prev) => {
if (prev.some((c) => c.id === chat.id)) return prev;
return [chat, ...prev];
});
openChatInPaneRef.current(paneIdx, chat.id);
await api.messages.send(chat.id, content);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to send');
}
}, [sessionId]);
return {
chats,
setChats,
createChat,
archiveChat,
unarchiveChat,
deleteChat,
renameChat,
handleLandingSend,
};
}

View File

@@ -2,6 +2,10 @@ import { useEffect, useRef, useState } from 'react';
import type { Message, WsFrame } from '@/api/types';
import { sessionEvents } from './sessionEvents';
// session_renamed frame removed from WsFrame — it was declared but never
// published on the per-session WS channel (server publishes via broker.publishUser
// since v1.4). chat_renamed remains; auto_name.ts publishes it on session WS.
interface State {
messages: Message[];
connected: boolean;
@@ -118,14 +122,6 @@ function applyFrame(state: State, frame: WsFrame): State {
messages: state.messages.filter((m) => !removeSet.has(m.id)),
};
}
case 'session_renamed': {
sessionEvents.emit({
type: 'session_renamed',
session_id: frame.session_id,
name: frame.name,
});
return state;
}
case 'chat_renamed': {
sessionEvents.emit({
type: 'chat_updated',
@@ -147,6 +143,11 @@ function applyFrame(state: State, frame: WsFrame): State {
}
}
// Matches useUserEvents — exponential backoff with the same ceiling so the
// two channels reconnect on the same cadence after a network handoff.
const RECONNECT_INITIAL_MS = 1000;
const RECONNECT_MAX_MS = 30_000;
export function useSessionStream(sessionId: string | undefined) {
const [state, setState] = useState<State>({ messages: [], connected: false, error: null });
const wsRef = useRef<WebSocket | null>(null);
@@ -156,32 +157,52 @@ export function useSessionStream(sessionId: string | undefined) {
setState({ messages: [], connected: false, error: null });
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
const url = `${proto}://${window.location.host}/api/ws/sessions/${sessionId}`;
const ws = new WebSocket(url);
wsRef.current = ws;
let unmounted = false;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let reconnectDelay = RECONNECT_INITIAL_MS;
ws.onopen = () => {
setState((s) => ({ ...s, connected: true, error: null }));
};
ws.onmessage = (ev) => {
try {
const frame = JSON.parse(typeof ev.data === 'string' ? ev.data : '') as WsFrame;
setState((s) => applyFrame(s, frame));
} catch (err) {
console.warn('bad ws frame', err);
}
};
ws.onerror = () => {
setState((s) => ({ ...s, error: 'websocket error' }));
};
ws.onclose = () => {
setState((s) => ({ ...s, connected: false }));
const connect = () => {
if (unmounted) return;
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
const url = `${proto}://${window.location.host}/api/ws/sessions/${sessionId}`;
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onopen = () => {
reconnectDelay = RECONNECT_INITIAL_MS;
setState((s) => ({ ...s, connected: true, error: null }));
};
ws.onmessage = (ev) => {
try {
const frame = JSON.parse(typeof ev.data === 'string' ? ev.data : '') as WsFrame;
setState((s) => applyFrame(s, frame));
} catch (err) {
console.warn('bad ws frame', err);
}
};
// v1.8.1: WS errors no longer surface as user-facing toasts here. The
// user-channel hook (useUserEvents) owns the debounced "reconnecting…"
// UI; this channel just reconnects silently on the same backoff.
ws.onerror = () => {
try { ws.close(); } catch {}
};
ws.onclose = () => {
if (unmounted) return;
setState((s) => ({ ...s, connected: false }));
const delay = reconnectDelay;
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
reconnectTimer = setTimeout(connect, delay);
};
};
connect();
return () => {
unmounted = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
const ws = wsRef.current;
wsRef.current = null;
ws.close();
if (ws) try { ws.close(); } catch {}
};
}, [sessionId]);

View File

@@ -148,6 +148,9 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
return prev;
case 'attach_chat_file':
return prev;
case 'open_chat_in_active_pane':
// Consumed by Workspace; sidebar has no business with pane state.
return prev;
case 'session_archived': {
let changed = false;
const projects = prev.projects.map((p) => {
@@ -168,6 +171,7 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
case 'chat_archived':
case 'chat_unarchived':
case 'chat_deleted':
case 'chat_status':
return prev;
case 'project_archived': {
const next = prev.projects.filter((p) => p.id !== event.project_id);

View File

@@ -0,0 +1,36 @@
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import type { ReactNode } from 'react';
import { useLocation } from 'react-router-dom';
interface SidebarDrawerState {
open: boolean;
setOpen: (open: boolean) => void;
toggle: () => void;
}
const Ctx = createContext<SidebarDrawerState | null>(null);
export function SidebarDrawerProvider({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false);
const location = useLocation();
// Auto-close on navigation. Effect fires once on mount too (open default
// is false, so no observable effect) and on every pathname change after.
useEffect(() => {
setOpen(false);
}, [location.pathname]);
const toggle = useCallback(() => setOpen((v) => !v), []);
return <Ctx.Provider value={{ open, setOpen, toggle }}>{children}</Ctx.Provider>;
}
export function useSidebarDrawer(): SidebarDrawerState {
const ctx = useContext(Ctx);
if (!ctx) {
// Soft fallback so consumers don't crash if rendered outside a provider.
// In practice all top-level routes are inside the provider.
return { open: false, setOpen: () => {}, toggle: () => {} };
}
return ctx;
}

View File

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

View File

@@ -0,0 +1,45 @@
import { useEffect, useState } from 'react';
// Breakpoints (px): mobile <768, tablet 768-1023, desktop >=1024.
const MOBILE_MAX = 767;
const TABLET_MAX = 1023;
export interface ViewportSnapshot {
isMobile: boolean;
isTablet: boolean;
width: number;
}
function snapshot(): ViewportSnapshot {
if (typeof window === 'undefined') {
return { isMobile: false, isTablet: false, width: 1280 };
}
const width = window.innerWidth;
return {
isMobile: width <= MOBILE_MAX,
isTablet: width > MOBILE_MAX && width <= TABLET_MAX,
width,
};
}
// matchMedia-based, no resize polling. We listen to two breakpoint queries
// and recompute the snapshot on any change.
export function useViewport(): ViewportSnapshot {
const [state, setState] = useState<ViewportSnapshot>(snapshot);
useEffect(() => {
if (typeof window === 'undefined') return;
const mobileMq = window.matchMedia(`(max-width: ${MOBILE_MAX}px)`);
const tabletMq = window.matchMedia(`(min-width: ${MOBILE_MAX + 1}px) and (max-width: ${TABLET_MAX}px)`);
const update = () => setState(snapshot());
mobileMq.addEventListener('change', update);
tabletMq.addEventListener('change', update);
update();
return () => {
mobileMq.removeEventListener('change', update);
tabletMq.removeEventListener('change', update);
};
}, []);
return state;
}

View File

@@ -0,0 +1,339 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { DragEvent } from 'react';
import { toast } from 'sonner';
import type { WorkspacePane } from '@/api/types';
import { setActivePaneInfo, clearActivePane } from '@/hooks/useActivePane';
export const MAX_PANES = 5;
const STORAGE_KEY = 'boocode.workspace.panes';
function generateId(): string {
return crypto.randomUUID();
}
function emptyPane(): WorkspacePane {
return { id: generateId(), kind: 'empty', chatIds: [], activeChatIdx: -1 };
}
function chatPane(chatId: string): WorkspacePane {
return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 };
}
function loadPanes(sessionId: string): WorkspacePane[] | null {
try {
const raw = localStorage.getItem(`${STORAGE_KEY}.${sessionId}`);
if (!raw) return null;
const parsed = JSON.parse(raw) as WorkspacePane[];
if (!Array.isArray(parsed) || parsed.length === 0) return null;
return parsed;
} catch {
return null;
}
}
function savePanes(sessionId: string, panes: WorkspacePane[]): void {
try {
localStorage.setItem(`${STORAGE_KEY}.${sessionId}`, JSON.stringify(panes));
} catch { /* quota or disabled */ }
}
export interface UseWorkspacePanesResult {
panes: WorkspacePane[];
activePaneIdx: number;
setActivePaneIdx: React.Dispatch<React.SetStateAction<number>>;
activePaneIdxRef: React.MutableRefObject<number>;
openChatInPane: (paneIdx: number, chatId: string) => void;
switchTab: (paneIdx: number, tabIdx: number) => void;
removeTab: (paneIdx: number, chatId: string) => void;
closeOtherTabs: (paneIdx: number, keepChatId: string) => void;
closeTabsToRight: (paneIdx: number, pivotChatId: string) => void;
closeAllTabs: (paneIdx: number) => void;
showLandingPage: (paneIdx: number) => void;
addSplitPane: (kind: 'chat' | 'terminal' | 'agent') => void;
removePane: (idx: number) => void;
removeChatFromPanes: (chatId: string) => void;
initializeFirstChatIfEmpty: (chatId: string) => void;
handlePaneDragStart: (idx: number) => (e: DragEvent<HTMLDivElement>) => void;
handlePaneDragOver: (idx: number) => (e: DragEvent<HTMLDivElement>) => void;
handlePaneDragLeave: () => void;
handlePaneDrop: (targetIdx: number) => (e: DragEvent<HTMLDivElement>) => void;
handlePaneDragEnd: () => void;
dragOverIdx: number | null;
draggingIdxRef: React.MutableRefObject<number | null>;
}
export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
const [panes, setPanes] = useState<WorkspacePane[]>(() => {
return loadPanes(sessionId) ?? [emptyPane()];
});
const [activePaneIdx, setActivePaneIdx] = useState(0);
const draggingIdxRef = useRef<number | null>(null);
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
useEffect(() => {
savePanes(sessionId, panes);
}, [sessionId, panes]);
useEffect(() => {
const active = panes[activePaneIdx];
if (!active) {
clearActivePane();
return;
}
setActivePaneInfo({
sessionId,
paneId: active.id,
kind: active.kind,
activeFile: null,
});
}, [sessionId, panes, activePaneIdx]);
useEffect(() => {
return () => {
clearActivePane();
};
}, []);
const activePaneIdxRef = useRef(activePaneIdx);
activePaneIdxRef.current = activePaneIdx;
const openChatInPane = useCallback((paneIdx: number, chatId: string) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
const existing = pane.chatIds.indexOf(chatId);
if (existing >= 0) {
next[paneIdx] = { ...pane, kind: 'chat', chatId, activeChatIdx: existing };
} else {
const newIds = [...pane.chatIds, chatId];
next[paneIdx] = {
...pane,
kind: 'chat',
chatId,
chatIds: newIds,
activeChatIdx: newIds.length - 1,
};
}
return next;
});
setActivePaneIdx(paneIdx);
}, []);
const switchTab = useCallback((paneIdx: number, tabIdx: number) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
const chatId = pane.chatIds[tabIdx];
if (!chatId) return prev;
next[paneIdx] = { ...pane, chatId, activeChatIdx: tabIdx };
return next;
});
}, []);
const removeTab = useCallback((paneIdx: number, chatId: string) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
const nextIds = pane.chatIds.filter((id) => id !== chatId);
if (nextIds.length === 0) {
next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined, chatIds: [], activeChatIdx: -1 };
} else {
const nextActiveIdx = Math.min(pane.activeChatIdx, nextIds.length - 1);
next[paneIdx] = {
...pane,
chatIds: nextIds,
activeChatIdx: nextActiveIdx,
chatId: nextIds[nextActiveIdx],
};
}
return next;
});
}, []);
// Keep only the right-clicked tab open in this pane.
const closeOtherTabs = useCallback((paneIdx: number, keepChatId: string) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
const keepIdx = pane.chatIds.indexOf(keepChatId);
if (keepIdx < 0) return prev;
next[paneIdx] = {
...pane,
kind: 'chat',
chatId: keepChatId,
chatIds: [keepChatId],
activeChatIdx: 0,
};
return next;
});
}, []);
// Close every tab to the right of the right-clicked one.
const closeTabsToRight = useCallback((paneIdx: number, pivotChatId: string) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
const pivotIdx = pane.chatIds.indexOf(pivotChatId);
if (pivotIdx < 0 || pivotIdx === pane.chatIds.length - 1) return prev;
const nextIds = pane.chatIds.slice(0, pivotIdx + 1);
const nextActiveIdx = Math.min(pane.activeChatIdx, nextIds.length - 1);
next[paneIdx] = {
...pane,
chatIds: nextIds,
activeChatIdx: nextActiveIdx,
chatId: nextIds[nextActiveIdx],
};
return next;
});
}, []);
// Close every tab in this pane; land on landing page.
const closeAllTabs = useCallback((paneIdx: number) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined, chatIds: [], activeChatIdx: -1 };
return next;
});
}, []);
const showLandingPage = useCallback((paneIdx: number) => {
setPanes((prev) => {
const next = [...prev];
const pane = next[paneIdx]!;
next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined };
return next;
});
}, []);
const addSplitPane = useCallback((kind: 'chat' | 'terminal' | 'agent') => {
if (kind === 'terminal') {
toast('Terminal panes coming in BooTerm');
return;
}
if (kind === 'agent') {
toast('Agent panes coming in BooCoder');
return;
}
setPanes((prev) => {
if (prev.length >= MAX_PANES) {
toast.error(`Maximum ${MAX_PANES} panes`);
return prev;
}
const next = [...prev, emptyPane()];
setActivePaneIdx(next.length - 1);
return next;
});
}, []);
const removePane = useCallback((idx: number) => {
setPanes((prev) => {
if (prev.length <= 1) return prev;
const next = prev.filter((_, i) => i !== idx);
setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
return next;
});
}, []);
// Replaces a single empty default pane with a chat pane. Used by the initial
// chat fetch to land on the most-recent open chat if no saved pane state.
const initializeFirstChatIfEmpty = useCallback((chatId: string) => {
setPanes((prev) => {
if (prev.length === 1 && prev[0]!.kind === 'empty') {
return [chatPane(chatId)];
}
return prev;
});
}, []);
const removeChatFromPanes = useCallback((chatId: string) => {
setPanes((prev) => prev.map((p) => {
const idx = p.chatIds.indexOf(chatId);
if (idx < 0) return p;
const nextIds = p.chatIds.filter((id) => id !== chatId);
if (nextIds.length === 0) {
return { ...p, kind: 'empty' as const, chatId: undefined, chatIds: [], activeChatIdx: -1 };
}
const nextActiveIdx = Math.min(p.activeChatIdx, nextIds.length - 1);
return {
...p,
chatIds: nextIds,
activeChatIdx: nextActiveIdx,
chatId: nextIds[nextActiveIdx],
};
}));
}, []);
const handlePaneDragStart = useCallback(
(idx: number) => (e: DragEvent<HTMLDivElement>) => {
draggingIdxRef.current = idx;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(idx));
},
[]
);
const handlePaneDragOver = useCallback(
(idx: number) => (e: DragEvent<HTMLDivElement>) => {
if (draggingIdxRef.current === null) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (dragOverIdx !== idx) setDragOverIdx(idx);
},
[dragOverIdx]
);
const handlePaneDragLeave = useCallback(() => {
setDragOverIdx(null);
}, []);
const handlePaneDrop = useCallback(
(targetIdx: number) => (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
const fromIdx = draggingIdxRef.current;
draggingIdxRef.current = null;
setDragOverIdx(null);
if (fromIdx === null || fromIdx === targetIdx) return;
setPanes((prev) => {
const next = [...prev];
const [moved] = next.splice(fromIdx, 1);
if (!moved) return prev;
next.splice(targetIdx, 0, moved);
// Keep active selection on the same logical pane (the one being dragged).
setActivePaneIdx(targetIdx);
return next;
});
},
[]
);
const handlePaneDragEnd = useCallback(() => {
draggingIdxRef.current = null;
setDragOverIdx(null);
}, []);
return {
panes,
activePaneIdx,
setActivePaneIdx,
activePaneIdxRef,
openChatInPane,
switchTab,
removeTab,
closeOtherTabs,
closeTabsToRight,
closeAllTabs,
showLandingPage,
addSplitPane,
removePane,
removeChatFromPanes,
initializeFirstChatIfEmpty,
handlePaneDragStart,
handlePaneDragOver,
handlePaneDragLeave,
handlePaneDrop,
handlePaneDragEnd,
dragOverIdx,
draggingIdxRef,
};
}

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { ChevronDown, ChevronRight, Folder, RotateCcw } from 'lucide-react';
import { ChevronDown, ChevronRight, Folder, FolderTree, Menu, RotateCcw } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { AddProjectModal } from '@/components/AddProjectModal';
@@ -8,6 +8,9 @@ import { api } from '@/api/client';
import type { Project } from '@/api/types';
import { sessionEvents } from '@/hooks/sessionEvents';
import { useSidebar } from '@/hooks/useSidebar';
import { useSidebarDrawer } from '@/hooks/useSidebarDrawer';
import { useRightRailDrawer } from '@/hooks/useRightRailDrawer';
import { useViewport } from '@/hooks/useViewport';
export function Home() {
const { data } = useSidebar();
@@ -15,6 +18,9 @@ export function Home() {
const [createOpen, setCreateOpen] = useState(false);
const [archived, setArchived] = useState<Project[] | null>(null);
const [showArchived, setShowArchived] = useState(false);
const { setOpen: setSidebarOpen } = useSidebarDrawer();
const { toggle: toggleRightRail } = useRightRailDrawer();
const { isMobile } = useViewport();
const empty = data ? data.projects.length === 0 : false;
@@ -70,8 +76,32 @@ export function Home() {
}
return (
<div className="flex-1 flex flex-col items-center px-6 py-12 overflow-y-auto">
<div className="w-full max-w-md space-y-6">
<div className="flex-1 flex flex-col min-h-0">
{isMobile && (
<header
className="border-b px-3 sm:px-4 py-2 flex items-center gap-1.5 shrink-0 text-sm"
style={{ paddingTop: 'max(0.5rem, env(safe-area-inset-top))' }}
>
<button
type="button"
onClick={() => setSidebarOpen(true)}
className="inline-flex items-center justify-center -ml-1 min-w-[44px] min-h-[44px] rounded text-muted-foreground hover:bg-muted hover:text-foreground shrink-0"
aria-label="Open sidebar"
>
<Menu className="size-5" />
</button>
<button
type="button"
onClick={toggleRightRail}
className="inline-flex items-center justify-center -mr-1 ml-auto min-w-[44px] min-h-[44px] rounded text-muted-foreground hover:bg-muted hover:text-foreground shrink-0"
aria-label="Toggle file browser"
>
<FolderTree className="size-5" />
</button>
</header>
)}
<div className="flex-1 flex flex-col items-center px-6 py-12 overflow-y-auto">
<div className="w-full max-w-md space-y-6">
<div className="text-center space-y-3">
{empty ? (
<>
@@ -127,9 +157,10 @@ export function Home() {
)}
</div>
)}
</div>
<AddProjectModal open={addOpen} onOpenChange={setAddOpen} onAdded={() => {}} />
<CreateProjectModal open={createOpen} onOpenChange={setCreateOpen} />
</div>
<AddProjectModal open={addOpen} onOpenChange={setAddOpen} onAdded={() => {}} />
<CreateProjectModal open={createOpen} onOpenChange={setCreateOpen} />
</div>
);
}

View File

@@ -1,12 +1,14 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { Plus, MessageSquare, Trash2, ChevronDown, ChevronRight, RotateCcw } from 'lucide-react';
import { Plus, MessageSquare, Trash2, ChevronDown, ChevronRight, RotateCcw, Menu } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import type { Project as ProjectType, Session } from '@/api/types';
import { Button } from '@/components/ui/button';
import { sessionEvents } from '@/hooks/sessionEvents';
import { useSessions } from '@/hooks/useSessions';
import { useSidebarDrawer } from '@/hooks/useSidebarDrawer';
import { useViewport } from '@/hooks/useViewport';
export function Project() {
const { id } = useParams<{ id: string }>();
@@ -16,6 +18,8 @@ export function Project() {
const [creating, setCreating] = useState(false);
const [archivedSessions, setArchivedSessions] = useState<Session[] | null>(null);
const [showArchived, setShowArchived] = useState(false);
const { setOpen: setDrawerOpen } = useSidebarDrawer();
const { isMobile } = useViewport();
useEffect(() => {
if (!id) return;
@@ -76,18 +80,33 @@ export function Project() {
return (
<div className="flex-1 flex flex-col">
<header className="border-b px-6 py-3 flex items-center justify-between">
<div>
<h1 className="text-lg font-semibold tracking-tight">
{project?.name ?? '…'}
</h1>
<div className="text-xs text-muted-foreground font-mono">
{project?.path}
<header
className="border-b px-3 sm:px-6 py-2 sm:py-3 flex items-center justify-between gap-2"
style={{ paddingTop: 'max(0.5rem, env(safe-area-inset-top))' }}
>
<div className="flex items-center gap-2 min-w-0">
{isMobile && (
<button
type="button"
onClick={() => setDrawerOpen(true)}
className="inline-flex items-center justify-center -ml-1 min-w-[44px] min-h-[44px] rounded text-muted-foreground hover:bg-muted hover:text-foreground shrink-0"
aria-label="Open sidebar"
>
<Menu className="size-5" />
</button>
)}
<div className="min-w-0">
<h1 className="text-base sm:text-lg font-semibold tracking-tight truncate">
{project?.name ?? '…'}
</h1>
<div className="text-xs text-muted-foreground font-mono truncate hidden sm:block">
{project?.path}
</div>
</div>
</div>
<Button onClick={handleNew} disabled={creating}>
<Button onClick={handleNew} disabled={creating} className="shrink-0" aria-label="New session">
<Plus />
New session
<span className="hidden sm:inline">New session</span>
</Button>
</header>

View File

@@ -1,132 +1,343 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { ChevronLeft } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import {
Link,
useLocation,
useNavigate,
useParams,
useSearchParams,
} from 'react-router-dom';
import { ChevronRight, FolderTree, Menu } from 'lucide-react';
import { api } from '@/api/client';
import type { Session as SessionType } from '@/api/types';
import type { Project, Session as SessionType } from '@/api/types';
import { sessionEvents } from '@/hooks/sessionEvents';
import { useActivePane } from '@/hooks/useActivePane';
import { useSidebarDrawer } from '@/hooks/useSidebarDrawer';
import { useRightRailDrawer } from '@/hooks/useRightRailDrawer';
import { useViewport } from '@/hooks/useViewport';
import { useWorkspacePanes, MAX_PANES } from '@/hooks/useWorkspacePanes';
import { useSessionChats } from '@/hooks/useSessionChats';
import { useProjectGit } from '@/hooks/useProjectGit';
import { Workspace } from '@/components/Workspace';
import { ModelPicker } from '@/components/ModelPicker';
import { MobileTabSwitcher } from '@/components/MobileTabSwitcher';
import { NewPaneMenu } from '@/components/NewPaneMenu';
import { cn } from '@/lib/utils';
export function Session() {
const { id } = useParams<{ id: string }>();
if (!id) return null;
// v1.8: key on id so route navigation remounts SessionInner — the hoisted
// useWorkspacePanes + useSessionChats then reinitialize cleanly from the
// new sessionId instead of carrying stale state across sessions.
return <SessionInner key={id} sessionId={id} />;
}
function SessionInner({ sessionId }: { sessionId: string }) {
const navigate = useNavigate();
const location = useLocation();
const [searchParams] = useSearchParams();
const [session, setSession] = useState<SessionType | null>(null);
const [project, setProject] = useState<Project | null>(null);
const [name, setName] = useState('');
const [editingName, setEditingName] = useState(false);
const active = useActivePane();
const { setOpen: setDrawerOpen } = useSidebarDrawer();
const { toggle: toggleRightRail } = useRightRailDrawer();
const { isMobile } = useViewport();
// v1.8: pane + chat state hoisted into Session so the mobile header pill
// (MobileTabSwitcher) shares one source of truth with the pane grid below.
const panesHook = useWorkspacePanes(sessionId);
const {
panes,
activePaneIdx,
setActivePaneIdx,
openChatInPane,
activePaneIdxRef,
addSplitPane,
removePane,
removeChatFromPanes,
initializeFirstChatIfEmpty,
} = panesHook;
const openChatInActivePane = useCallback(
(chatId: string) => openChatInPane(activePaneIdxRef.current, chatId),
[openChatInPane, activePaneIdxRef],
);
const chatsHook = useSessionChats(sessionId, {
removeChatFromPanes,
openChatInPane,
openChatInActivePane,
initializeFirstChatIfEmpty,
});
const { chats, renameChat } = chatsHook;
// v1.8 Level 1: branch indicator. Polls every 30s; server caches the same
// span so back-to-back loads are cheap. Returns null until the first fetch
// resolves or if the project isn't a git repo.
const git = useProjectGit(project?.id);
useEffect(() => {
if (!id) return;
setSession(null);
setProject(null);
let cancelled = false;
api.sessions
.get(id)
.get(sessionId)
.then((s) => {
if (cancelled) return;
setSession(s);
setName(s.name);
// Emit unconditionally — the sidebar's session_loaded handler
// updates activeSession; redundant when the session is already in
// the recent_sessions cache but harmless. This lets the sidebar
// highlight the parent project for deep-linked sessions that
// aren't in the cache.
sessionEvents.emit({
type: 'session_loaded',
session_id: id,
session_id: sessionId,
project_id: s.project_id,
});
api.projects.list().then((projects) => {
if (cancelled) return;
const p = projects.find((x) => x.id === s.project_id);
if (p) setProject(p);
}).catch((err) => console.warn('Session: failed to load project for breadcrumb', err));
})
.catch(() => {});
.catch((err) => console.warn('Session: failed to fetch session', err));
return () => {
cancelled = true;
};
}, [id]);
}, [sessionId]);
useEffect(() => {
if (!id) return;
return sessionEvents.subscribe((event) => {
if (event.type === 'session_renamed' && event.session_id === id) {
if (event.type === 'session_renamed' && event.session_id === sessionId) {
setSession((prev) => (prev ? { ...prev, name: event.name } : prev));
setName((prev) => (editingName ? prev : event.name));
return;
}
if (
(event.type === 'session_deleted' || event.type === 'session_archived') &&
event.session_id === id
event.session_id === sessionId
) {
navigate(`/project/${event.project_id}`);
}
});
}, [id, editingName, navigate]);
}, [sessionId, editingName, navigate]);
// v1.8: URL ?pane= sync (mobile only). Lifted from Workspace.tsx so
// MobileTabSwitcher's onSwitchPane can push the same URL state and the
// browser Back button continues to walk pane history on mobile.
useEffect(() => {
if (!isMobile || panes.length === 0) return;
const paneId = searchParams.get('pane');
if (!paneId) {
if (activePaneIdx !== 0) setActivePaneIdx(0);
return;
}
const idx = panes.findIndex((p) => p.id === paneId);
if (idx >= 0 && idx !== activePaneIdx) setActivePaneIdx(idx);
}, [isMobile, searchParams, panes, activePaneIdx, setActivePaneIdx]);
const switchActivePane = useCallback(
(idx: number) => {
setActivePaneIdx(idx);
if (isMobile) {
const pane = panes[idx];
if (!pane) return;
const params = new URLSearchParams(location.search);
params.set('pane', pane.id);
navigate(`${location.pathname}?${params.toString()}`);
}
},
[setActivePaneIdx, isMobile, panes, navigate, location.pathname, location.search],
);
async function saveName() {
if (!id || !session) return;
if (!session) return;
const trimmed = name.trim();
if (!trimmed || trimmed === session.name) {
setName(session.name);
setEditingName(false);
return;
}
const updated = await api.sessions.update(id, { name: trimmed });
const updated = await api.sessions.update(sessionId, { name: trimmed });
setSession(updated);
sessionEvents.emit({
type: 'session_renamed',
session_id: id,
name: trimmed,
});
setEditingName(false);
// Server publishes session_renamed via broker.publishUser; no local emit needed.
}
// Workspace only sets activeFile for file-browser panes; checking it alone
// suffices and is forward-compatible with future pane kinds.
const showActiveFile = active.sessionId === sessionId && !!active.activeFile;
return (
<div className="flex-1 flex flex-col min-h-0">
<header className="border-b px-4 py-2 flex items-center gap-2 shrink-0">
{session && (
<Link
to={`/project/${session.project_id}`}
className="text-muted-foreground hover:text-foreground"
aria-label="Back to project"
>
<ChevronLeft className="size-4" />
</Link>
<header
className={cn(
'border-b shrink-0 text-sm',
isMobile
? 'flex flex-col gap-1.5 px-3 py-2'
: 'flex items-center gap-1.5 px-3 sm:px-4 py-2',
)}
{editingName ? (
<input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
onBlur={() => void saveName()}
onKeyDown={(e) => {
if (e.key === 'Enter') void saveName();
if (e.key === 'Escape') {
setName(session?.name ?? '');
setEditingName(false);
}
}}
className="bg-transparent border-b border-border px-1 py-0.5 text-sm font-medium outline-none focus:border-ring"
/>
style={{ paddingTop: 'max(0.5rem, env(safe-area-inset-top))' }}
>
{isMobile ? (
<>
{/* v1.8 mobile row 1: hamburger | repo+branch | ModelPicker | FolderTree.
Gear/kebab cluster lands in Batch 7; ModelPicker stays here until
then so mobile users keep model-switching access. */}
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => setDrawerOpen(true)}
className="inline-flex items-center justify-center -ml-1 min-w-[44px] min-h-[44px] rounded text-muted-foreground hover:bg-muted hover:text-foreground shrink-0"
aria-label="Open sidebar"
>
<Menu className="size-5" />
</button>
<div className="flex-1 min-w-0 flex items-center justify-center gap-1.5">
{project ? (
<span className="text-sm font-medium truncate" title={project.name}>
{project.name}
</span>
) : (
<span className="text-muted-foreground/60"></span>
)}
{git?.branch && (
<span
className="text-muted-foreground/80 text-xs truncate"
title={`branch: ${git.branch}${git.is_dirty ? ' (dirty)' : ''}`}
>
· {git.branch}
</span>
)}
</div>
{session && (
<div className="inline-flex items-center rounded-full bg-muted/40 hover:bg-muted/70 px-1 shrink-0">
<ModelPicker
value={session.model}
onChange={async (model) => {
const updated = await api.sessions.update(session.id, { model });
setSession(updated);
}}
/>
</div>
)}
<button
type="button"
onClick={toggleRightRail}
className="inline-flex items-center justify-center -mr-1 min-w-[44px] min-h-[44px] rounded text-muted-foreground hover:bg-muted hover:text-foreground shrink-0"
aria-label="Toggle file browser"
>
<FolderTree className="size-5" />
</button>
</div>
{/* v1.8 mobile row 2: pane-switcher pill + new-pane menu. Pill
expands; NewPaneMenu is the trailing 44x44 trigger. */}
<div className="flex items-center gap-1.5">
<MobileTabSwitcher
panes={panes}
activePaneIdx={activePaneIdx}
chats={chats}
onSwitchPane={switchActivePane}
onRemovePane={removePane}
onRenameChat={renameChat}
/>
<NewPaneMenu
onAddPane={addSplitPane}
disabled={panes.length >= MAX_PANES}
/>
</div>
</>
) : (
<button
type="button"
className="text-sm font-medium hover:underline"
onClick={() => setEditingName(true)}
>
{session?.name ?? '…'}
</button>
<>
{/* Desktop: unchanged single-row header. */}
<div className="hidden sm:flex items-center gap-1.5 min-w-0">
<Link to="/" className="text-muted-foreground hover:text-foreground shrink-0 text-xs">
Projects
</Link>
<ChevronRight className="size-3 text-muted-foreground/60 shrink-0" />
{project ? (
<Link
to={`/project/${project.id}`}
className="text-muted-foreground hover:text-foreground truncate max-w-[200px]"
title={project.name}
>
{project.name}
</Link>
) : (
<span className="text-muted-foreground/60"></span>
)}
<ChevronRight className="size-3 text-muted-foreground/60 shrink-0" />
</div>
{editingName ? (
<input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
onBlur={() => void saveName()}
onKeyDown={(e) => {
if (e.key === 'Enter') void saveName();
if (e.key === 'Escape') {
setName(session?.name ?? '');
setEditingName(false);
}
}}
className="bg-transparent border-b border-border px-1 py-0.5 text-sm font-medium outline-none focus:border-ring min-w-0"
/>
) : (
<button
type="button"
className="text-sm font-medium hover:underline truncate max-w-[280px] min-w-0"
onClick={() => setEditingName(true)}
title={session?.name ?? ''}
>
{session?.name ?? '…'}
</button>
)}
{showActiveFile && active.activeFile && (
<>
<span className="text-muted-foreground/40 mx-1">·</span>
<span
className="text-xs font-mono text-muted-foreground truncate max-w-[200px]"
title={active.activeFile}
>
{active.activeFile}
</span>
</>
)}
<div className="ml-auto shrink-0">
{session && (
<div className="inline-flex items-center rounded-full bg-muted/40 hover:bg-muted/70 px-1">
<ModelPicker
value={session.model}
onChange={async (model) => {
const updated = await api.sessions.update(session.id, { model });
setSession(updated);
}}
/>
</div>
)}
</div>
</>
)}
<div className="ml-auto">
{session && (
<ModelPicker
value={session.model}
onChange={async (model) => {
const updated = await api.sessions.update(session.id, { model });
setSession(updated);
}}
/>
)}
</div>
</header>
{id && session && (
<Workspace sessionId={id} projectId={session.project_id} />
{session && (
<Workspace
sessionId={sessionId}
projectId={session.project_id}
agentId={session.agent_id}
onAgentChange={async (agent_id) => {
const updated = await api.sessions.update(session.id, { agent_id });
setSession(updated);
}}
panesHook={panesHook}
chatsHook={chatsHook}
/>
)}
</div>
);

340
boocode_roadmap.md Normal file
View File

@@ -0,0 +1,340 @@
# BooCode v1.x — Roadmap
Last updated: 2026-05-16
## Overview
BooCode is a standalone code-chat tool at `/opt/boocode/`. Read-only by design — pick a project, chat with a local LLM that has file-inspection tools, get streaming responses over WebSocket.
Live at `https://code.indifferentketchup.com` (Caddy → Authelia → Tailscale → `100.114.205.53:9500`).
**Architectural commitments:**
- No embeddings. The model uses file-view tools (`view_file`, `list_dir`, `grep`, `find_files`) + sidecar analyzers (codecontext, codesight). Walked away from the RAG pipeline May 2026.
- Read-only in v1.x. Write tools land in BooCoder (separate container, post-v1.x).
- One Postgres (`boocode_db`), one frontend SPA, container-per-service for new capabilities.
External code lifted from / referenced in: see `boocode_code_review.md` for full inventory.
-----
## Batch summary
|Batch |Theme |Status |Branch / Notes |
|------------------------------------------|-----------------------------------------------------------------------------------|-----------|---------------------------------------|
|1 |Markdown, Copy + Regen, tok/s + ctx, AI naming |✅ Done |`v1.1-batch1` merged |
|2 |Sidebar restructure |✅ Done |`v1.1-batch2` merged |
|3 |Pane system, FileBrowserPane + Shiki, cross-tab |✅ Done |`v1.1-batch3` merged |
|3.5 |Chip infrastructure, `@file`, line-select |✅ Done |merged |
|4 (v1.2) |Chats inside sessions, right-rail, `/compact`, archive, force-send |✅ Done |merged |
|4.14.4 |Project archive, sidebar context, Gitea API, bootstrap |✅ Done |merged |
|v1.5 cleanup |resolveProjectPath, BOOTSTRAP_ROOT, vitest pin |✅ Done |merged |
|v1.6 mobile |Drawer, single-pane, long-press, IME-safe, pull-to-refresh, swipe-close |✅ Done |merged |
|v1.6.1 |RightRail mobile wrapper fix |✅ Done |merged |
|Tool-loop bump |MAX_TOOL_LOOP_DEPTH 5→15 |✅ Done |merged |
|v1.6.2 |Workspace + Session+Project headers + ChatTabBar new-chat + RightRail mobile drawer|🔄 In flight|`v1.6.2-mobile-ui-fixes` |
|**v1.8 mobile tabs** |**Bottom-sheet pane switcher + cross-tab `pane_status` WS sync + StatusDot on tabs**|**Next up**|`v1.8-mobile-tabs`; hand-rolled sheet |
|9 (REORDERED, DECOUPLED) |Agents (Tier 2): `AGENTS.md`, per-agent temp/tools, picker in ChatInput toolbar |✅ Implemented, uncommitted|six builtins; on `main` awaiting commit|
|5 |Fork message, delete message, header polish |Planned | |
|6 |Drag-drop file + paste-as-attachment |Planned |thin extension of 3.5 chips |
|7 |Settings drawer: system prompt, web search toggle, agent entry |Planned |adds SettingsDrawer agent entry (Batch 9 deferred half) |
|8 |Web search backend: SearXNG `web_search` + `web_fetch` |Planned | |
|10 |BooTerm: separate container, xterm.js + node-pty + tmux |Planned | |
|11 — Architect: codebase map |codecontext sidecar + MCP tool wiring |Planned |from nmakod/codecontext |
|11b — Architect: repo health |call graph, circular deps, dead code |Planned |from spirituslab/codesight |
|12 — Tool approval + plan/act mode |Read-only invariant, per-tool gating |Planned |from cline |
|13 — Append-only event log |Replace messages-table semantics |Planned |from OpenHands V1 |
|14 — BooCoder: pending changes |Sandboxed edit queue, atomic apply |Post-v1.x |from plandex |
|15 — BooCoder runtime isolation |Per-session Docker sandbox |Post-v1.x |from OpenHands |
|16 — Multi-provider LLM |Optional litellm-style abstraction |Optional |from pi-ai |
|17 — Workflow graphs |Multi-agent coordination |Far future |from microsoft/agent-framework concepts|
**Old Batch 12 (codebase indexer w/ Harrier embeddings) — REMOVED.** Replaced by Batch 11/11b sidecar approach. See `boocode_code_review.md` decisions log.
**Batch 9 reordered ahead of 58, 10.** Picker mounts in `ChatInput.tsx` toolbar only. SettingsDrawer agent entry rolled into Batch 7 when it lands. No UI dependency on Batches 5/6/7, so it can ship anytime after v1.6.2.
-----
## Batch details (planned / new)
### Batch 9 — Agents (Tier 2, DECOUPLED)
**Spec:** `boocode_batch9.md` with the deltas below.
**Status:** Next up after v1.6.2 merges. Decoupled from Batch 7.
**Deltas from `boocode_batch9.md`:**
1. Builtin defaults in `agents.ts` OMIT the `model` field. Resolution order makes `session.model` win when `agent.model` is null. Spec line 30 example is misleading — do not hardcode any model in builtins.
2. Builtin defaults are the six agents shipped in `/opt/boocode/AGENTS.md`: **Code Reviewer, Debugger, Refactorer, Architect, Security Auditor, Prompt Builder.** If project root `AGENTS.md` exists, only its agents show. If absent, show the six builtins.
3. AgentPicker mounts in `ChatInput.tsx` toolbar between ModelPicker and the `+` button. **No `SettingsDrawer.tsx` or `Header.tsx` changes in this batch.**
4. SettingsDrawer agent entry + Header active-agent badge moved to Batch 7.
**Files to create:**
- `apps/server/src/services/agents.ts` — parser, six builtin defaults, mtime-keyed cache.
- `apps/server/src/routes/agents.ts``GET /api/projects/:id/agents`.
- `apps/web/src/components/AgentPicker.tsx` — dropdown, matches ModelPicker pattern.
**Files to modify:**
- `apps/server/src/schema.sql``ALTER TABLE sessions ADD COLUMN IF NOT EXISTS agent_id TEXT;`
- `apps/server/src/services/inference.ts` — resolution order: `effective_system_prompt`, `effective_model`, `effective_temperature`, `effective_tools` from session + agent + project. Filter tools array against agent whitelist before sending to llama-swap.
- `apps/server/src/routes/sessions.ts` — PATCH accepts `agent_id`.
- `apps/server/src/types/api.ts` — Agent type, extend Session with `agent_id`.
- `apps/web/src/api/client.ts`, `apps/web/src/api/types.ts` — Agent type, `api.agents.list(projectId)`.
- `apps/web/src/components/ChatInput.tsx` — mount AgentPicker.
**Testing plan (manual, before locking temps):**
- Drop `/opt/boocode/AGENTS.md` (six agents, no `model` field on any).
- For each of the 7 keeper models, switch session model and run the same target prompt against each agent. Log tok/s, instruction-following quality.
- Adjust per-agent temperature in `AGENTS.md` based on results.
- A/B candidates: qwen3.6-35b-a3b-mxfp4 (daily), qwopus3.6-35b-a3b-q4 (reasoning), qwopus3.5-27b-q4, qwen3.6-27b-ud-q4-xl, nemotron-3-nano-30b, gemma-4-26b-a4b-mxfp4, qwen3-coder-30b-apex.
**Dependencies:** v1.6.2 merged.
-----
### Batch 11 — Architect: codebase map (REVISED)
**Inspiration / lift:** `nmakod/codecontext` (MIT, Go binary).
**What it gives BooCode:** an architect-grade codebase overview without embeddings. Codecontext parses the repo with tree-sitter, extracts symbols, builds import/dependency relationships, and exposes the result via an MCP server with 8 tools. The model gets a structural map of any codebase on demand.
**Why this replaces the original Batch 11 (aider PageRank port):** codecontext is a finished binary in our stack language (Go), with watch mode, incremental updates, framework detection, and git-co-change-based semantic neighborhoods (no embeddings). The aider port would be reimplementing what codecontext already ships.
**Scope:**
- Add `codecontext` sidecar container to `docker-compose.yml`. Mount the project root read-only. One sidecar per BooCode instance — projects are addressed by absolute path.
- Wire each codecontext MCP tool into BooCodes `inference/tools.ts` as a native tool the model can call:
- `repo_overview(project_id)` → codecontext `get_codebase_overview`
- `repo_file_analysis(project_id, path)``get_file_analysis`
- `repo_symbol_info(project_id, symbol)``get_symbol_info`
- `repo_search_symbols(project_id, query)``search_symbols`
- `repo_dependencies(project_id, path)``get_dependencies`
- `repo_semantic_neighborhoods(project_id, path)``get_semantic_neighborhoods` (git co-change)
- `repo_framework_analysis(project_id)``get_framework_analysis`
- `path_guard.ts` extension: incorporate `continuedev/continue` `DEFAULT_SECURITY_IGNORE_FILETYPES` so codecontext cant surface `.env`, `.pem`, keys, etc.
- Fallback grammars: drop `Aider-AI/aider`s `aider/queries/tree-sitter-*.scm` files for any language codecontext doesnt cover. Use them via an in-process tree-sitter wrapper *only if* a project needs an unsupported language. Defer wrapper build until thats an actual gap.
**Where it goes:** new `apps/server/src/architect/` directory. No new tables — codecontext maintains its own state on disk. New env: `CODECONTEXT_URL=http://codecontext:8765` (MCP endpoint).
**Decisions to make at recon time:**
- Bundle the binary directly in the BooCode Dockerfile, or run codecontext as its own service? Sidecar is cleaner. Bundle is one less container.
- How does the model discover codecontext tools — register them statically in the tools registry, or proxy MCP `tools/list` at startup?
**Dependencies:** none. Can ship before Batches 510.
-----
### Batch 11b — Architect: repo health (NEW)
**Inspiration / lift:** `spirituslab/codesight` (MIT-ish, TS/Node).
**What it gives BooCode:** complement to Batch 11. Where codecontext answers “what is this codebase,” repo health answers “whats wrong with this codebase.” Call graph, circular dependency detection, dead code flagging.
**Scope:**
- Port codesights `analyze.mjs` analyzer core into `apps/server/src/architect/repo_health.ts`. Drop the VS Code extension shell. Keep:
- Symbol extraction (already overlaps codecontext — call codecontext where possible, only redo whats needed for graph edges).
- Call graph builder (function-to-function edges).
- Circular dependency detector.
- Dead code detector (exported symbols never imported or called).
- New tool: `repo_health(project_id)` returning `{ circular_dependencies: [...], dead_code: [...] }`. Output respects codesights documented false-positive caveats (customElements.define, framework entry points, dynamic imports) — surface those in the tool description so the model doesnt trust dead-code flags blindly.
- Cache results in `boocode_db` keyed by `(project_id, file_hashes)`. Invalidate on file change via file-index hash check.
**Decisions:**
- Build it in-process (Node) vs spawn a CLI? In-process is simpler. Spawn matches codecontext sidecar pattern but adds latency.
**Dependencies:** Batch 11 merged (so we can reuse codecontexts parse output where possible). Can be deferred until after Batches 510.
-----
### Batch 12 — Tool approval gating + plan/act mode
**Inspiration / lift:** `cline/cline` (Apache-2.0).
**What it gives BooCode:** per-session control over which tools the model can call. Lays the groundwork for BooCoder by building the gating mechanism before there are any write tools to gate.
**Scope:**
- New column `sessions.tool_approval_mode TEXT` — values: `read_only` (v1.x default), `plan`, `act_auto`, `act_approve`.
- New column `sessions.approved_tools JSONB` — per-session whitelist for `act_approve` mode.
- Tool registry refactor: tools tagged `read_only` or `write`. In `read_only` mode (v1.x), write tools never appear in the models tools array. In `plan` mode, same — write tools hidden, model produces a plan only. `act_*` modes unlock writes (post-v1.x).
- UI: mode picker in SettingsDrawer (Batch 7 dependency). Inline indicator in chat header.
**Dependencies:** Batch 7 (SettingsDrawer).
-----
### Batch 13 — Append-only event log
**Inspiration / lift:** `OpenHands/OpenHands` V1 (MIT).
**What it gives BooCode:** replaces the ad-hoc `messages` table semantics with a typed event stream. Unlocks rewind, time-travel, and clean handoff semantics for multi-agent flows.
**Scope:**
- New `session_events` table: `(id, session_id, ts, kind, payload JSONB, parent_id)`. Event kinds: `user_message`, `assistant_message`, `tool_call`, `tool_result`, `pane_action`, `mode_change`, `system`.
- Existing `messages` table becomes a derived view over `session_events` for backward compatibility, then deprecated over a release.
- Inference loop emits events instead of mutating message rows.
- Frontend `useSessionStream` reducer rewritten to consume events.
**Migration is non-trivial.** Plan in a dedicated batch with explicit cutover window.
**Dependencies:** Batches 5 (fork/delete) and 7 (settings) merged. Must not be in flight with other backend work.
-----
### Batch 14 — BooCoder: pending changes
**Inspiration / lift:** `plandex-ai/plandex` (MIT).
**What it gives BooCode:** safe write tools. Edits queue in a virtual layer; nothing touches the filesystem until explicit `/apply`.
**Scope:**
- New container `boocoder` at `100.114.205.53:9502`. Owns write tools (`edit_file`, `create_file`, `delete_file`, `apply_pending`, `rewind`).
- New table `pending_changes (id, session_id, file_path, diff TEXT, status, created_at)`. Status: `pending`, `applied`, `rejected`.
- Tools execute against the pending-changes layer, not the filesystem. `apply_pending` is the only path that touches disk. `rewind` rolls back a `pending`-id back to disk state.
- BooCode chat container stays read-only (`/opt:/opt:ro`). BooCoder mounts `/opt/repos:/opt/repos:rw` and uses git worktree pattern from paseo for isolation.
- Frontend: new pane kind `pending_diff` shows the queued diff inline with Approve/Reject per chunk.
**Dependencies:** Batches 12 (gating) + 13 (events). Dont start until both are live.
-----
### Batch 15 — BooCoder runtime isolation
**Inspiration / lift:** `OpenHands/OpenHands` (MIT).
**What it gives BooCode:** per-session Docker sandbox for BooCoder writes. Closes the `/opt:ro` mount risk identified in v1.x open items.
**Scope:**
- Per-session container spawned by BooCoder on first write. Container has only the projects path mounted, not `/opt`.
- Container lifecycle: spawn on first write call, idle-timeout after 30 min, recreate on resume.
- Action execution server pattern: HTTP API inside the container, BooCoder calls in. Standard OpenHands runtime contract.
**Dependencies:** Batch 14.
-----
### Batch 16 — Multi-provider LLM abstraction
**Inspiration / lift:** `earendil-works/pi` `pi-ai` (MIT).
**What it gives BooCode:** optional non-llama-swap inference paths (Anthropic, OpenAI, Mistral direct). Currently we have one provider (llama-swap) and the existing `streamCompletion` is hardcoded to OpenAI-compatible at that endpoint.
**Scope:**
- Provider abstraction: `interface LLMProvider { stream(req): AsyncIterator<Frame> }`.
- Built-in: llama-swap (current), Anthropic, OpenAI (Codex-style).
- Per-session `provider_id` column.
**Status:** **Optional. Skip unless a concrete need surfaces.** llama-swap covers daily driver work.
-----
### Batch 17 — Workflow graphs
**Inspiration / lift:** `microsoft/agent-framework` (MIT) — concepts only.
**What it gives BooCode:** multi-agent coordination. Architect → Coder → Reviewer → Verifier handoffs orchestrated by a YAML-defined workflow.
**Status:** **Far future.** Read agent-frameworks `docs/decisions/` ADRs. Dont port code — Azure/.NET-heavy.
**Dependencies:** Batches 12 (modes), 13 (events). Realistically a v2.x topic.
-----
## Order of operations
Two tracks. Pick one to drive next.
**Track A — Finish v1.x mobile + polish then agents:**
- v1.6.2 ships (in flight)
- **Batch 9 (agents)** — decoupled, can land next; no UI dependency on 5/6/7
- Batches 5, 6, 7, 8 in order. Each is small, frontend-heavy, no architecture risk. Batch 7 absorbs SettingsDrawer agent entry.
**Track B — Begin architect capabilities in parallel:**
- Batch 11 (codecontext sidecar) — biggest single capability jump. Frontend stays the same; new tools appear to the model.
- Batch 11b (repo health) — follow-up.
- Batch 12 (gating) — sets up everything post-v1.x.
Recommendation: ship v1.6.2, then **Batch 9 (agents)** next so the test bed exists before Track A continues. Then Track A through Batch 7. Batch 11 can run in parallel with Batches 810 since 11 has no UI dependency.
-----
## Architecture target state
### Containers
|Container |Port |Mount |Purpose |Status |
|-------------|--------------------------------|--------------------------|------------------------------|--------|
|`boocode` |`100.114.205.53:9500` |`/opt:/opt:ro` |Chat + read-only tools + SPA |Live |
|`boocode_db` |`127.0.0.1:5500` |`boocode_pgdata` volume |Postgres 16-alpine |Live |
|`codecontext`|`100.114.205.53:8765` (internal)|project root :ro |MCP server for architect tools|Batch 11|
|`booterm` |`100.114.205.53:9501` |`/opt/repos:/opt/repos:rw`|Terminals (tmux + node-pty) |Batch 10|
|`boocoder` |`100.114.205.53:9502` |per-session sandbox |Write tools |Batch 14|
### Schema additions
**Batch 9:** `sessions.agent_id TEXT` (nullable; references AGENTS.md by slug).
**Batch 11:** none (codecontext stateless on disk).
**Batch 11b:** `repo_health_cache (project_id, file_hashes_sig, payload JSONB, created_at)`.
**Batch 12:** `sessions.tool_approval_mode`, `sessions.approved_tools`.
**Batch 13:** `session_events`; deprecate `messages` long-tail.
**Batch 14:** `pending_changes`.
-----
## Lift sources (summary)
Full inventory in `boocode_code_review.md`. Headline items:
|Source |Used for |Where |
|--------------------------------------|----------------------------------------|---------------------|
|nmakod/codecontext (MIT, Go) |Architect: codebase map sidecar |Batch 11 |
|spirituslab/codesight (MIT-ish, TS) |Architect: repo health analyzer |Batch 11b |
|Aider-AI/aider (Apache-2.0) |Fallback `.scm` grammars (60+ languages)|Batch 11 (fallback) |
|continuedev/continue (Apache-2.0) |DEFAULT_SECURITY_IGNORE_FILETYPES |Batch 11 prep |
|cline/cline (Apache-2.0) |Plan/Act mode pattern |Batch 12 |
|plandex-ai/plandex (MIT) |Pending-changes data model |Batch 14 |
|OpenHands/OpenHands (MIT) |Event log + sandbox runtime |Batches 13, 15 |
|aimasteracc/tree-sitter-analyzer (MIT)|Outline-first response patterns |Reference |
|earendil-works/pi (MIT) |Multi-provider LLM |Batch 16 (optional) |
|rshah515/claude-code-subagents (MIT) |Reference for builtin agent prompts |Batch 9 (six builtins)|
|microsoft/agent-framework (MIT) |Workflow concepts |Batch 17 (far future)|
-----
## Decisions log
- **Embeddings dropped from BooCode.** Replaced RAG with file-view tools + sidecar analyzers.
- **Original Batch 11 (aider PageRank port) replaced** by codecontext sidecar approach.
- **Original Batch 12 (codebase indexer w/ Harrier) removed** entirely. No embedding infrastructure in BooCode v1.x.
- **Globstar parked** — not an architect tool, future verify-before-commit candidate only.
- **codeprysm rejected** — embedding-based; node/edge taxonomy noted as reference if we ever build our own graph.
- **Batch 9 decoupled from Batch 7 (2026-05-16).** AgentPicker mounts in `ChatInput.tsx` toolbar only. SettingsDrawer agent entry and Header active-agent badge moved to Batch 7. Builtin defaults shipped: six agents (Code Reviewer, Debugger, Refactorer, Architect, Security Auditor, Prompt Builder) with no `model` field — session model wins by default.
-----
## Workflow
Each batch:
1. Verify previous batch merged.
2. Dispatch via Paseo to Claude Code at `/opt/boocode`.
3. Claude Code recon → blocking questions → implement → hand back.
4. Compliance review in separate Claude chat.
5. Deploy: `docker compose up --build -d`.
6. Smoke test.
7. Sam commits and pushes.
Sam reviews all diffs. Sam commits. Never git pull/push/commit on his behalf.

View File

@@ -9,7 +9,15 @@ services:
environment:
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boocode
volumes:
- /opt:/opt:rw
# Read-only mount for legacy/existing project add-existing flow.
- /opt:/opt:ro
# Writable mount only for the create-new-project bootstrap target.
# Host must `mkdir -p /opt/projects` before container start.
- /opt/projects:/opt/projects:rw
- ./secrets/boocode_gitea:/root/.ssh/id_ed25519:ro
# v1.8.1: global agents file. Host seeds it once before deploy:
# cp /opt/boocode/AGENTS.md /opt/boocode/data/AGENTS.md
- ./data:/data:ro
depends_on:
- boocode_db
networks:

322
pnpm-lock.yaml generated
View File

@@ -45,6 +45,9 @@ importers:
typescript:
specifier: ^5.5.0
version: 5.9.3
vitest:
specifier: ^3.2.4
version: 3.2.4(@types/debug@4.1.13)(@types/node@20.19.41)(lightningcss@1.32.0)(msw@2.14.6(@types/node@20.19.41)(typescript@5.9.3))
apps/web:
dependencies:
@@ -1697,9 +1700,15 @@ packages:
'@types/babel__traverse@7.28.0':
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/debug@4.1.13':
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/estree-jsx@1.0.5':
resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
@@ -1756,6 +1765,35 @@ packages:
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
'@vitest/expect@3.2.4':
resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==}
'@vitest/mocker@3.2.4':
resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==}
peerDependencies:
msw: ^2.4.9
vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
'@vitest/pretty-format@3.2.4':
resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==}
'@vitest/runner@3.2.4':
resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==}
'@vitest/snapshot@3.2.4':
resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==}
'@vitest/spy@3.2.4':
resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==}
'@vitest/utils@3.2.4':
resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==}
abstract-logging@2.0.1:
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
@@ -1809,6 +1847,10 @@ packages:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
ast-types@0.16.1:
resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==}
engines: {node: '>=4'}
@@ -1863,6 +1905,10 @@ packages:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'}
cac@6.7.14:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
@@ -1881,6 +1927,10 @@ packages:
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
chai@5.3.3:
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
engines: {node: '>=18'}
chalk@5.6.2:
resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
@@ -1897,6 +1947,10 @@ packages:
character-reference-invalid@2.0.1:
resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==}
check-error@2.1.3:
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
engines: {node: '>= 16'}
class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
@@ -2021,6 +2075,10 @@ packages:
babel-plugin-macros:
optional: true
deep-eql@5.0.2:
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
engines: {node: '>=6'}
deepmerge@4.3.1:
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
engines: {node: '>=0.10.0'}
@@ -2121,6 +2179,9 @@ packages:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
es-module-lexer@1.7.0:
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
es-object-atoms@1.1.1:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'}
@@ -2154,6 +2215,9 @@ packages:
estree-util-is-identifier-name@3.0.0:
resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
etag@1.8.1:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'}
@@ -2174,6 +2238,10 @@ packages:
resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==}
engines: {node: ^18.19.0 || >=20.5.0}
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
express-rate-limit@8.5.2:
resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==}
engines: {node: '>= 16'}
@@ -2529,6 +2597,9 @@ packages:
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
js-tokens@9.0.1:
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
js-yaml@4.1.1:
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
hasBin: true
@@ -2653,6 +2724,9 @@ packages:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
@@ -2998,6 +3072,13 @@ packages:
path-to-regexp@8.4.2:
resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
pathval@2.0.1:
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
engines: {node: '>= 14.16'}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -3308,6 +3389,9 @@ packages:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
engines: {node: '>= 0.4'}
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
signal-exit@3.0.7:
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
@@ -3342,6 +3426,9 @@ packages:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
statuses@2.0.1:
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
engines: {node: '>= 0.8'}
@@ -3350,6 +3437,9 @@ packages:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
std-env@3.10.0:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
stdin-discarder@0.2.2:
resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==}
engines: {node: '>=18'}
@@ -3402,6 +3492,9 @@ packages:
resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==}
engines: {node: '>=18'}
strip-literal@3.1.0:
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
style-to-js@1.1.21:
resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==}
@@ -3428,6 +3521,28 @@ packages:
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
tinyexec@0.3.2:
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
tinyglobby@0.2.16:
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
engines: {node: '>=12.0.0'}
tinypool@1.1.1:
resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}
engines: {node: ^18.0.0 || >=20.0.0}
tinyrainbow@2.0.0:
resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
engines: {node: '>=14.0.0'}
tinyspy@4.0.4:
resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==}
engines: {node: '>=14.0.0'}
tldts-core@7.0.30:
resolution: {integrity: sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==}
@@ -3572,6 +3687,11 @@ packages:
vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
vite-node@3.2.4:
resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
vite@5.4.21:
resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==}
engines: {node: ^18.0.0 || >=20.0.0}
@@ -3603,6 +3723,34 @@ packages:
terser:
optional: true
vitest@3.2.4:
resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@types/debug': ^4.1.12
'@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
'@vitest/browser': 3.2.4
'@vitest/ui': 3.2.4
happy-dom: '*'
jsdom: '*'
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
'@types/debug':
optional: true
'@types/node':
optional: true
'@vitest/browser':
optional: true
'@vitest/ui':
optional: true
happy-dom:
optional: true
jsdom:
optional: true
web-streams-polyfill@3.3.3:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'}
@@ -3617,6 +3765,11 @@ packages:
engines: {node: ^16.13.0 || >=18.0.0}
hasBin: true
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@@ -5196,10 +5349,17 @@ snapshots:
dependencies:
'@babel/types': 7.29.0
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
'@types/debug@4.1.13':
dependencies:
'@types/ms': 2.1.0
'@types/deep-eql@4.0.2': {}
'@types/estree-jsx@1.0.5':
dependencies:
'@types/estree': 1.0.8
@@ -5261,6 +5421,49 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@vitest/expect@3.2.4':
dependencies:
'@types/chai': 5.2.3
'@vitest/spy': 3.2.4
'@vitest/utils': 3.2.4
chai: 5.3.3
tinyrainbow: 2.0.0
'@vitest/mocker@3.2.4(msw@2.14.6(@types/node@20.19.41)(typescript@5.9.3))(vite@5.4.21(@types/node@20.19.41)(lightningcss@1.32.0))':
dependencies:
'@vitest/spy': 3.2.4
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
msw: 2.14.6(@types/node@20.19.41)(typescript@5.9.3)
vite: 5.4.21(@types/node@20.19.41)(lightningcss@1.32.0)
'@vitest/pretty-format@3.2.4':
dependencies:
tinyrainbow: 2.0.0
'@vitest/runner@3.2.4':
dependencies:
'@vitest/utils': 3.2.4
pathe: 2.0.3
strip-literal: 3.1.0
'@vitest/snapshot@3.2.4':
dependencies:
'@vitest/pretty-format': 3.2.4
magic-string: 0.30.21
pathe: 2.0.3
'@vitest/spy@3.2.4':
dependencies:
tinyspy: 4.0.4
'@vitest/utils@3.2.4':
dependencies:
'@vitest/pretty-format': 3.2.4
loupe: 3.2.1
tinyrainbow: 2.0.0
abstract-logging@2.0.1: {}
accepts@2.0.0:
@@ -5301,6 +5504,8 @@ snapshots:
dependencies:
tslib: 2.8.1
assertion-error@2.0.1: {}
ast-types@0.16.1:
dependencies:
tslib: 2.8.1
@@ -5360,6 +5565,8 @@ snapshots:
bytes@3.1.2: {}
cac@6.7.14: {}
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
@@ -5376,6 +5583,14 @@ snapshots:
ccount@2.0.1: {}
chai@5.3.3:
dependencies:
assertion-error: 2.0.1
check-error: 2.1.3
deep-eql: 5.0.2
loupe: 3.2.1
pathval: 2.0.1
chalk@5.6.2: {}
character-entities-html4@2.1.0: {}
@@ -5386,6 +5601,8 @@ snapshots:
character-reference-invalid@2.0.1: {}
check-error@2.1.3: {}
class-variance-authority@0.7.1:
dependencies:
clsx: 2.1.1
@@ -5474,6 +5691,8 @@ snapshots:
dedent@1.7.2: {}
deep-eql@5.0.2: {}
deepmerge@4.3.1: {}
default-browser-id@5.0.1: {}
@@ -5556,6 +5775,8 @@ snapshots:
es-errors@1.3.0: {}
es-module-lexer@1.7.0: {}
es-object-atoms@1.1.1:
dependencies:
es-errors: 1.3.0
@@ -5625,6 +5846,10 @@ snapshots:
estree-util-is-identifier-name@3.0.0: {}
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.8
etag@1.8.1: {}
eventsource-parser@3.0.8: {}
@@ -5660,6 +5885,8 @@ snapshots:
strip-final-newline: 4.0.0
yoctocolors: 2.1.2
expect-type@1.3.0: {}
express-rate-limit@8.5.2(express@5.2.1):
dependencies:
express: 5.2.1
@@ -6053,6 +6280,8 @@ snapshots:
js-tokens@4.0.0: {}
js-tokens@9.0.1: {}
js-yaml@4.1.1:
dependencies:
argparse: 2.0.1
@@ -6149,6 +6378,8 @@ snapshots:
dependencies:
js-tokens: 4.0.0
loupe@3.2.1: {}
lru-cache@10.4.3: {}
lru-cache@5.1.1:
@@ -6700,6 +6931,10 @@ snapshots:
path-to-regexp@8.4.2: {}
pathe@2.0.3: {}
pathval@2.0.1: {}
picocolors@1.1.1: {}
picomatch@2.3.2: {}
@@ -7178,6 +7413,8 @@ snapshots:
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
siginfo@2.0.0: {}
signal-exit@3.0.7: {}
signal-exit@4.1.0: {}
@@ -7201,10 +7438,14 @@ snapshots:
split2@4.2.0: {}
stackback@0.0.2: {}
statuses@2.0.1: {}
statuses@2.0.2: {}
std-env@3.10.0: {}
stdin-discarder@0.2.2: {}
stream-shift@1.0.3: {}
@@ -7258,6 +7499,10 @@ snapshots:
strip-final-newline@4.0.0: {}
strip-literal@3.1.0:
dependencies:
js-tokens: 9.0.1
style-to-js@1.1.21:
dependencies:
style-to-object: 1.0.14
@@ -7280,6 +7525,21 @@ snapshots:
tiny-invariant@1.3.3: {}
tinybench@2.9.0: {}
tinyexec@0.3.2: {}
tinyglobby@0.2.16:
dependencies:
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
tinypool@1.1.1: {}
tinyrainbow@2.0.0: {}
tinyspy@4.0.4: {}
tldts-core@7.0.30: {}
tldts@7.0.30:
@@ -7419,6 +7679,24 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
vite-node@3.2.4(@types/node@20.19.41)(lightningcss@1.32.0):
dependencies:
cac: 6.7.14
debug: 4.4.3
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 5.4.21(@types/node@20.19.41)(lightningcss@1.32.0)
transitivePeerDependencies:
- '@types/node'
- less
- lightningcss
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
vite@5.4.21(@types/node@20.19.41)(lightningcss@1.32.0):
dependencies:
esbuild: 0.21.5
@@ -7429,6 +7707,45 @@ snapshots:
fsevents: 2.3.3
lightningcss: 1.32.0
vitest@3.2.4(@types/debug@4.1.13)(@types/node@20.19.41)(lightningcss@1.32.0)(msw@2.14.6(@types/node@20.19.41)(typescript@5.9.3)):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
'@vitest/mocker': 3.2.4(msw@2.14.6(@types/node@20.19.41)(typescript@5.9.3))(vite@5.4.21(@types/node@20.19.41)(lightningcss@1.32.0))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4
'@vitest/spy': 3.2.4
'@vitest/utils': 3.2.4
chai: 5.3.3
debug: 4.4.3
expect-type: 1.3.0
magic-string: 0.30.21
pathe: 2.0.3
picomatch: 4.0.4
std-env: 3.10.0
tinybench: 2.9.0
tinyexec: 0.3.2
tinyglobby: 0.2.16
tinypool: 1.1.1
tinyrainbow: 2.0.0
vite: 5.4.21(@types/node@20.19.41)(lightningcss@1.32.0)
vite-node: 3.2.4(@types/node@20.19.41)(lightningcss@1.32.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/debug': 4.1.13
'@types/node': 20.19.41
transitivePeerDependencies:
- less
- lightningcss
- msw
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
web-streams-polyfill@3.3.3: {}
which@2.0.2:
@@ -7439,6 +7756,11 @@ snapshots:
dependencies:
isexe: 3.1.5
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0