Compare commits
8 Commits
v2.8.20-pa
...
v2.8.24-me
| Author | SHA1 | Date | |
|---|---|---|---|
| 6fde7002aa | |||
| 50de80ee75 | |||
| 51733c1338 | |||
| fa07b01567 | |||
| e2d6a6b6cd | |||
| 381b97f78a | |||
| 9e2b0a7dc0 | |||
| 51f2f4284f |
@@ -3,9 +3,9 @@
|
|||||||
> **Stack:** fastify, go-net-http | none | react | typescript
|
> **Stack:** fastify, go-net-http | none | react | typescript
|
||||||
> **Microservices:** @boocode/contracts, @boocode/ion, @boocode/booterm, @boocode/coder, @boocode/server, @boocode/web, codecontext, @boocode/conductor
|
> **Microservices:** @boocode/contracts, @boocode/ion, @boocode/booterm, @boocode/coder, @boocode/server, @boocode/web, codecontext, @boocode/conductor
|
||||||
|
|
||||||
> 131 routes (9 inferred) + 9 ws | 18 models | 69 components | 247 lib files | 39 env vars | 16 middleware
|
> 147 routes (9 inferred) + 9 ws | 23 models | 92 components | 296 lib files | 43 env vars | 17 middleware
|
||||||
> **Token savings:** this file is ~0 tokens. Without it, AI exploration would cost ~0 tokens. **Saves ~0 tokens per conversation.**
|
> **Token savings:** this file is ~0 tokens. Without it, AI exploration would cost ~0 tokens. **Saves ~0 tokens per conversation.**
|
||||||
> **Last scanned:** 2026-06-07 21:09 — re-run after significant changes
|
> **Last scanned:** 2026-06-08 03:49 — re-run after significant changes
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
## CRUD Resources
|
## CRUD Resources
|
||||||
|
|
||||||
- **`/api/battles`** GET | POST | GET/:id → Battle
|
- **`/api/battles`** GET | POST | GET/:id → Battle
|
||||||
|
- **`/api/plans`** GET | POST | GET/:id | PATCH/:id → Plan
|
||||||
- **`/api/runs`** GET | POST | GET/:id → Run
|
- **`/api/runs`** GET | POST | GET/:id → Run
|
||||||
- **`/api/tasks`** GET | POST | GET/:id → Task
|
- **`/api/tasks`** GET | POST | GET/:id → Task
|
||||||
- **`/api/chats/:id/messages`** GET | POST | GET/:id | DELETE/:id → Message
|
- **`/api/chats/:id/messages`** GET | POST | GET/:id | DELETE/:id → Message
|
||||||
@@ -25,11 +26,16 @@
|
|||||||
### fastify
|
### fastify
|
||||||
|
|
||||||
- `GET` `/api/term/health` params()
|
- `GET` `/api/term/health` params()
|
||||||
|
- `GET` `/api/term/sessions/:sid/panes/:pid/search` params(sid, pid) [auth]
|
||||||
|
- `GET` `/api/term/sessions` params() [auth]
|
||||||
- `POST` `/api/term/sessions/:sid/panes/:pid/start` params(sid, pid) [auth]
|
- `POST` `/api/term/sessions/:sid/panes/:pid/start` params(sid, pid) [auth]
|
||||||
- `POST` `/api/term/sessions/:sid/panes/:pid/kill` params(sid, pid) [auth]
|
- `POST` `/api/term/sessions/:sid/panes/:pid/kill` params(sid, pid) [auth]
|
||||||
- `GET` `/ws/term/sessions/:sid/panes/:pid` params(sid, pid) [auth]
|
- `GET` `/ws/term/sessions/:sid/panes/:pid` params(sid, pid) [auth]
|
||||||
- `GET` `/api/health` params() [auth, db, queue, ai]
|
- `GET` `/api/health` params() [auth, db, queue, ai]
|
||||||
- `GET` `/api/sessions/:sessionId/agent-sessions` params(sessionId) [auth, db]
|
- `GET` `/api/sessions/:sessionId/agent-sessions` params(sessionId) [auth, db]
|
||||||
|
- `GET` `/api/analytics/summary` params() [auth, db]
|
||||||
|
- `GET` `/api/analytics/sessions` params() [auth, db]
|
||||||
|
- `GET` `/api/analytics/token-breakdown` params() [auth, db]
|
||||||
- `POST` `/api/battles/generate-prompt` params() [auth, db]
|
- `POST` `/api/battles/generate-prompt` params() [auth, db]
|
||||||
- `POST` `/api/battles/:id/stop` params(id) [auth, db]
|
- `POST` `/api/battles/:id/stop` params(id) [auth, db]
|
||||||
- `GET` `/api/battles/:id/analysis` params(id) [auth, db]
|
- `GET` `/api/battles/:id/analysis` params(id) [auth, db]
|
||||||
@@ -53,6 +59,7 @@
|
|||||||
- `POST` `/api/pending/:id/apply` params(id) [auth, db, queue]
|
- `POST` `/api/pending/:id/apply` params(id) [auth, db, queue]
|
||||||
- `POST` `/api/pending/:id/reject` params(id) [auth, db, queue]
|
- `POST` `/api/pending/:id/reject` params(id) [auth, db, queue]
|
||||||
- `POST` `/api/pending/:id/rewind` params(id) [auth, db, queue]
|
- `POST` `/api/pending/:id/rewind` params(id) [auth, db, queue]
|
||||||
|
- `GET` `/api/plans/active` params() [db]
|
||||||
- `GET` `/api/providers/snapshot` params() [db, cache]
|
- `GET` `/api/providers/snapshot` params() [db, cache]
|
||||||
- `GET` `/api/providers/config` params() [db, cache]
|
- `GET` `/api/providers/config` params() [db, cache]
|
||||||
- `PATCH` `/api/providers/config` params() [db, cache]
|
- `PATCH` `/api/providers/config` params() [db, cache]
|
||||||
@@ -70,19 +77,22 @@
|
|||||||
- `GET` `/api/ws/sessions/:sessionId` params(sessionId) [auth, db]
|
- `GET` `/api/ws/sessions/:sessionId` params(sessionId) [auth, db]
|
||||||
- `GET` `/api/ws/user` params() [auth, db]
|
- `GET` `/api/ws/user` params() [auth, db]
|
||||||
- `GET` `/api/projects/:id/agents` params(id) [db, cache]
|
- `GET` `/api/projects/:id/agents` params(id) [db, cache]
|
||||||
|
- `GET` `/api/analytics/context` params() [auth, db]
|
||||||
- `POST` `/api/chats/:id/messages/:msg_id/artifacts/download` params(id, msg_id) [auth, db]
|
- `POST` `/api/chats/:id/messages/:msg_id/artifacts/download` params(id, msg_id) [auth, db]
|
||||||
- `GET` `/api/chats/:id/messages/:msg_id/html_artifact` params(id, msg_id) [auth, db]
|
- `GET` `/api/chats/:id/messages/:msg_id/html_artifact` params(id, msg_id) [auth, db]
|
||||||
- `GET` `/api/projects/:project_id/artifacts/:filename` params(project_id, filename) [auth, db]
|
- `GET` `/api/projects/:project_id/artifacts/:filename` params(project_id, filename) [auth, db]
|
||||||
- `GET` `/api/sessions/:id/chats` params(id) [auth, db]
|
- `GET` `/api/sessions/:id/chats` params(id) [auth, db, queue]
|
||||||
- `POST` `/api/sessions/:id/chats` params(id) [auth, db]
|
- `POST` `/api/sessions/:id/chats` params(id) [auth, db, queue]
|
||||||
- `PATCH` `/api/chats/:id` params(id) [auth, db]
|
- `PATCH` `/api/chats/:id` params(id) [auth, db, queue]
|
||||||
- `POST` `/api/sessions/:id/chats/archive-all` params(id) [auth, db]
|
- `POST` `/api/sessions/:id/chats/archive-all` params(id) [auth, db, queue]
|
||||||
- `GET` `/api/sessions/:id/chats/open-count` params(id) [auth, db]
|
- `GET` `/api/sessions/:id/chats/open-count` params(id) [auth, db, queue]
|
||||||
- `POST` `/api/chats/:id/archive` params(id) [auth, db]
|
- `POST` `/api/chats/:id/archive` params(id) [auth, db, queue]
|
||||||
- `POST` `/api/chats/:id/unarchive` params(id) [auth, db]
|
- `POST` `/api/chats/:id/unarchive` params(id) [auth, db, queue]
|
||||||
- `DELETE` `/api/chats/:id` params(id) [auth, db]
|
- `DELETE` `/api/chats/:id` params(id) [auth, db, queue]
|
||||||
- `POST` `/api/chats/:id/fork` params(id) [auth, db]
|
- `POST` `/api/chats/:id/fork` params(id) [auth, db, queue]
|
||||||
- `POST` `/api/chats/:id/discard_stale` params(id) [auth, db]
|
- `POST` `/api/chats/:id/discard_stale` params(id) [auth, db, queue]
|
||||||
|
- `GET` `/api/chats/:id/export` params(id) [auth, db, queue]
|
||||||
|
- `POST` `/api/chats/:id/compare` params(id) [auth, db, queue]
|
||||||
- `GET` `/api/coder/ws/sessions/:sessionId` params(sessionId) [auth]
|
- `GET` `/api/coder/ws/sessions/:sessionId` params(sessionId) [auth]
|
||||||
- `ALL` `/api/coder/*` params() [auth]
|
- `ALL` `/api/coder/*` params() [auth]
|
||||||
- `GET` `/api/settings/inference` params() [cache]
|
- `GET` `/api/settings/inference` params() [cache]
|
||||||
@@ -94,7 +104,9 @@
|
|||||||
- `POST` `/api/chats/:id/continue` params(id) [auth, db, queue]
|
- `POST` `/api/chats/:id/continue` params(id) [auth, db, queue]
|
||||||
- `POST` `/api/chats/:id/force_send` params(id) [auth, db, queue]
|
- `POST` `/api/chats/:id/force_send` params(id) [auth, db, queue]
|
||||||
- `POST` `/api/chats/:id/grant_read_access` params(id) [auth, db, queue]
|
- `POST` `/api/chats/:id/grant_read_access` params(id) [auth, db, queue]
|
||||||
- `GET` `/api/models` params()
|
- `POST` `/api/chats/:id/mcp-approve` params(id) [auth, db, queue]
|
||||||
|
- `POST` `/api/chats/:id/messages/:message_id/feedback` params(id, message_id) [auth, db, queue]
|
||||||
|
- `GET` `/api/models` params() [auth]
|
||||||
- `POST` `/api/projects/create` params() [auth, db]
|
- `POST` `/api/projects/create` params() [auth, db]
|
||||||
- `POST` `/api/projects/:id/archive` params(id) [auth, db]
|
- `POST` `/api/projects/:id/archive` params(id) [auth, db]
|
||||||
- `POST` `/api/projects/:id/unarchive` params(id) [auth, db]
|
- `POST` `/api/projects/:id/unarchive` params(id) [auth, db]
|
||||||
@@ -122,6 +134,7 @@
|
|||||||
- `GET` `/api/skills` params() [auth, db, queue]
|
- `GET` `/api/skills` params() [auth, db, queue]
|
||||||
- `POST` `/api/chats/:id/skill_invoke` params(id) [auth, db, queue]
|
- `POST` `/api/chats/:id/skill_invoke` params(id) [auth, db, queue]
|
||||||
- `GET` `/api/tools/cost_stats` params() [auth, db]
|
- `GET` `/api/tools/cost_stats` params() [auth, db]
|
||||||
|
- `GET` `/api/chats/:id/traces` params(id) [db]
|
||||||
- `GET` `/api/ws/sessions/:id` params(id) [auth, db]
|
- `GET` `/api/ws/sessions/:id` params(id) [auth, db]
|
||||||
|
|
||||||
### go-net-http
|
### go-net-http
|
||||||
@@ -273,6 +286,25 @@
|
|||||||
- model: text (required)
|
- model: text (required)
|
||||||
- verdict: text
|
- verdict: text
|
||||||
|
|
||||||
|
### flow_step_events
|
||||||
|
- id: uuid (pk)
|
||||||
|
- run_id: uuid (required, fk)
|
||||||
|
- step_id: varchar (required, fk)
|
||||||
|
- event: varchar (required)
|
||||||
|
- payload: jsonb
|
||||||
|
|
||||||
|
### plans
|
||||||
|
- id: uuid (pk)
|
||||||
|
- project_id: uuid (required, fk)
|
||||||
|
- title: text (required)
|
||||||
|
- description: text
|
||||||
|
- status: text (required)
|
||||||
|
- flow_run_id: uuid (fk)
|
||||||
|
- progress_pct: integer (required)
|
||||||
|
- items_total: integer (required)
|
||||||
|
- items_completed: integer (required)
|
||||||
|
- metadata: jsonb
|
||||||
|
|
||||||
### projects
|
### projects
|
||||||
- id: uuid (pk)
|
- id: uuid (pk)
|
||||||
- name: text (required)
|
- name: text (required)
|
||||||
@@ -294,6 +326,8 @@
|
|||||||
- content: text (required)
|
- content: text (required)
|
||||||
- status: text (required)
|
- status: text (required)
|
||||||
- last_seq: integer (required)
|
- last_seq: integer (required)
|
||||||
|
- cache_tokens: integer
|
||||||
|
- reasoning_tokens: integer
|
||||||
|
|
||||||
### message_parts
|
### message_parts
|
||||||
- id: uuid (pk)
|
- id: uuid (pk)
|
||||||
@@ -311,6 +345,45 @@
|
|||||||
- name: text
|
- name: text
|
||||||
- status: text (required)
|
- status: text (required)
|
||||||
|
|
||||||
|
### tool_traces
|
||||||
|
- id: uuid (pk)
|
||||||
|
- session_id: uuid (required, fk)
|
||||||
|
- chat_id: uuid (required, fk)
|
||||||
|
- message_id: uuid (fk)
|
||||||
|
- turn_number: integer (required)
|
||||||
|
- tool_name: text (required)
|
||||||
|
- tool_input: jsonb (required)
|
||||||
|
- tool_output: text
|
||||||
|
- started_at: timestamp(tz) (required)
|
||||||
|
- finished_at: timestamp(tz)
|
||||||
|
- latency_ms: integer
|
||||||
|
- tokens_used: integer
|
||||||
|
- cache_tokens: integer
|
||||||
|
- reasoning_tokens: integer
|
||||||
|
- error: text
|
||||||
|
- outcome: text
|
||||||
|
|
||||||
|
### tool_trace_states
|
||||||
|
- id: uuid (pk)
|
||||||
|
- session_id: uuid (required, fk)
|
||||||
|
- chat_id: uuid (required, fk)
|
||||||
|
- message_id: uuid (fk)
|
||||||
|
- turn_number: integer (required)
|
||||||
|
- tool_name: text (required)
|
||||||
|
- tool_input: jsonb (required)
|
||||||
|
- started_at: timestamp(tz) (required)
|
||||||
|
|
||||||
|
### agent_snapshots
|
||||||
|
- id: uuid (pk)
|
||||||
|
- session_id: uuid (required, fk)
|
||||||
|
- chat_id: uuid (required, fk)
|
||||||
|
- model: text (required)
|
||||||
|
- agent: text
|
||||||
|
- mode: text
|
||||||
|
- turn_number: integer (required)
|
||||||
|
- messages: jsonb (required)
|
||||||
|
- tool_states: jsonb (required)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Components
|
# Components
|
||||||
@@ -325,23 +398,34 @@
|
|||||||
- **AttachmentChip** — props: attachment, onRemove, onPreview — `apps/web/src/components/AttachmentChip.tsx`
|
- **AttachmentChip** — props: attachment, onRemove, onPreview — `apps/web/src/components/AttachmentChip.tsx`
|
||||||
- **AttachmentPreviewModal** — props: attachment, onClose — `apps/web/src/components/AttachmentPreviewModal.tsx`
|
- **AttachmentPreviewModal** — props: attachment, onClose — `apps/web/src/components/AttachmentPreviewModal.tsx`
|
||||||
- **BottomSheet** — props: open, onClose, title — `apps/web/src/components/BottomSheet.tsx`
|
- **BottomSheet** — props: open, onClose, title — `apps/web/src/components/BottomSheet.tsx`
|
||||||
|
- **CacheShapeBadge** — props: cacheTokens, totalTokens — `apps/web/src/components/CacheShapeBadge.tsx`
|
||||||
- **CapHitSentinel** — props: message, capHitPosition, isLatest — `apps/web/src/components/CapHitSentinel.tsx`
|
- **CapHitSentinel** — props: message, capHitPosition, isLatest — `apps/web/src/components/CapHitSentinel.tsx`
|
||||||
- **ChatInput** — props: disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, generating, onStop — `apps/web/src/components/ChatInput.tsx`
|
- **ChatInput** — props: disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, generating, onStop — `apps/web/src/components/ChatInput.tsx`
|
||||||
- **ChatTabBar** — props: pane, tabs, tabNumbers, onSwitchTab, onRemoveTab, onCloseOthers, onCloseToRight, onCloseAll, onNewTab, onSplitPane — `apps/web/src/components/ChatTabBar.tsx`
|
- **ChatTabBar** — props: pane, tabs, tabNumbers, onSwitchTab, onRemoveTab, onCloseOthers, onCloseToRight, onCloseAll, onNewTab, onSplitPane — `apps/web/src/components/ChatTabBar.tsx`
|
||||||
- **ChatThroughput** — props: chatId, className — `apps/web/src/components/ChatThroughput.tsx`
|
- **ChatThroughput** — props: chatId, className — `apps/web/src/components/ChatThroughput.tsx`
|
||||||
- **CodeBlock** — props: code, lang — `apps/web/src/components/CodeBlock.tsx`
|
- **CodeBlock** — props: code, lang — `apps/web/src/components/CodeBlock.tsx`
|
||||||
|
- **ComparePane** — props: models, responses, onClose — `apps/web/src/components/ComparePane.tsx`
|
||||||
- **ContextMeter** — props: messages, modelContextLimit, sessionCostUsd — `apps/web/src/components/ContextMeter.tsx`
|
- **ContextMeter** — props: messages, modelContextLimit, sessionCostUsd — `apps/web/src/components/ContextMeter.tsx`
|
||||||
- **CreateProjectModal** — props: open, onOpenChange — `apps/web/src/components/CreateProjectModal.tsx`
|
- **CreateProjectModal** — props: open, onOpenChange — `apps/web/src/components/CreateProjectModal.tsx`
|
||||||
|
- **DiffSnippet** — props: diff — `apps/web/src/components/DiffSnippet.tsx`
|
||||||
|
- **DiffSplitView** — props: file, wrapLines — `apps/web/src/components/DiffSplitView.tsx`
|
||||||
- **DoomLoopSentinel** — props: message — `apps/web/src/components/DoomLoopSentinel.tsx`
|
- **DoomLoopSentinel** — props: message — `apps/web/src/components/DoomLoopSentinel.tsx`
|
||||||
- **DropOverlay** — props: visible — `apps/web/src/components/DropOverlay.tsx`
|
- **DropOverlay** — props: visible — `apps/web/src/components/DropOverlay.tsx`
|
||||||
|
- **EmptyState** — props: icon, title, description, action, className — `apps/web/src/components/EmptyState.tsx`
|
||||||
- **FileMentionPopover** — props: query, files, anchorRect, onSelect, onClose — `apps/web/src/components/FileMentionPopover.tsx`
|
- **FileMentionPopover** — props: query, files, anchorRect, onSelect, onClose — `apps/web/src/components/FileMentionPopover.tsx`
|
||||||
- **FileViewerOverlay** — props: path, content, lang, onClose — `apps/web/src/components/FileViewerOverlay.tsx`
|
- **FileViewerOverlay** — props: path, content, lang, onClose — `apps/web/src/components/FileViewerOverlay.tsx`
|
||||||
- **FlowLauncherDialog** — `apps/web/src/components/FlowLauncherDialog.tsx`
|
- **FlowLauncherDialog** — `apps/web/src/components/FlowLauncherDialog.tsx`
|
||||||
- **GitDiffView** — props: result, loading, error, mode, onSelectMode, onRefresh, mutating, mutateError, onStage, onUnstage — `apps/web/src/components/GitDiffView.tsx`
|
- **GitDiffView** — props: result, loading, error, mode, onSelectMode, onRefresh, mutating, mutateError, onStage, onUnstage — `apps/web/src/components/GitDiffView.tsx`
|
||||||
- **HtmlArtifactPane** — props: chatId, state, onClose — `apps/web/src/components/HtmlArtifactPane.tsx`
|
- **HtmlArtifactPane** — props: chatId, state, onClose — `apps/web/src/components/HtmlArtifactPane.tsx`
|
||||||
- **InferenceSettings** — `apps/web/src/components/InferenceSettings.tsx`
|
- **InferenceSettings** — `apps/web/src/components/InferenceSettings.tsx`
|
||||||
|
- **InlineReviewEditor** — props: initialBody, onSave, onCancel — `apps/web/src/components/InlineReviewEditor.tsx`
|
||||||
|
- **InlineReviewGutterCell** — props: lineNumber, type, hasComments, canComment, onClick — `apps/web/src/components/InlineReviewGutterCell.tsx`
|
||||||
|
- **InlineReviewThread** — props: comments, onEditComment, onDeleteComment — `apps/web/src/components/InlineReviewThread.tsx`
|
||||||
|
- **KeyboardShortcutsDialog** — props: open, onOpenChange — `apps/web/src/components/KeyboardShortcutsDialog.tsx`
|
||||||
- **MarkdownArtifactPane** — props: chatId, state, onClose — `apps/web/src/components/MarkdownArtifactPane.tsx`
|
- **MarkdownArtifactPane** — props: chatId, state, onClose — `apps/web/src/components/MarkdownArtifactPane.tsx`
|
||||||
- **MarkdownRenderer** — props: content — `apps/web/src/components/MarkdownRenderer.tsx`
|
- **MarkdownRenderer** — props: content — `apps/web/src/components/MarkdownRenderer.tsx`
|
||||||
|
- **McpPermissionDialog** — props: toolCallId, toolName, toolArgs, chatId, open, onClose — `apps/web/src/components/McpPermissionDialog.tsx`
|
||||||
|
- **McpResponseDisplay** — props: toolCall, toolResult — `apps/web/src/components/McpResponseDisplay.tsx`
|
||||||
- **MessageBubble** — props: message, sessionChats, capHitInfo, actions, hideActions, hasCheckpoint, restoreDisabled — `apps/web/src/components/MessageBubble.tsx`
|
- **MessageBubble** — props: message, sessionChats, capHitInfo, actions, hideActions, hasCheckpoint, restoreDisabled — `apps/web/src/components/MessageBubble.tsx`
|
||||||
- **MessageList** — props: messages, sessionChats — `apps/web/src/components/MessageList.tsx`
|
- **MessageList** — props: messages, sessionChats — `apps/web/src/components/MessageList.tsx`
|
||||||
- **MobileTabSwitcher** — props: panes, activePaneIdx, chats, onSwitchPane, onRemovePane, onRenameChat — `apps/web/src/components/MobileTabSwitcher.tsx`
|
- **MobileTabSwitcher** — props: panes, activePaneIdx, chats, onSwitchPane, onRemovePane, onRenameChat — `apps/web/src/components/MobileTabSwitcher.tsx`
|
||||||
@@ -353,12 +437,14 @@
|
|||||||
- **RequestReadAccessCard** — props: toolCall, toolResult, chatId — `apps/web/src/components/RequestReadAccessCard.tsx`
|
- **RequestReadAccessCard** — props: toolCall, toolResult, chatId — `apps/web/src/components/RequestReadAccessCard.tsx`
|
||||||
- **RightRail** — props: projectId, sessionId — `apps/web/src/components/RightRail.tsx`
|
- **RightRail** — props: projectId, sessionId — `apps/web/src/components/RightRail.tsx`
|
||||||
- **SessionLandingPage** — props: projectId, sessionId, agentId, onAgentChange, onSend, onSkillInvoke, createChat, chats, onOpenChat, onUnarchiveChat — `apps/web/src/components/SessionLandingPage.tsx`
|
- **SessionLandingPage** — props: projectId, sessionId, agentId, onAgentChange, onSend, onSkillInvoke, createChat, chats, onOpenChat, onUnarchiveChat — `apps/web/src/components/SessionLandingPage.tsx`
|
||||||
|
- **SessionTimeline** — props: messages, onClose, onScrollToMessage — `apps/web/src/components/SessionTimeline.tsx`
|
||||||
- **SlashCommandPicker** — props: query, items, groups, inputRef, onSelect, onClose, emptyLabel — `apps/web/src/components/SlashCommandPicker.tsx`
|
- **SlashCommandPicker** — props: query, items, groups, inputRef, onSelect, onClose, emptyLabel — `apps/web/src/components/SlashCommandPicker.tsx`
|
||||||
- **StaleStreamBanner** — props: onRetry, onDiscard — `apps/web/src/components/StaleStreamBanner.tsx`
|
- **StaleStreamBanner** — props: onRetry, onDiscard — `apps/web/src/components/StaleStreamBanner.tsx`
|
||||||
- **StatusDot** — props: chatId, className — `apps/web/src/components/StatusDot.tsx`
|
- **StatusDot** — props: chatId, className — `apps/web/src/components/StatusDot.tsx`
|
||||||
- **ThemePicker** — `apps/web/src/components/ThemePicker.tsx`
|
- **ThemePicker** — `apps/web/src/components/ThemePicker.tsx`
|
||||||
- **ToolCallGroup** — props: runs — `apps/web/src/components/ToolCallGroup.tsx`
|
- **ToolCallGroup** — props: runs — `apps/web/src/components/ToolCallGroup.tsx`
|
||||||
- **ToolCallLine** — props: run, insideGroup — `apps/web/src/components/ToolCallLine.tsx`
|
- **ToolCallLine** — props: run, insideGroup, chatId — `apps/web/src/components/ToolCallLine.tsx`
|
||||||
|
- **TraceViewer** — props: chatId — `apps/web/src/components/TraceViewer.tsx`
|
||||||
- **Workspace** — props: sessionId, projectId, agentId, onAgentChange, panesHook, chatsHook, session, project, onAddPane — `apps/web/src/components/Workspace.tsx`
|
- **Workspace** — props: sessionId, projectId, agentId, onAgentChange, panesHook, chatsHook, session, project, onAddPane — `apps/web/src/components/Workspace.tsx`
|
||||||
- **AddProviderModal** — props: open, onOpenChange, onAdded — `apps/web/src/components/coder/AddProviderModal.tsx`
|
- **AddProviderModal** — props: open, onOpenChange, onAdded — `apps/web/src/components/coder/AddProviderModal.tsx`
|
||||||
- **ProvidersSettings** — `apps/web/src/components/coder/ProvidersSettings.tsx`
|
- **ProvidersSettings** — `apps/web/src/components/coder/ProvidersSettings.tsx`
|
||||||
@@ -367,21 +453,31 @@
|
|||||||
- **ThemeFx** — `apps/web/src/components/fx/ThemeFx.tsx`
|
- **ThemeFx** — `apps/web/src/components/fx/ThemeFx.tsx`
|
||||||
- **ClaudeIcon** — props: size, className — `apps/web/src/components/icons/ProviderIcons.tsx`
|
- **ClaudeIcon** — props: size, className — `apps/web/src/components/icons/ProviderIcons.tsx`
|
||||||
- **OpenCodeIcon** — props: size, className — `apps/web/src/components/icons/ProviderIcons.tsx`
|
- **OpenCodeIcon** — props: size, className — `apps/web/src/components/icons/ProviderIcons.tsx`
|
||||||
|
- **ActionRow** — props: message, actions, hiddenSet, hasCheckpoint, restoreDisabled — `apps/web/src/components/message-parts/ActionRow.tsx`
|
||||||
|
- **CompactCard** — props: message, sessionChats — `apps/web/src/components/message-parts/CompactCard.tsx`
|
||||||
|
- **MistakeRecoverySentinel** — props: message — `apps/web/src/components/message-parts/MistakeRecoverySentinel.tsx`
|
||||||
|
- **ReasoningBlock** — props: text, streaming — `apps/web/src/components/message-parts/ReasoningBlock.tsx`
|
||||||
|
- **SendToTerminalMenu** — `apps/web/src/components/message-parts/SendToTerminalMenu.tsx`
|
||||||
|
- **StatsLine** — props: message — `apps/web/src/components/message-parts/StatsLine.tsx`
|
||||||
|
- **SummaryCard** — props: message — `apps/web/src/components/message-parts/SummaryCard.tsx`
|
||||||
- **ArenaPane** — props: state, onClose — `apps/web/src/components/panes/ArenaPane.tsx`
|
- **ArenaPane** — props: state, onClose — `apps/web/src/components/panes/ArenaPane.tsx`
|
||||||
- **ChatPane** — props: sessionId, chatId, projectId, agentId, onAgentChange, sessionChats, webSearchEnabled — `apps/web/src/components/panes/ChatPane.tsx`
|
- **ChatPane** — props: sessionId, chatId, projectId, agentId, onAgentChange, sessionChats, webSearchEnabled — `apps/web/src/components/panes/ChatPane.tsx`
|
||||||
- **CoderMessageList** — props: messages, chatId, footer, actions, checkpointMessageIds, restoreDisabled — `apps/web/src/components/panes/CoderMessageList.tsx`
|
- **CoderMessageList** — props: messages, chatId, footer, actions, checkpointMessageIds, restoreDisabled — `apps/web/src/components/panes/CoderMessageList.tsx`
|
||||||
- **CoderPane** — props: sessionId, paneId, chatId, chatPending, projectPath, onConnectedChange, onAgentLabelChange — `apps/web/src/components/panes/CoderPane.tsx`
|
- **CoderPane** — props: sessionId, paneId, chatId, chatPending, projectPath, onConnectedChange, onAgentLabelChange — `apps/web/src/components/panes/CoderPane.tsx`
|
||||||
- **OrchestratorPane** — props: state, onClose — `apps/web/src/components/panes/OrchestratorPane.tsx`
|
- **OrchestratorPane** — props: state, onClose — `apps/web/src/components/panes/OrchestratorPane.tsx`
|
||||||
- **SettingsPane** — props: session, project, maximized, onToggleMaximize, onClose, isMobile — `apps/web/src/components/panes/SettingsPane.tsx`
|
- **SettingsPane** — props: session, project, maximized, onToggleMaximize, onClose, isMobile — `apps/web/src/components/panes/SettingsPane.tsx`
|
||||||
- **TerminalPane** — props: sessionId, paneId, label, active — `apps/web/src/components/panes/TerminalPane.tsx`
|
- **TerminalPane** — props: sessionId, paneId, label, description, parentAgent, active — `apps/web/src/components/panes/TerminalPane.tsx`
|
||||||
- **FloatingMenu** — props: x, y, hasSelection, chatInputs, onCopy, onPaste, onSelectAll, onSearch, onSendToChat, onDismiss — `apps/web/src/components/panes/terminal/FloatingMenu.tsx`
|
- **FloatingMenu** — props: x, y, hasSelection, chatInputs, onCopy, onPaste, onSelectAll, onSearch, onSendToChat, onDismiss — `apps/web/src/components/panes/terminal/FloatingMenu.tsx`
|
||||||
- **SearchBar** — props: searchRef, theme, onClose — `apps/web/src/components/panes/terminal/SearchBar.tsx`
|
- **SearchBar** — props: searchRef, theme, onClose — `apps/web/src/components/panes/terminal/SearchBar.tsx`
|
||||||
- **TerminalHotkeyBar** — props: ctrlArmed, onSendBytes, onArmCtrl, onFit — `apps/web/src/components/panes/terminal/TerminalHotkeyBar.tsx`
|
- **TerminalHotkeyBar** — props: ctrlArmed, onSendBytes, onArmCtrl, onFit — `apps/web/src/components/panes/terminal/TerminalHotkeyBar.tsx`
|
||||||
- **RightRailDrawerProvider** — `apps/web/src/hooks/useRightRailDrawer.tsx`
|
- **RightRailDrawerProvider** — `apps/web/src/hooks/useRightRailDrawer.tsx`
|
||||||
- **SidebarDrawerProvider** — `apps/web/src/hooks/useSidebarDrawer.tsx`
|
- **SidebarDrawerProvider** — `apps/web/src/hooks/useSidebarDrawer.tsx`
|
||||||
- **PATH_REGEX** — `apps/web/src/lib/linkify-paths.tsx`
|
- **PATH_REGEX** — `apps/web/src/lib/linkify-paths.tsx`
|
||||||
|
- **Analytics** — `apps/web/src/pages/Analytics.tsx`
|
||||||
- **Home** — `apps/web/src/pages/Home.tsx`
|
- **Home** — `apps/web/src/pages/Home.tsx`
|
||||||
|
- **Memory** — `apps/web/src/pages/Memory.tsx`
|
||||||
- **Project** — `apps/web/src/pages/Project.tsx`
|
- **Project** — `apps/web/src/pages/Project.tsx`
|
||||||
|
- **Results** — `apps/web/src/pages/Results.tsx`
|
||||||
- **Session** — `apps/web/src/pages/Session.tsx`
|
- **Session** — `apps/web/src/pages/Session.tsx`
|
||||||
- **Settings** — `apps/web/src/pages/Settings.tsx`
|
- **Settings** — `apps/web/src/pages/Settings.tsx`
|
||||||
|
|
||||||
@@ -403,8 +499,17 @@
|
|||||||
- function ensureSession: (tmuxConfPath, sessionName, projectRoot, log, cols?, rows?) => Promise<void>
|
- function ensureSession: (tmuxConfPath, sessionName, projectRoot, log, cols?, rows?) => Promise<void>
|
||||||
- function killSession: (tmuxConfPath, sessionName) => Promise<boolean>
|
- function killSession: (tmuxConfPath, sessionName) => Promise<boolean>
|
||||||
- function capturePane: (tmuxConfPath, sessionName, lines) => Promise<string>
|
- function capturePane: (tmuxConfPath, sessionName, lines) => Promise<string>
|
||||||
|
- _...1 more_
|
||||||
- `apps/booterm/src/pty/pty.ts` — function attachPty: (opts) => IPty
|
- `apps/booterm/src/pty/pty.ts` — function attachPty: (opts) => IPty
|
||||||
- `apps/booterm/src/ws/attach.ts` — function registerWsAttachRoute: (app, tmuxConfPath) => void
|
- `apps/booterm/src/pty/registry.ts`
|
||||||
|
- function register: (sessionId, paneId, projectPath, title?, opts?) => void
|
||||||
|
- function unregister: (paneId) => void
|
||||||
|
- function touchActivity: (paneId) => void
|
||||||
|
- function list: () => SessionMeta[]
|
||||||
|
- function get: (paneId) => SessionMeta | undefined
|
||||||
|
- function setPendingMetadata: (paneId, meta) => void
|
||||||
|
- _...8 more_
|
||||||
|
- `apps/booterm/src/ws/attach.ts` — function registerWsAttachRoute: (app, tmuxConfPath, idleTimeoutSeconds?, absoluteTimeoutSeconds?) => void
|
||||||
- `apps/coder/src/conductor/contracts.ts`
|
- `apps/coder/src/conductor/contracts.ts`
|
||||||
- function produceContract: (contracts) => string
|
- function produceContract: (contracts) => string
|
||||||
- function reviewContract: (contracts) => string
|
- function reviewContract: (contracts) => string
|
||||||
@@ -491,7 +596,7 @@
|
|||||||
- function classifyLane: (battleType, _identity, model, localModels) => ContestantLane
|
- function classifyLane: (battleType, _identity, model, localModels) => ContestantLane
|
||||||
- function nextLocalContestant: (contestants) => string | null
|
- function nextLocalContestant: (contestants) => string | null
|
||||||
- function isBattleComplete: (contestants) => boolean
|
- function isBattleComplete: (contestants) => boolean
|
||||||
- function computeBenchmark: (startedAt, endedAt, costTokens, lane) => Benchmark
|
- function computeBenchmark: (startedAt, endedAt, costTokens, lane, tokenBreakdown) => Benchmark
|
||||||
- function sanitizeSlug: (s) => string
|
- function sanitizeSlug: (s) => string
|
||||||
- function buildBattleSlug: (battleId, battleType, createdAt) => string
|
- function buildBattleSlug: (battleId, battleType, createdAt) => string
|
||||||
- _...7 more_
|
- _...7 more_
|
||||||
@@ -555,6 +660,7 @@
|
|||||||
- function stepEndedToUsage: (props) => StepUsage
|
- function stepEndedToUsage: (props) => StepUsage
|
||||||
- interface StepEndedProps
|
- interface StepEndedProps
|
||||||
- interface StepUsage
|
- interface StepUsage
|
||||||
|
- `apps/coder/src/services/backends/paseo.ts` — class PaseoBackend, interface PaseoBackendDeps
|
||||||
- `apps/coder/src/services/backends/pushable-iterable.ts` — function createPushable: () => Pushable<T>, interface Pushable
|
- `apps/coder/src/services/backends/pushable-iterable.ts` — function createPushable: () => Pushable<T>, interface Pushable
|
||||||
- `apps/coder/src/services/backends/turn-guard.ts`
|
- `apps/coder/src/services/backends/turn-guard.ts`
|
||||||
- function armAbortGuard: (g) => void
|
- function armAbortGuard: (g) => void
|
||||||
@@ -563,6 +669,30 @@
|
|||||||
- interface AbortTerminalGuard
|
- interface AbortTerminalGuard
|
||||||
- `apps/coder/src/services/backends/warm-acp-routing.ts` — function shouldUseWarmBackend: (task) => boolean, function isTurnOkForStopReason: (stopReason) => boolean
|
- `apps/coder/src/services/backends/warm-acp-routing.ts` — function shouldUseWarmBackend: (task) => boolean, function isTurnOkForStopReason: (stopReason) => boolean
|
||||||
- `apps/coder/src/services/backends/warm-acp.ts` — class WarmAcpBackend, interface WarmAcpBackendDeps
|
- `apps/coder/src/services/backends/warm-acp.ts` — class WarmAcpBackend, interface WarmAcpBackendDeps
|
||||||
|
- `apps/coder/src/services/behavioral/generation.ts`
|
||||||
|
- function createExecutionPlan: (observational, actionable, previouslyApplied, disambiguationGroups, lowCriticality) => BatchExecutionPlan[]
|
||||||
|
- function getRetryTemperatures: (baseTemp, maxAttempts) => number[]
|
||||||
|
- class SchematicGenerator
|
||||||
|
- class DefaultSchematicGenerator
|
||||||
|
- interface ObservationalOutput
|
||||||
|
- interface ActionableOutput
|
||||||
|
- _...7 more_
|
||||||
|
- `apps/coder/src/services/behavioral/matching.ts`
|
||||||
|
- function matchWithRetry: (fn) => void
|
||||||
|
- function executeBatchesParallel: (batches, _generationInfo) => Promise<GuidelineMatchingResult>
|
||||||
|
- function createScoredMatch: (guidelineId, score, rationale) => ScoredMatch
|
||||||
|
- class GuidelineMatchingBatchError
|
||||||
|
- class ObservationalGuidelineMatchingBatch
|
||||||
|
- class ActionableGuidelineMatchingBatch
|
||||||
|
- _...25 more_
|
||||||
|
- `apps/coder/src/services/behavioral/resolver.ts`
|
||||||
|
- class RelationalResolver
|
||||||
|
- interface RelationshipEntity
|
||||||
|
- interface Relationship
|
||||||
|
- interface RelationshipStore
|
||||||
|
- interface ResolvedEntity
|
||||||
|
- interface Resolution
|
||||||
|
- _...8 more_
|
||||||
- `apps/coder/src/services/cancel-registry.ts` — function createCancelRegistry: () => CancelRegistry, interface CancelRegistry
|
- `apps/coder/src/services/cancel-registry.ts` — function createCancelRegistry: () => CancelRegistry, interface CancelRegistry
|
||||||
- `apps/coder/src/services/checkpoints.ts`
|
- `apps/coder/src/services/checkpoints.ts`
|
||||||
- function buildShadowCommitCommand: (worktreePath, id) => string
|
- function buildShadowCommitCommand: (worktreePath, id) => string
|
||||||
@@ -573,7 +703,15 @@
|
|||||||
- interface RestoreCheckpointResult
|
- interface RestoreCheckpointResult
|
||||||
- _...1 more_
|
- _...1 more_
|
||||||
- `apps/coder/src/services/claude-command-discovery.ts` — function discoverClaudeCommands: () => AgentCommand[]
|
- `apps/coder/src/services/claude-command-discovery.ts` — function discoverClaudeCommands: () => AgentCommand[]
|
||||||
|
- `apps/coder/src/services/collision-detector.ts`
|
||||||
|
- function findConflicts: (changedFiles, worktreeId, /** Approximate line range for the proposed changes, keyed by file path */
|
||||||
|
changedRanges, {...}, conflictIndex) => ConflictVerdict[]
|
||||||
|
- interface ConflictVerdict
|
||||||
|
- interface ConflictEntry
|
||||||
|
- type ConflictSeverity
|
||||||
|
- type ConflictIndexData
|
||||||
- `apps/coder/src/services/command-availability.ts` — function isCommandAvailable: (binary) => Promise<boolean>
|
- `apps/coder/src/services/command-availability.ts` — function isCommandAvailable: (binary) => Promise<boolean>
|
||||||
|
- `apps/coder/src/services/conflict-index.ts` — class ConflictIndex, const conflictIndex
|
||||||
- `apps/coder/src/services/correction-service.ts`
|
- `apps/coder/src/services/correction-service.ts`
|
||||||
- function recordCorrection: (originalClaim, correction, principleExtracted, persistedTo, basePath?) => Promise<UserCorrectionRecord>
|
- function recordCorrection: (originalClaim, correction, principleExtracted, persistedTo, basePath?) => Promise<UserCorrectionRecord>
|
||||||
- function scanForCorrections: (auditPath) => Promise<UserCorrectionRecord[]>
|
- function scanForCorrections: (auditPath) => Promise<UserCorrectionRecord[]>
|
||||||
@@ -603,10 +741,11 @@
|
|||||||
- function partitionReady: (ready, ctx) => void
|
- function partitionReady: (ready, ctx) => void
|
||||||
- function isRunComplete: (flow, state) => boolean
|
- function isRunComplete: (flow, state) => boolean
|
||||||
- function isStuck: (flow, state) => boolean
|
- function isStuck: (flow, state) => boolean
|
||||||
- function reconcileResumeStep: (status, taskId, taskState) => ResumeAction
|
- function buildBatchState: (flow, inFlight) => Map<string,
|
||||||
- _...5 more_
|
- _...12 more_
|
||||||
- `apps/coder/src/services/flow-runner.ts`
|
- `apps/coder/src/services/flow-runner.ts`
|
||||||
- function createFlowRunner: (deps) => FlowRunner
|
- function createFlowRunner: (deps) => FlowRunner
|
||||||
|
- function resolveVariables: (prompt, results, string>) => string
|
||||||
- interface LaunchOpts
|
- interface LaunchOpts
|
||||||
- interface FlowRunner
|
- interface FlowRunner
|
||||||
- `apps/coder/src/services/frame-emitter.ts`
|
- `apps/coder/src/services/frame-emitter.ts`
|
||||||
@@ -626,6 +765,19 @@
|
|||||||
- function deleteGuideline: (id, basePath?) => Promise<boolean>
|
- function deleteGuideline: (id, basePath?) => Promise<boolean>
|
||||||
- function findGuideline: (content, basePath?) => Promise<Guideline | null>
|
- function findGuideline: (content, basePath?) => Promise<Guideline | null>
|
||||||
- _...14 more_
|
- _...14 more_
|
||||||
|
- `apps/coder/src/services/hashline/hash-computation.ts`
|
||||||
|
- function computeLineHash: (lineNumber, content) => string
|
||||||
|
- function computeLegacyLineHash: (lineNumber, content) => string
|
||||||
|
- function formatHashLine: (lineNumber, content) => string
|
||||||
|
- function formatHashLines: (content) => string
|
||||||
|
- `apps/coder/src/services/hashline/validation.ts`
|
||||||
|
- function normalizeLineRef: (ref) => string
|
||||||
|
- function parseLineRef: (ref) => LineRef
|
||||||
|
- function validateLineRef: (lines, ref) => void
|
||||||
|
- function validateLineRefs: (lines, refs) => void
|
||||||
|
- class HashlineMismatchError
|
||||||
|
- interface LineRef
|
||||||
|
- `apps/coder/src/services/hashline/xxhash32.ts` — function hashXxh32: (input, seed) => number
|
||||||
- `apps/coder/src/services/host-exec.ts` — function hostExec: (command, opts?) => Promise<HostExecResult>, interface HostExecResult
|
- `apps/coder/src/services/host-exec.ts` — function hostExec: (command, opts?) => Promise<HostExecResult>, interface HostExecResult
|
||||||
- `apps/coder/src/services/lsp/client.ts` — class LspClient
|
- `apps/coder/src/services/lsp/client.ts` — class LspClient
|
||||||
- `apps/coder/src/services/lsp/config.ts` — function getServerConfig: (filePath) => LspServerConfig | null, interface LspServerConfig
|
- `apps/coder/src/services/lsp/config.ts` — function getServerConfig: (filePath) => LspServerConfig | null, interface LspServerConfig
|
||||||
@@ -637,6 +789,44 @@
|
|||||||
- function findReferences: (client, filePath, content, line, character) => Promise<Location[]>
|
- function findReferences: (client, filePath, content, line, character) => Promise<Location[]>
|
||||||
- `apps/coder/src/services/lsp/server-manager.ts` — class LspServerManager, const lspManager
|
- `apps/coder/src/services/lsp/server-manager.ts` — class LspServerManager, const lspManager
|
||||||
- `apps/coder/src/services/mcp-server.ts` — function startMcpServer: (sql) => Promise<void>
|
- `apps/coder/src/services/mcp-server.ts` — function startMcpServer: (sql) => Promise<void>
|
||||||
|
- `apps/coder/src/services/model-resolution/connected-providers-cache.ts`
|
||||||
|
- function readConnectedProvidersCache: () => string[] | null
|
||||||
|
- function findProviderModelMetadata: (_providerID, _modelID) => ModelMetadata | undefined
|
||||||
|
- function readProviderModelsCache: () => ProviderModelsCache | null
|
||||||
|
- interface ProviderModelsCache
|
||||||
|
- interface ConnectedProvidersAdapter
|
||||||
|
- const connectedProvidersAdapter: ConnectedProvidersAdapter
|
||||||
|
- `apps/coder/src/services/model-resolution/fallback-chain-from-models.ts`
|
||||||
|
- function parseFallbackModelEntry: (model, contextProviderID, defaultProviderID) => FallbackEntry | undefined
|
||||||
|
- function parseFallbackModelObjectEntry: (obj, contextProviderID, defaultProviderID) => FallbackEntry | undefined
|
||||||
|
- function findMostSpecificFallbackEntry: (providerID, modelID, chain) => FallbackEntry | undefined
|
||||||
|
- function buildFallbackChainFromModels: (fallbackModels) => void
|
||||||
|
- `apps/coder/src/services/model-resolution/model-availability.ts` — function fuzzyMatchModel: (target, available, providers?) => string | null, function isModelAvailable: (targetModel, availableModels) => boolean
|
||||||
|
- `apps/coder/src/services/model-resolution/model-error-classifier.ts`
|
||||||
|
- function isRetryableModelError: (error) => boolean
|
||||||
|
- function shouldRetryError: (error) => boolean
|
||||||
|
- function getNextFallback: (fallbackChain, attemptCount) => FallbackEntry | undefined
|
||||||
|
- function hasMoreFallbacks: (fallbackChain, attemptCount) => boolean
|
||||||
|
- function selectFallbackProvider: (providers, preferredProviderID?) => string
|
||||||
|
- function selectFallbackProviderWithCache: (providers, providerCache, preferredProviderID?) => string
|
||||||
|
- _...1 more_
|
||||||
|
- `apps/coder/src/services/model-resolution/model-normalization.ts` — function normalizeModel: (model?) => string | undefined, function normalizeModelID: (modelID) => string
|
||||||
|
- `apps/coder/src/services/model-resolution/model-resolution-pipeline.ts`
|
||||||
|
- function _setModelResolutionLogImplementationForTesting: (logImplementation) => void
|
||||||
|
- function resolveModelPipeline: (request, providerCache) => void
|
||||||
|
- type ModelResolutionRequest
|
||||||
|
- type ModelResolutionProvenance
|
||||||
|
- type ModelResolutionResult
|
||||||
|
- type ModelResolutionDeps
|
||||||
|
- `apps/coder/src/services/model-resolution/model-resolver.ts`
|
||||||
|
- function resolveModel: (input) => string | undefined
|
||||||
|
- function resolveModelWithFallback: (input, connectedProvidersAdapter) => ModelResolutionResult | undefined
|
||||||
|
- function normalizeFallbackModels: (models) => void
|
||||||
|
- function flattenToFallbackModelStrings: (models) => void
|
||||||
|
- type ModelResolutionInput
|
||||||
|
- type ModelSource
|
||||||
|
- _...2 more_
|
||||||
|
- `apps/coder/src/services/model-resolution/provider-model-id-transform.ts` — function transformModelForProvider: (provider, model) => string, function transformModelForProviderDisplay: (provider, model) => string
|
||||||
- `apps/coder/src/services/net/port-utils.ts`
|
- `apps/coder/src/services/net/port-utils.ts`
|
||||||
- function reclaimPort: (port) => void
|
- function reclaimPort: (port) => void
|
||||||
- function waitForPortRelease: (port, timeoutMs) => Promise<boolean>
|
- function waitForPortRelease: (port, timeoutMs) => Promise<boolean>
|
||||||
@@ -646,6 +836,13 @@
|
|||||||
- function createOrphanWorktreeReaper: (deps) => void
|
- function createOrphanWorktreeReaper: (deps) => void
|
||||||
- interface OrphanWorktreeReaperDeps
|
- interface OrphanWorktreeReaperDeps
|
||||||
- interface OrphanReaperResult
|
- interface OrphanReaperResult
|
||||||
|
- `apps/coder/src/services/paseo-client.ts`
|
||||||
|
- class PaseoClientError
|
||||||
|
- class PaseoClient
|
||||||
|
- interface PaseoAgentListItem
|
||||||
|
- interface PaseoAgentDetail
|
||||||
|
- interface PaseoSendResult
|
||||||
|
- interface PaseoClientConfig
|
||||||
- `apps/coder/src/services/pending_changes.ts`
|
- `apps/coder/src/services/pending_changes.ts`
|
||||||
- function planEdit: (content, oldStr, newStr) => EditPlan
|
- function planEdit: (content, oldStr, newStr) => EditPlan
|
||||||
- function queueEdit: (sql, sessionId, taskId, filePath, oldString, newString, projectRoot, // v2.6 Phase 1-UX) => void
|
- function queueEdit: (sql, sessionId, taskId, filePath, oldString, newString, projectRoot, // v2.6 Phase 1-UX) => void
|
||||||
@@ -662,6 +859,14 @@
|
|||||||
- function waitForElicitationResponse: (taskId, sessionId, provider, modeId, params, timeoutMs) => Promise<CreateElicitationResponse>
|
- function waitForElicitationResponse: (taskId, sessionId, provider, modeId, params, timeoutMs) => Promise<CreateElicitationResponse>
|
||||||
- function cancelPendingPermission: (taskId) => void
|
- function cancelPendingPermission: (taskId) => void
|
||||||
- _...3 more_
|
- _...3 more_
|
||||||
|
- `apps/coder/src/services/plan-store.ts`
|
||||||
|
- function createPlan: (sql, opts) => Promise<Plan>
|
||||||
|
- function getPlan: (sql, planId) => Promise<Plan | null>
|
||||||
|
- function listPlans: (sql, projectId) => Promise<Plan[]>
|
||||||
|
- function listActivePlans: (sql, projectId) => Promise<Plan[]>
|
||||||
|
- function updatePlan: (sql, planId, opts) => Promise<Plan | null>
|
||||||
|
- function updatePlanFromRun: (sql, runId, runStatus) => Promise<boolean>
|
||||||
|
- _...5 more_
|
||||||
- `apps/coder/src/services/provider-commands.ts`
|
- `apps/coder/src/services/provider-commands.ts`
|
||||||
- function getManifestCommands: (provider) => AgentCommand[]
|
- function getManifestCommands: (provider) => AgentCommand[]
|
||||||
- function mergeCommands: (...lists) => AgentCommand[]
|
- function mergeCommands: (...lists) => AgentCommand[]
|
||||||
@@ -684,13 +889,13 @@
|
|||||||
- interface ProviderManifestEntry
|
- interface ProviderManifestEntry
|
||||||
- const PROVIDER_MANIFEST: Record<string, ProviderManifestEntry>
|
- const PROVIDER_MANIFEST: Record<string, ProviderManifestEntry>
|
||||||
- `apps/coder/src/services/provider-snapshot.ts`
|
- `apps/coder/src/services/provider-snapshot.ts`
|
||||||
|
- function fetchDeepSeekModels: (config) => Promise<ProviderModel[]>
|
||||||
- function fetchLlamaSwapModels: (config) => Promise<ProviderModel[]>
|
- function fetchLlamaSwapModels: (config) => Promise<ProviderModel[]>
|
||||||
- function prefixLlamaSwapModels: (models) => ProviderModel[]
|
- function prefixLlamaSwapModels: (models) => ProviderModel[]
|
||||||
- function mergeModels: (...lists) => ProviderModel[]
|
- function mergeModels: (...lists) => ProviderModel[]
|
||||||
- function getProviderSnapshot: (sql, config, cwd?, force) => Promise<ProviderSnapshotEntry[]>
|
- function getProviderSnapshot: (sql, config, cwd?, force) => Promise<ProviderSnapshotEntry[]>
|
||||||
- function clearProviderSnapshotCache: () => void
|
- function clearProviderSnapshotCache: () => void
|
||||||
- function peekSnapshotEntry: (name, cwd?) => ProviderSnapshotEntry | undefined
|
- _...2 more_
|
||||||
- _...1 more_
|
|
||||||
- `apps/coder/src/services/pty-dispatch.ts`
|
- `apps/coder/src/services/pty-dispatch.ts`
|
||||||
- function dispatchViaPty: (opts) => Promise<DispatchResult>
|
- function dispatchViaPty: (opts) => Promise<DispatchResult>
|
||||||
- interface DispatchResult
|
- interface DispatchResult
|
||||||
@@ -800,6 +1005,17 @@
|
|||||||
- function readSession: (sessionId, projectRoot?) => SessionJson | null
|
- function readSession: (sessionId, projectRoot?) => SessionJson | null
|
||||||
- _...9 more_
|
- _...9 more_
|
||||||
- `apps/server/src/services/auto_name.ts` — function maybeAutoNameChat: (ctx, chatId, sessionId) => Promise<void>
|
- `apps/server/src/services/auto_name.ts` — function maybeAutoNameChat: (ctx, chatId, sessionId) => Promise<void>
|
||||||
|
- `apps/server/src/services/background-task.ts`
|
||||||
|
- function setBackgroundInferenceEnqueuer: (enqueue, chatId, assistantMessageId, user) => void
|
||||||
|
- function spawnBackgroundTask: (sql, log, projectId, input, model, agent?, label?) => Promise<BackgroundTask>
|
||||||
|
- function getBackgroundTaskStatus: (sql, taskId) => Promise<BackgroundTask | null>
|
||||||
|
- function getBackgroundTaskResult: (sql, taskId, chatId) => Promise<
|
||||||
|
- function cancelBackgroundTask: (sql, taskId) => Promise<boolean>
|
||||||
|
- interface BackgroundTask
|
||||||
|
- `apps/server/src/services/boocontext_client.ts`
|
||||||
|
- function callBoocontext: (req, log?, msg) => void
|
||||||
|
- interface BoocontextRequest
|
||||||
|
- interface BoocontextResponse
|
||||||
- `apps/server/src/services/broker.ts`
|
- `apps/server/src/services/broker.ts`
|
||||||
- function createBroker: (log?) => Broker
|
- function createBroker: (log?) => Broker
|
||||||
- interface Broker
|
- interface Broker
|
||||||
@@ -818,6 +1034,7 @@
|
|||||||
- function select: (messages, contextLimit, tailTurns) => SelectResult
|
- function select: (messages, contextLimit, tailTurns) => SelectResult
|
||||||
- function deriveFilesRead: (head) => string[]
|
- function deriveFilesRead: (head) => string[]
|
||||||
- _...8 more_
|
- _...8 more_
|
||||||
|
- `apps/server/src/services/export-formatter.ts` — function formatJson: (chat, messages, model) => string, function formatMarkdown: (chat, messages, model) => string
|
||||||
- `apps/server/src/services/file_index.ts` — function getProjectFiles: (projectId, projectRoot) => Promise<string[]>
|
- `apps/server/src/services/file_index.ts` — function getProjectFiles: (projectId, projectRoot) => Promise<string[]>
|
||||||
- `apps/server/src/services/file_ops.ts`
|
- `apps/server/src/services/file_ops.ts`
|
||||||
- function listDir: (projectRoot, relPath, opts?) => Promise<ListDirResult>
|
- function listDir: (projectRoot, relPath, opts?) => Promise<ListDirResult>
|
||||||
@@ -842,7 +1059,20 @@
|
|||||||
- interface GiteaConfig
|
- interface GiteaConfig
|
||||||
- interface GiteaRepo
|
- interface GiteaRepo
|
||||||
- `apps/server/src/services/grant_resolver.ts` — function resolveGrantRoot: (sql, requestedPath, projectRoot, whitelistRoot) => Promise<GrantResolution>, type GrantResolution
|
- `apps/server/src/services/grant_resolver.ts` — function resolveGrantRoot: (sql, requestedPath, projectRoot, whitelistRoot) => Promise<GrantResolution>, type GrantResolution
|
||||||
|
- `apps/server/src/services/hooks.ts`
|
||||||
|
- function loadHooksConfig: (path) => HooksConfig
|
||||||
|
- function reloadHooksConfig: () => HooksConfig
|
||||||
|
- function createHookRunner: () => HookRunner
|
||||||
|
- interface HookConfig
|
||||||
|
- interface HooksConfig
|
||||||
|
- interface PreToolUsePayload
|
||||||
|
- _...10 more_
|
||||||
- `apps/server/src/services/inference/budget.ts` — function resolveToolBudget: (agent) => number
|
- `apps/server/src/services/inference/budget.ts` — function resolveToolBudget: (agent) => number
|
||||||
|
- `apps/server/src/services/inference/compute-diff.ts`
|
||||||
|
- function computeDiff: (oldStr, newStr, filePath) => string
|
||||||
|
- function isWriteTool: (name) => boolean
|
||||||
|
- function diffFromToolArgs: (name, args, unknown>, filePath?) => string
|
||||||
|
- const WRITE_TOOL_NAMES
|
||||||
- `apps/server/src/services/inference/content-flusher.ts` — function createContentFlusher: (sql, messageId, getContent) => void, interface ContentFlusher
|
- `apps/server/src/services/inference/content-flusher.ts` — function createContentFlusher: (sql, messageId, getContent) => void, interface ContentFlusher
|
||||||
- `apps/server/src/services/inference/dcp/messages.ts`
|
- `apps/server/src/services/inference/dcp/messages.ts`
|
||||||
- function toDcpMessages: (parts) => DcpMessage[]
|
- function toDcpMessages: (parts) => DcpMessage[]
|
||||||
@@ -882,6 +1112,10 @@
|
|||||||
- type FailureKind
|
- type FailureKind
|
||||||
- const MISTAKE_THRESHOLD
|
- const MISTAKE_THRESHOLD
|
||||||
- _...1 more_
|
- _...1 more_
|
||||||
|
- `apps/server/src/services/inference/multi-modal.ts`
|
||||||
|
- function hasImageAttachments: (_message) => boolean
|
||||||
|
- function imageAttachmentsToParts: (attachments) => Array<
|
||||||
|
- interface ImageAttachment
|
||||||
- `apps/server/src/services/inference/parts.ts`
|
- `apps/server/src/services/inference/parts.ts`
|
||||||
- function insertParts: (sql, parts) => Promise<void>
|
- function insertParts: (sql, parts) => Promise<void>
|
||||||
- function partsFromAssistantMessage: (args) => void
|
- function partsFromAssistantMessage: (args) => void
|
||||||
@@ -894,10 +1128,13 @@
|
|||||||
- function maybeFlagForCompaction: (ctx, chatId, updated) => Promise<void>
|
- function maybeFlagForCompaction: (ctx, chatId, updated) => Promise<void>
|
||||||
- interface OpenAiMessage
|
- interface OpenAiMessage
|
||||||
- `apps/server/src/services/inference/provider.ts`
|
- `apps/server/src/services/inference/provider.ts`
|
||||||
- function resolveRoute: (agent, config?) => RoutingInfo
|
- function isDeepSeekModel: (modelId) => boolean
|
||||||
|
- function resolveRoute: (agent, config?, modelId?) => RoutingInfo
|
||||||
- function upstreamModel: (config, modelId, agent?) => LanguageModel
|
- function upstreamModel: (config, modelId, agent?) => LanguageModel
|
||||||
|
- function resolveModelEndpoint: (config, modelId) => void
|
||||||
|
- function resetDeepSeekProvider: () => void
|
||||||
- interface RoutingInfo
|
- interface RoutingInfo
|
||||||
- type InferenceRoute
|
- _...1 more_
|
||||||
- `apps/server/src/services/inference/prune.ts`
|
- `apps/server/src/services/inference/prune.ts`
|
||||||
- function selectPruneTargets: (partsNewestFirst, tailStartCreatedAt) => void
|
- function selectPruneTargets: (partsNewestFirst, tailStartCreatedAt) => void
|
||||||
- function prune: (args) => Promise<PruneResult>
|
- function prune: (args) => Promise<PruneResult>
|
||||||
@@ -918,6 +1155,12 @@
|
|||||||
- function isAnySentinel: (m) => boolean
|
- function isAnySentinel: (m) => boolean
|
||||||
- const DOOM_LOOP_THRESHOLD
|
- const DOOM_LOOP_THRESHOLD
|
||||||
- _...1 more_
|
- _...1 more_
|
||||||
|
- `apps/server/src/services/inference/state-graph.ts`
|
||||||
|
- function createDefaultGraph: () => GraphNode[]
|
||||||
|
- function runGraph: (ctx, args, extra) => Promise<GraphResult>
|
||||||
|
- interface GraphState
|
||||||
|
- interface GraphResult
|
||||||
|
- type GraphNodeType
|
||||||
- `apps/server/src/services/inference/step-decision.ts`
|
- `apps/server/src/services/inference/step-decision.ts`
|
||||||
- function decideStep: (input) => PreStepDecision
|
- function decideStep: (input) => PreStepDecision
|
||||||
- function decidePostToolAction: (action, mistakeTracker) => PostToolDecision
|
- function decidePostToolAction: (action, mistakeTracker) => PostToolDecision
|
||||||
@@ -934,12 +1177,14 @@
|
|||||||
- `apps/server/src/services/inference/stream-phase.ts` — function executeStreamPhase: (ctx, args, session, messages, state, agent, // v1.11.8, web_search and web_fetch are stripped from the
|
- `apps/server/src/services/inference/stream-phase.ts` — function executeStreamPhase: (ctx, args, session, messages, state, agent, // v1.11.8, web_search and web_fetch are stripped from the
|
||||||
// tool list sent to the LLM, so the model can't even attempt them.
|
// tool list sent to the LLM, so the model can't even attempt them.
|
||||||
webToolsEnabled) => Promise<StreamResult>
|
webToolsEnabled) => Promise<StreamResult>
|
||||||
|
- `apps/server/src/services/inference/supervisor.ts` — function resolveSupervisorTurn: (latestUserMessage, agents, fallbackModel?) => Promise<SupervisorRoute | null>, interface SupervisorRoute
|
||||||
- `apps/server/src/services/inference/tool-call-parser.ts`
|
- `apps/server/src/services/inference/tool-call-parser.ts`
|
||||||
- function stripToolMarkup: (text, opts?) => string
|
- function stripToolMarkup: (text, opts?) => string
|
||||||
- function extractToolCallBlocks: (buffer, log?) => ToolCallExtraction
|
- function extractToolCallBlocks: (buffer, log?) => ToolCallExtraction
|
||||||
- interface ParsedCall
|
- interface ParsedCall
|
||||||
- interface ToolCallExtraction
|
- interface ToolCallExtraction
|
||||||
- `apps/server/src/services/inference/tool-phase.ts` — function executeToolPhase: (ctx, args, result, startedAt, session, projectRoot, agent?) => Promise<ToolPhaseResult>, interface ToolPhaseResult
|
- `apps/server/src/services/inference/tool-input-repair.ts` — function repairToolInput: (schema, unknown> | undefined, args, unknown>) => void, interface ToolInputRepair
|
||||||
|
- `apps/server/src/services/inference/tool-phase.ts` — function executeToolPhase: (ctx, args, result, startedAt, session, projectRoot, agent?, turnNumber?) => Promise<ToolPhaseResult>, interface ToolPhaseResult
|
||||||
- `apps/server/src/services/inference/tool-shim.ts`
|
- `apps/server/src/services/inference/tool-shim.ts`
|
||||||
- function extractToolCalls: (text) => ParsedToolCall[]
|
- function extractToolCalls: (text) => ParsedToolCall[]
|
||||||
- function hasToolCallMarkup: (text) => boolean
|
- function hasToolCallMarkup: (text) => boolean
|
||||||
@@ -955,20 +1200,26 @@
|
|||||||
- `apps/server/src/services/inference/turn.ts`
|
- `apps/server/src/services/inference/turn.ts`
|
||||||
- function runAssistantTurn: (ctx, args) => Promise<void>
|
- function runAssistantTurn: (ctx, args) => Promise<void>
|
||||||
- function runInference: (ctx, sessionId, chatId, assistantMessageId, signal?) => Promise<void>
|
- function runInference: (ctx, sessionId, chatId, assistantMessageId, signal?) => Promise<void>
|
||||||
|
- function runInferenceWithModel: (ctx, sessionId, chatId, assistantMessageId, modelOverride, compareGroupId, signal?) => Promise<void>
|
||||||
- function createInferenceRunner: (ctx, 'publishUser'>, publishUserFn, frame) => void
|
- function createInferenceRunner: (ctx, 'publishUser'>, publishUserFn, frame) => void
|
||||||
- `apps/server/src/services/mcp-client.ts`
|
- `apps/server/src/services/mcp-client.ts`
|
||||||
- function initialize: (entries, logger) => Promise<void>
|
- function initialize: (entries, logger) => Promise<void>
|
||||||
- function callTool: (prefixedName, args, unknown>) => Promise<unknown>
|
- function callTool: (prefixedName, args, unknown>) => Promise<unknown>
|
||||||
|
- function getServerPermission: (prefixedToolName) => McpPermission
|
||||||
|
- function setServerPermission: (serverName, permission) => void
|
||||||
|
- function getServerName: (prefixedToolName) => string | null
|
||||||
- function getTools: () => ToolDef<Record<string, unknown>>[]
|
- function getTools: () => ToolDef<Record<string, unknown>>[]
|
||||||
- function getMcpServers: () => Array<
|
- _...6 more_
|
||||||
- function shutdown: () => Promise<void>
|
|
||||||
- function wrapMcpTool: (serverName, mcpTool) => ToolDef<Record<string, unknown>>
|
|
||||||
- _...2 more_
|
|
||||||
- `apps/server/src/services/mcp-config.ts`
|
- `apps/server/src/services/mcp-config.ts`
|
||||||
- function substituteEnvVars: (value, log, unsetVars?) => unknown
|
- function substituteEnvVars: (value, log, unsetVars?) => unknown
|
||||||
- function loadMcpConfig: (configPath, log) => McpServerEntry[]
|
- function loadMcpConfig: (configPath, log) => McpServerEntry[]
|
||||||
- interface McpServerEntry
|
- interface McpServerEntry
|
||||||
- type McpServerConfig
|
- type McpServerConfig
|
||||||
|
- `apps/server/src/services/memory/bm25.ts` — class Bm25Ranker
|
||||||
|
- `apps/server/src/services/memory/embeddings.ts`
|
||||||
|
- function isEmbeddingAvailable: () => boolean
|
||||||
|
- function initEmbeddings: (modelPath?) => Promise<boolean>
|
||||||
|
- function embed: (texts) => Promise<number[][] | null>
|
||||||
- `apps/server/src/services/memory/entries.ts` — function parseMemoryEntries: (fileName, markdown) => MemoryEntry[], interface MemoryEntry
|
- `apps/server/src/services/memory/entries.ts` — function parseMemoryEntries: (fileName, markdown) => MemoryEntry[], interface MemoryEntry
|
||||||
- `apps/server/src/services/memory/paths.ts`
|
- `apps/server/src/services/memory/paths.ts`
|
||||||
- function getMemoryRoot: (projectRoot) => string
|
- function getMemoryRoot: (projectRoot) => string
|
||||||
@@ -976,7 +1227,10 @@
|
|||||||
- function ensureMemoryScaffold: (root) => Promise<void>
|
- function ensureMemoryScaffold: (root) => Promise<void>
|
||||||
- type MemoryTopic
|
- type MemoryTopic
|
||||||
- `apps/server/src/services/memory/prompt.ts` — function formatMemoryBlock: (entries) => string
|
- `apps/server/src/services/memory/prompt.ts` — function formatMemoryBlock: (entries) => string
|
||||||
- `apps/server/src/services/memory/recall.ts` — function rankByRelevance: (query, entries) => MemoryEntry[], function loadMemoryForSession: (projectRoot, _sessionId?, query?) => Promise<string[]>
|
- `apps/server/src/services/memory/recall.ts`
|
||||||
|
- function rankByRelevance: (query, entries) => MemoryEntry[]
|
||||||
|
- function rankByHybrid: (query, entries) => Promise<MemoryEntry[]>
|
||||||
|
- function loadMemoryForSession: (projectRoot, _sessionId?, query?) => Promise<string[]>
|
||||||
- `apps/server/src/services/memory/scan.ts`
|
- `apps/server/src/services/memory/scan.ts`
|
||||||
- function scanMemoryScopes: (scope) => Promise<MemoryEntry[]>
|
- function scanMemoryScopes: (scope) => Promise<MemoryEntry[]>
|
||||||
- function scanProjectMemory: (projectRoot) => Promise<MemoryEntry[]>
|
- function scanProjectMemory: (projectRoot) => Promise<MemoryEntry[]>
|
||||||
@@ -1007,6 +1261,11 @@
|
|||||||
- function filterSecretEntries: (entries, pathOf) => void
|
- function filterSecretEntries: (entries, pathOf) => void
|
||||||
- class SecretBlockedError
|
- class SecretBlockedError
|
||||||
- const DEFAULT_SECURITY_IGNORE_FILETYPES: ReadonlyArray<string>
|
- const DEFAULT_SECURITY_IGNORE_FILETYPES: ReadonlyArray<string>
|
||||||
|
- `apps/server/src/services/session-snapshots.ts`
|
||||||
|
- function saveAgentSnapshot: (sql, chatId, data) => Promise<void>
|
||||||
|
- function loadAgentSnapshot: (sql, chatId) => Promise<AgentSnapshot | null>
|
||||||
|
- function deleteAgentSnapshot: (sql, chatId) => Promise<void>
|
||||||
|
- interface AgentSnapshot
|
||||||
- `apps/server/src/services/skill-invoke.ts`
|
- `apps/server/src/services/skill-invoke.ts`
|
||||||
- function runSkillInvokeTransaction: (sql, args) => Promise<
|
- function runSkillInvokeTransaction: (sql, args) => Promise<
|
||||||
- function buildSkillInvokeSyntheticFrames: (chatId, result, toolCall, skillBody) => SkillInvokeSessionFrame[]
|
- function buildSkillInvokeSyntheticFrames: (chatId, result, toolCall, skillBody) => SkillInvokeSessionFrame[]
|
||||||
@@ -1037,8 +1296,53 @@
|
|||||||
- _...2 more_
|
- _...2 more_
|
||||||
- `apps/server/src/services/task-model.ts` — function taskModelCompletion: (opts) => Promise<string>
|
- `apps/server/src/services/task-model.ts` — function taskModelCompletion: (opts) => Promise<string>
|
||||||
- `apps/server/src/services/task-search-rewrite.ts` — function rewriteSearchQuery: (userMessage) => Promise<string>
|
- `apps/server/src/services/task-search-rewrite.ts` — function rewriteSearchQuery: (userMessage) => Promise<string>
|
||||||
|
- `apps/server/src/services/tool-traces.ts`
|
||||||
|
- function insertToolTrace: (sql, insert) => Promise<ToolTrace>
|
||||||
|
- function updateToolTrace: (sql, id, updates) => Promise<ToolTrace | null>
|
||||||
|
- interface ToolTrace
|
||||||
|
- interface ToolTraceInsert
|
||||||
|
- interface ToolTraceUpdate
|
||||||
|
- `apps/server/src/services/tools/background-subagent-tools.ts`
|
||||||
|
- function executeSpawnSubagent: (input, sql, sessionId) => Promise<Record<string, unknown>>
|
||||||
|
- function executeSubagentStatus: (input, sql) => Promise<Record<string, unknown>>
|
||||||
|
- function executeSubagentResult: (input, sql) => Promise<Record<string, unknown>>
|
||||||
|
- type SpawnSubagentInputT
|
||||||
|
- type SubagentStatusInputT
|
||||||
|
- type SubagentResultInputT
|
||||||
|
- _...6 more_
|
||||||
- `apps/server/src/services/tools/codecontext/factory.ts` — function makeCodecontextTool: (opts, unknown>;
|
- `apps/server/src/services/tools/codecontext/factory.ts` — function makeCodecontextTool: (opts, unknown>;
|
||||||
mapArgs) => void
|
mapArgs) => void
|
||||||
|
- `apps/server/src/services/tools/codecontext/get_code_health.ts`
|
||||||
|
- function executeGetCodeHealth: (input, projectPath) => Promise<string>
|
||||||
|
- type GetCodeHealthInputT
|
||||||
|
- const GetCodeHealthInput
|
||||||
|
- const getCodeHealth: ToolDef<GetCodeHealthInputT>
|
||||||
|
- `apps/server/src/services/tools/codecontext/get_code_impact.ts`
|
||||||
|
- function executeGetCodeImpact: (input, projectPath) => Promise<CodecontextResponse>
|
||||||
|
- type GetCodeImpactInputT
|
||||||
|
- const GetCodeImpactInput
|
||||||
|
- const getCodeImpact: ToolDef<GetCodeImpactInputT>
|
||||||
|
- `apps/server/src/services/tools/codecontext/get_code_map.ts`
|
||||||
|
- function executeGetCodeMap: (input, projectRoot) => Promise<CodeMapResponse>
|
||||||
|
- interface CodeMapResponse
|
||||||
|
- type GetCodeMapInputT
|
||||||
|
- const GetCodeMapInput
|
||||||
|
- const getCodeMap: ToolDef<GetCodeMapInputT>
|
||||||
|
- `apps/server/src/services/tools/codecontext/get_type_info.ts`
|
||||||
|
- function executeGetTypeInfo: (input, _projectPath?) => Promise<CodecontextResponse>
|
||||||
|
- type GetTypeInfoInputT
|
||||||
|
- const GetTypeInfoInput
|
||||||
|
- const getTypeInfo: ToolDef<GetTypeInfoInputT>
|
||||||
|
- `apps/server/src/services/tools/codecontext/get_wiki_article.ts`
|
||||||
|
- function executeGetWikiArticle: (input, projectPath) => Promise<string>
|
||||||
|
- type GetWikiArticleInputT
|
||||||
|
- const GetWikiArticleInput
|
||||||
|
- const getWikiArticle: ToolDef<GetWikiArticleInputT>
|
||||||
|
- `apps/server/src/services/tools/execute-command.ts`
|
||||||
|
- function executeRunCommand: (input, projectRoot) => Promise<RunCommandOutput>
|
||||||
|
- type RunCommandInputT
|
||||||
|
- type RunCommandOutput
|
||||||
|
- const runCommand: ToolDef<RunCommandInputT>
|
||||||
- `apps/server/src/services/tools/registry.ts` — function appendMcpTools: (mcpTools) => void, function toolJsonSchemas: () => ToolJsonSchema[]
|
- `apps/server/src/services/tools/registry.ts` — function appendMcpTools: (mcpTools) => void, function toolJsonSchemas: () => ToolJsonSchema[]
|
||||||
- `apps/server/src/services/tools/tiers.ts`
|
- `apps/server/src/services/tools/tiers.ts`
|
||||||
- function resolveToolTier: (tier) => readonly string[]
|
- function resolveToolTier: (tier) => readonly string[]
|
||||||
@@ -1064,6 +1368,39 @@
|
|||||||
- interface WebSearchOutput
|
- interface WebSearchOutput
|
||||||
- type WebSearchInputT
|
- type WebSearchInputT
|
||||||
- const webSearch: ToolDef<WebSearchInputT>
|
- const webSearch: ToolDef<WebSearchInputT>
|
||||||
|
- `apps/server/src/services/workflow/catalog.ts`
|
||||||
|
- function fingerprintAgentTask: (prompt, spec, unknown>, args) => string
|
||||||
|
- function getBuiltinWorkflows: () => BuiltinWorkflow[]
|
||||||
|
- function getBuiltinWorkflow: (name) => BuiltinWorkflow | undefined
|
||||||
|
- function mergeBuiltinWorkflows: (fileWorkflows) => Array<
|
||||||
|
- interface BuiltinWorkflow
|
||||||
|
- const meta
|
||||||
|
- `apps/server/src/services/workflow/discovery.ts`
|
||||||
|
- function isBuiltinWorkflow: (meta) => boolean
|
||||||
|
- function discoverWorkflows: (projectRoot) => WorkflowMeta[]
|
||||||
|
- function findWorkflow: (name, projectRoot) => WorkflowMeta | undefined
|
||||||
|
- function isValidWorkflowPath: (filePath) => boolean
|
||||||
|
- interface WorkflowMeta
|
||||||
|
- `apps/server/src/services/workflow/manager.ts`
|
||||||
|
- class WorkflowManager
|
||||||
|
- interface WorkflowMetaInfo
|
||||||
|
- type WorkflowEventHandler
|
||||||
|
- `apps/server/src/services/workflow/resumability.ts`
|
||||||
|
- function cacheKey: (spec, args) => string
|
||||||
|
- function getCachedResult: (key) => CachedResult | null
|
||||||
|
- function setCachedResult: (key, result) => void
|
||||||
|
- function invalidateRun: (runKey) => void
|
||||||
|
- function clearCache: () => void
|
||||||
|
- function cacheSize: () => number
|
||||||
|
- _...1 more_
|
||||||
|
- `apps/server/src/services/workflow/sandbox.ts`
|
||||||
|
- function transformEsmToCjs: (code) => string
|
||||||
|
- function name: (...) => void
|
||||||
|
- function isEsmSyntax: (code) => boolean
|
||||||
|
- function buildSandbox: (context) => Record<string, unknown>
|
||||||
|
- function loadWorkflowScript: (sourceFile, context) => (...args: unknown[]) => Promise<unknown>
|
||||||
|
- function loadWorkflowScriptFromCode: (code, context, filename?) => (...args: unknown[]) => Promise<unknown>
|
||||||
|
- _...3 more_
|
||||||
- `apps/server/src/utils/string-utils.ts` — function stripQuotes: (s) => string
|
- `apps/server/src/utils/string-utils.ts` — function stripQuotes: (s) => string
|
||||||
- `apps/web/src/api/client.ts`
|
- `apps/web/src/api/client.ts`
|
||||||
- class ApiError
|
- class ApiError
|
||||||
@@ -1084,7 +1421,7 @@
|
|||||||
- interface TerminalSelectionActions
|
- interface TerminalSelectionActions
|
||||||
- interface TerminalSelection
|
- interface TerminalSelection
|
||||||
- `apps/web/src/hooks/terminal/useTerminalSocket.ts`
|
- `apps/web/src/hooks/terminal/useTerminalSocket.ts`
|
||||||
- function useTerminalSocket: ({...}, sessionId, paneId, fit, getSize, setSize, }) => TerminalSocket
|
- function useTerminalSocket: ({...}, sessionId, paneId, description, parentAgent, fit, getSize, setSize, }) => TerminalSocket
|
||||||
- interface TerminalSocket
|
- interface TerminalSocket
|
||||||
- type ConnState
|
- type ConnState
|
||||||
- `apps/web/src/hooks/useActivePane.ts`
|
- `apps/web/src/hooks/useActivePane.ts`
|
||||||
@@ -1108,7 +1445,8 @@
|
|||||||
- interface ThroughputSample
|
- interface ThroughputSample
|
||||||
- `apps/web/src/hooks/useCoderUserEvents.ts` — function useCoderUserEvents: () => void
|
- `apps/web/src/hooks/useCoderUserEvents.ts` — function useCoderUserEvents: () => void
|
||||||
- `apps/web/src/hooks/useDiffPreferences.ts` — function useDiffPreferences: () => void, interface DiffPreferences
|
- `apps/web/src/hooks/useDiffPreferences.ts` — function useDiffPreferences: () => void, interface DiffPreferences
|
||||||
- `apps/web/src/hooks/useGitDiff.ts` — function useGitDiff: (projectId) => void
|
- `apps/web/src/hooks/useDraftPersistence.ts` — function useDraftPersistence: (chatId) => DraftPersistenceResult, interface DraftPersistenceResult
|
||||||
|
- `apps/web/src/hooks/useGitDiff.ts` — function useGitDiff: (projectId, hideWhitespace) => void
|
||||||
- `apps/web/src/hooks/useLongPress.ts` — function useLongPress: (callback) => void
|
- `apps/web/src/hooks/useLongPress.ts` — function useLongPress: (callback) => void
|
||||||
- `apps/web/src/hooks/useProjectGit.ts` — function useProjectGit: (projectId) => GitMeta | null
|
- `apps/web/src/hooks/useProjectGit.ts` — function useProjectGit: (projectId) => GitMeta | null
|
||||||
- `apps/web/src/hooks/useProviderSnapshot.ts` — function refreshProviderSnapshot: (cwd?) => Promise<ProviderSnapshotEntry[]>, function useProviderSnapshot: (cwd?) => ProviderSnapshotEntry[] | null
|
- `apps/web/src/hooks/useProviderSnapshot.ts` — function refreshProviderSnapshot: (cwd?) => Promise<ProviderSnapshotEntry[]>, function useProviderSnapshot: (cwd?) => ProviderSnapshotEntry[] | null
|
||||||
@@ -1121,6 +1459,7 @@
|
|||||||
- `apps/web/src/hooks/useSessions.ts` — function useSessions: (projectId) => void
|
- `apps/web/src/hooks/useSessions.ts` — function useSessions: (projectId) => void
|
||||||
- `apps/web/src/hooks/useSidebar.ts` — function useSidebar: () => void
|
- `apps/web/src/hooks/useSidebar.ts` — function useSidebar: () => void
|
||||||
- `apps/web/src/hooks/useSkills.ts` — function useSkills: () => void
|
- `apps/web/src/hooks/useSkills.ts` — function useSkills: () => void
|
||||||
|
- `apps/web/src/hooks/useTerminals.ts` — function useTerminals: () => TerminalRegistration[]
|
||||||
- `apps/web/src/hooks/useUserEvents.ts` — function useUserEvents: () => void
|
- `apps/web/src/hooks/useUserEvents.ts` — function useUserEvents: () => void
|
||||||
- `apps/web/src/hooks/useViewport.ts` — function useViewport: () => ViewportSnapshot, interface ViewportSnapshot
|
- `apps/web/src/hooks/useViewport.ts` — function useViewport: () => ViewportSnapshot, interface ViewportSnapshot
|
||||||
- `apps/web/src/hooks/useWorkspacePanes.ts`
|
- `apps/web/src/hooks/useWorkspacePanes.ts`
|
||||||
@@ -1183,7 +1522,16 @@
|
|||||||
- interface ThemeMeta
|
- interface ThemeMeta
|
||||||
- type ThemeId
|
- type ThemeId
|
||||||
- _...5 more_
|
- _...5 more_
|
||||||
|
- `apps/web/src/lib/tool-utils.ts`
|
||||||
|
- function isMcpTool: (name) => boolean
|
||||||
|
- function extractServerName: (name) => string | null
|
||||||
|
- function extractToolName: (name) => string | null
|
||||||
|
- const BUILT_IN_TOOLS
|
||||||
- `apps/web/src/lib/utils.ts` — function cn: (...inputs) => void
|
- `apps/web/src/lib/utils.ts` — function cn: (...inputs) => void
|
||||||
|
- `apps/web/src/stores/useDiffCommentStore.ts`
|
||||||
|
- function useDiffComments: (sessionId, mode) => void
|
||||||
|
- interface DiffComment
|
||||||
|
- interface DiffCommentTarget
|
||||||
- `apps/web/src/utils/diff-layout.ts`
|
- `apps/web/src/utils/diff-layout.ts`
|
||||||
- function parseDiff: (diffBody) => ParsedDiffFile[]
|
- function parseDiff: (diffBody) => ParsedDiffFile[]
|
||||||
- function buildSplitRows: (file) => SplitRow[]
|
- function buildSplitRows: (file) => SplitRow[]
|
||||||
@@ -1344,8 +1692,11 @@
|
|||||||
- `CONTAINER_GUIDANCE_FILE` **required** — apps/server/src/services/__tests__/system-prompt.test.ts
|
- `CONTAINER_GUIDANCE_FILE` **required** — apps/server/src/services/__tests__/system-prompt.test.ts
|
||||||
- `CONTEXT7_API_KEY` (has default) — .env
|
- `CONTEXT7_API_KEY` (has default) — .env
|
||||||
- `DATABASE_URL` (has default) — .env.example
|
- `DATABASE_URL` (has default) — .env.example
|
||||||
|
- `DEEPSEEK_API_KEY` (has default) — .env
|
||||||
|
- `DEEPSEEK_BASE_URL` (has default) — .env
|
||||||
- `DEFAULT_MODEL` (has default) — .env.example
|
- `DEFAULT_MODEL` (has default) — .env.example
|
||||||
- `DEV_REMOTE_USER` **required** — apps/web/vite.config.ts
|
- `DEV_REMOTE_USER` **required** — apps/web/vite.config.ts
|
||||||
|
- `EMBEDDING_MODEL_PATH` **required** — apps/server/src/services/memory/embeddings.ts
|
||||||
- `GITEA_BASE_URL` (has default) — .env
|
- `GITEA_BASE_URL` (has default) — .env
|
||||||
- `GITEA_SSH_HOST` (has default) — .env
|
- `GITEA_SSH_HOST` (has default) — .env
|
||||||
- `GITEA_TOKEN` (has default) — .env
|
- `GITEA_TOKEN` (has default) — .env
|
||||||
@@ -1353,6 +1704,7 @@
|
|||||||
- `LLAMA_SWAP_URL` (has default) — .env.example
|
- `LLAMA_SWAP_URL` (has default) — .env.example
|
||||||
- `MCP_TEST_MISSING` **required** — apps/server/src/services/__tests__/mcp-config.test.ts
|
- `MCP_TEST_MISSING` **required** — apps/server/src/services/__tests__/mcp-config.test.ts
|
||||||
- `MCP_TEST_SECRET` **required** — apps/server/src/services/__tests__/mcp-config.test.ts
|
- `MCP_TEST_SECRET` **required** — apps/server/src/services/__tests__/mcp-config.test.ts
|
||||||
|
- `MEMORY_SEARCH` **required** — apps/server/src/services/memory/recall.ts
|
||||||
- `NODE_ENV` (has default) — .env.example
|
- `NODE_ENV` (has default) — .env.example
|
||||||
- `PORT` (has default) — .env.example
|
- `PORT` (has default) — .env.example
|
||||||
- `POSTGRES_PASSWORD` (has default) — .env.example
|
- `POSTGRES_PASSWORD` (has default) — .env.example
|
||||||
@@ -1368,6 +1720,10 @@
|
|||||||
- `apps/web/vite.config.ts`
|
- `apps/web/vite.config.ts`
|
||||||
- `docker-compose.yml`
|
- `docker-compose.yml`
|
||||||
|
|
||||||
|
## Key Dependencies
|
||||||
|
|
||||||
|
- better-sqlite3: ^11.10.0
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Middleware
|
# Middleware
|
||||||
@@ -1379,6 +1735,7 @@
|
|||||||
- turn-guard — `apps/coder/src/services/backends/turn-guard.ts`
|
- turn-guard — `apps/coder/src/services/backends/turn-guard.ts`
|
||||||
- get_middleware — `apps/server/src/services/tools/codecontext/get_middleware.ts`
|
- get_middleware — `apps/server/src/services/tools/codecontext/get_middleware.ts`
|
||||||
- authoring — `conductor/src/flows/authoring.ts`
|
- authoring — `conductor/src/flows/authoring.ts`
|
||||||
|
- spec — `openspec/changes/add-behavioral-engine/specs/audit-middleware/spec.md`
|
||||||
|
|
||||||
## custom
|
## custom
|
||||||
- write_guard.test — `apps/coder/src/services/__tests__/write_guard.test.ts`
|
- write_guard.test — `apps/coder/src/services/__tests__/write_guard.test.ts`
|
||||||
@@ -1400,39 +1757,39 @@
|
|||||||
|
|
||||||
## Most Imported Files (change these carefully)
|
## Most Imported Files (change these carefully)
|
||||||
|
|
||||||
- `apps/coder/src/db.ts` — imported by **40** files
|
- `apps/coder/src/db.ts` — imported by **44** files
|
||||||
- `apps/server/src/types/api.ts` — imported by **28** files
|
- `apps/server/src/types/api.ts` — imported by **34** files
|
||||||
- `apps/server/src/db.ts` — imported by **25** files
|
- `apps/server/src/db.ts` — imported by **32** files
|
||||||
- `packages/ion/src/cli/utils.ts` — imported by **24** files
|
- `packages/ion/src/cli/utils.ts` — imported by **24** files
|
||||||
- `apps/coder/src/services/tools/types.ts` — imported by **18** files
|
- `apps/coder/src/services/tools/types.ts` — imported by **18** files
|
||||||
- `apps/coder/src/conductor/types.ts` — imported by **14** files
|
- `apps/coder/src/conductor/types.ts` — imported by **16** files
|
||||||
|
- `apps/server/src/services/tools.ts` — imported by **15** files
|
||||||
- `apps/coder/src/services/agent-backend.ts` — imported by **14** files
|
- `apps/coder/src/services/agent-backend.ts` — imported by **14** files
|
||||||
- `apps/coder/src/services/acp-tool-snapshot.ts` — imported by **14** files
|
- `apps/coder/src/services/acp-tool-snapshot.ts` — imported by **14** files
|
||||||
|
- `apps/server/src/config.ts` — imported by **14** files
|
||||||
- `apps/server/src/services/tools/codecontext/factory.ts` — imported by **14** files
|
- `apps/server/src/services/tools/codecontext/factory.ts` — imported by **14** files
|
||||||
- `apps/server/src/services/tools.ts` — imported by **13** files
|
- `apps/server/src/services/tools/types.ts` — imported by **13** files
|
||||||
- `conductor/src/types.ts` — imported by **13** files
|
- `conductor/src/types.ts` — imported by **13** files
|
||||||
- `apps/coder/src/services/provider-config-registry.ts` — imported by **12** files
|
- `apps/coder/src/services/provider-config-registry.ts` — imported by **12** files
|
||||||
- `apps/server/src/config.ts` — imported by **12** files
|
|
||||||
- `apps/coder/src/config.ts` — imported by **11** files
|
- `apps/coder/src/config.ts` — imported by **11** files
|
||||||
- `apps/coder/src/services/provider-types.ts` — imported by **11** files
|
- `apps/coder/src/services/provider-types.ts` — imported by **11** files
|
||||||
|
- `apps/server/src/services/broker.ts` — imported by **10** files
|
||||||
- `apps/server/src/services/agents.ts` — imported by **10** files
|
- `apps/server/src/services/agents.ts` — imported by **10** files
|
||||||
|
- `apps/server/src/services/path_guard.ts` — imported by **10** files
|
||||||
- `apps/coder/src/services/pending_changes.ts` — imported by **9** files
|
- `apps/coder/src/services/pending_changes.ts` — imported by **9** files
|
||||||
- `apps/server/src/services/broker.ts` — imported by **9** files
|
|
||||||
- `apps/server/src/services/path_guard.ts` — imported by **9** files
|
|
||||||
- `apps/server/src/services/inference/payload.ts` — imported by **9** files
|
|
||||||
|
|
||||||
## Import Map (who imports what)
|
## Import Map (who imports what)
|
||||||
|
|
||||||
- `apps/coder/src/db.ts` ← `apps/coder/src/index.ts`, `apps/coder/src/routes/__tests__/agent-sessions.routes.test.ts`, `apps/coder/src/routes/__tests__/chat-resolve.test.ts`, `apps/coder/src/routes/__tests__/providers.routes.test.ts`, `apps/coder/src/routes/agent-sessions.ts` +35 more
|
- `apps/coder/src/db.ts` ← `apps/coder/src/index.ts`, `apps/coder/src/routes/__tests__/agent-sessions.routes.test.ts`, `apps/coder/src/routes/__tests__/chat-resolve.test.ts`, `apps/coder/src/routes/__tests__/providers.routes.test.ts`, `apps/coder/src/routes/agent-sessions.ts` +39 more
|
||||||
- `apps/server/src/types/api.ts` ← `apps/server/src/routes/chats.ts`, `apps/server/src/routes/messages.ts`, `apps/server/src/routes/models.ts`, `apps/server/src/routes/projects.ts`, `apps/server/src/routes/sessions.ts` +23 more
|
- `apps/server/src/types/api.ts` ← `apps/server/src/routes/chats.ts`, `apps/server/src/routes/messages.ts`, `apps/server/src/routes/models.ts`, `apps/server/src/routes/projects.ts`, `apps/server/src/routes/sessions.ts` +29 more
|
||||||
- `apps/server/src/db.ts` ← `apps/server/src/index.ts`, `apps/server/src/routes/agents.ts`, `apps/server/src/routes/artifacts.ts`, `apps/server/src/routes/chats.ts`, `apps/server/src/routes/messages.ts` +20 more
|
- `apps/server/src/db.ts` ← `apps/server/src/index.ts`, `apps/server/src/routes/agents.ts`, `apps/server/src/routes/analytics.ts`, `apps/server/src/routes/artifacts.ts`, `apps/server/src/routes/chats.ts` +27 more
|
||||||
- `packages/ion/src/cli/utils.ts` ← `packages/ion/src/cli/commands/abandon.ts`, `packages/ion/src/cli/commands/abandon.ts`, `packages/ion/src/cli/commands/approve.ts`, `packages/ion/src/cli/commands/approve.ts`, `packages/ion/src/cli/commands/cleanup.ts` +19 more
|
- `packages/ion/src/cli/utils.ts` ← `packages/ion/src/cli/commands/abandon.ts`, `packages/ion/src/cli/commands/abandon.ts`, `packages/ion/src/cli/commands/approve.ts`, `packages/ion/src/cli/commands/approve.ts`, `packages/ion/src/cli/commands/cleanup.ts` +19 more
|
||||||
- `apps/coder/src/services/tools/types.ts` ← `apps/coder/src/routes/messages.ts`, `apps/coder/src/services/dispatcher.ts`, `apps/coder/src/services/tools/adapter.ts`, `apps/coder/src/services/tools/apply_pending.ts`, `apps/coder/src/services/tools/check_task_status.ts` +13 more
|
- `apps/coder/src/services/tools/types.ts` ← `apps/coder/src/routes/messages.ts`, `apps/coder/src/services/dispatcher.ts`, `apps/coder/src/services/tools/adapter.ts`, `apps/coder/src/services/tools/apply_pending.ts`, `apps/coder/src/services/tools/check_task_status.ts` +13 more
|
||||||
- `apps/coder/src/conductor/types.ts` ← `apps/coder/src/conductor/flows/_util.ts`, `apps/coder/src/conductor/flows/architectural-analysis.ts`, `apps/coder/src/conductor/flows/authoring.ts`, `apps/coder/src/conductor/flows/code-review.ts`, `apps/coder/src/conductor/flows/discovery.ts` +9 more
|
- `apps/coder/src/conductor/types.ts` ← `apps/coder/src/conductor/flows/_util.ts`, `apps/coder/src/conductor/flows/architectural-analysis.ts`, `apps/coder/src/conductor/flows/authoring.ts`, `apps/coder/src/conductor/flows/code-review.ts`, `apps/coder/src/conductor/flows/discovery.ts` +11 more
|
||||||
|
- `apps/server/src/services/tools.ts` ← `apps/server/src/index.ts`, `apps/server/src/services/__tests__/agent-allowlist.test.ts`, `apps/server/src/services/agents.ts`, `apps/server/src/services/inference/stream-phase-adapter.ts`, `apps/server/src/services/inference/stream-phase.ts` +10 more
|
||||||
- `apps/coder/src/services/agent-backend.ts` ← `apps/coder/src/routes/lifecycle.ts`, `apps/coder/src/services/__tests__/stream-json-parser.test.ts`, `apps/coder/src/services/acp-event-map.ts`, `apps/coder/src/services/agent-pool.ts`, `apps/coder/src/services/backends/__tests__/claude-sdk-map.test.ts` +9 more
|
- `apps/coder/src/services/agent-backend.ts` ← `apps/coder/src/routes/lifecycle.ts`, `apps/coder/src/services/__tests__/stream-json-parser.test.ts`, `apps/coder/src/services/acp-event-map.ts`, `apps/coder/src/services/agent-pool.ts`, `apps/coder/src/services/backends/__tests__/claude-sdk-map.test.ts` +9 more
|
||||||
- `apps/coder/src/services/acp-tool-snapshot.ts` ← `apps/coder/src/services/__tests__/acp-event-map.test.ts`, `apps/coder/src/services/__tests__/frame-emitter.test.ts`, `apps/coder/src/services/__tests__/stream-json-parser.test.ts`, `apps/coder/src/services/acp-dispatch.ts`, `apps/coder/src/services/acp-event-map.ts` +9 more
|
- `apps/coder/src/services/acp-tool-snapshot.ts` ← `apps/coder/src/services/__tests__/acp-event-map.test.ts`, `apps/coder/src/services/__tests__/frame-emitter.test.ts`, `apps/coder/src/services/__tests__/stream-json-parser.test.ts`, `apps/coder/src/services/acp-dispatch.ts`, `apps/coder/src/services/acp-event-map.ts` +9 more
|
||||||
- `apps/server/src/services/tools/codecontext/factory.ts` ← `apps/server/src/services/tools/codecontext/get_blast_radius.ts`, `apps/server/src/services/tools/codecontext/get_call_graph.ts`, `apps/server/src/services/tools/codecontext/get_codebase_overview.ts`, `apps/server/src/services/tools/codecontext/get_dependencies.ts`, `apps/server/src/services/tools/codecontext/get_file_analysis.ts` +9 more
|
- `apps/server/src/config.ts` ← `apps/server/src/db.ts`, `apps/server/src/index.ts`, `apps/server/src/routes/chats.ts`, `apps/server/src/routes/messages.ts`, `apps/server/src/routes/models.ts` +9 more
|
||||||
- `apps/server/src/services/tools.ts` ← `apps/server/src/index.ts`, `apps/server/src/services/__tests__/agent-allowlist.test.ts`, `apps/server/src/services/agents.ts`, `apps/server/src/services/inference/stream-phase-adapter.ts`, `apps/server/src/services/inference/stream-phase.ts` +8 more
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -10,23 +10,34 @@
|
|||||||
- **AttachmentChip** — props: attachment, onRemove, onPreview — `apps/web/src/components/AttachmentChip.tsx`
|
- **AttachmentChip** — props: attachment, onRemove, onPreview — `apps/web/src/components/AttachmentChip.tsx`
|
||||||
- **AttachmentPreviewModal** — props: attachment, onClose — `apps/web/src/components/AttachmentPreviewModal.tsx`
|
- **AttachmentPreviewModal** — props: attachment, onClose — `apps/web/src/components/AttachmentPreviewModal.tsx`
|
||||||
- **BottomSheet** — props: open, onClose, title — `apps/web/src/components/BottomSheet.tsx`
|
- **BottomSheet** — props: open, onClose, title — `apps/web/src/components/BottomSheet.tsx`
|
||||||
|
- **CacheShapeBadge** — props: cacheTokens, totalTokens — `apps/web/src/components/CacheShapeBadge.tsx`
|
||||||
- **CapHitSentinel** — props: message, capHitPosition, isLatest — `apps/web/src/components/CapHitSentinel.tsx`
|
- **CapHitSentinel** — props: message, capHitPosition, isLatest — `apps/web/src/components/CapHitSentinel.tsx`
|
||||||
- **ChatInput** — props: disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, generating, onStop — `apps/web/src/components/ChatInput.tsx`
|
- **ChatInput** — props: disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, generating, onStop — `apps/web/src/components/ChatInput.tsx`
|
||||||
- **ChatTabBar** — props: pane, tabs, tabNumbers, onSwitchTab, onRemoveTab, onCloseOthers, onCloseToRight, onCloseAll, onNewTab, onSplitPane — `apps/web/src/components/ChatTabBar.tsx`
|
- **ChatTabBar** — props: pane, tabs, tabNumbers, onSwitchTab, onRemoveTab, onCloseOthers, onCloseToRight, onCloseAll, onNewTab, onSplitPane — `apps/web/src/components/ChatTabBar.tsx`
|
||||||
- **ChatThroughput** — props: chatId, className — `apps/web/src/components/ChatThroughput.tsx`
|
- **ChatThroughput** — props: chatId, className — `apps/web/src/components/ChatThroughput.tsx`
|
||||||
- **CodeBlock** — props: code, lang — `apps/web/src/components/CodeBlock.tsx`
|
- **CodeBlock** — props: code, lang — `apps/web/src/components/CodeBlock.tsx`
|
||||||
|
- **ComparePane** — props: models, responses, onClose — `apps/web/src/components/ComparePane.tsx`
|
||||||
- **ContextMeter** — props: messages, modelContextLimit, sessionCostUsd — `apps/web/src/components/ContextMeter.tsx`
|
- **ContextMeter** — props: messages, modelContextLimit, sessionCostUsd — `apps/web/src/components/ContextMeter.tsx`
|
||||||
- **CreateProjectModal** — props: open, onOpenChange — `apps/web/src/components/CreateProjectModal.tsx`
|
- **CreateProjectModal** — props: open, onOpenChange — `apps/web/src/components/CreateProjectModal.tsx`
|
||||||
|
- **DiffSnippet** — props: diff — `apps/web/src/components/DiffSnippet.tsx`
|
||||||
|
- **DiffSplitView** — props: file, wrapLines — `apps/web/src/components/DiffSplitView.tsx`
|
||||||
- **DoomLoopSentinel** — props: message — `apps/web/src/components/DoomLoopSentinel.tsx`
|
- **DoomLoopSentinel** — props: message — `apps/web/src/components/DoomLoopSentinel.tsx`
|
||||||
- **DropOverlay** — props: visible — `apps/web/src/components/DropOverlay.tsx`
|
- **DropOverlay** — props: visible — `apps/web/src/components/DropOverlay.tsx`
|
||||||
|
- **EmptyState** — props: icon, title, description, action, className — `apps/web/src/components/EmptyState.tsx`
|
||||||
- **FileMentionPopover** — props: query, files, anchorRect, onSelect, onClose — `apps/web/src/components/FileMentionPopover.tsx`
|
- **FileMentionPopover** — props: query, files, anchorRect, onSelect, onClose — `apps/web/src/components/FileMentionPopover.tsx`
|
||||||
- **FileViewerOverlay** — props: path, content, lang, onClose — `apps/web/src/components/FileViewerOverlay.tsx`
|
- **FileViewerOverlay** — props: path, content, lang, onClose — `apps/web/src/components/FileViewerOverlay.tsx`
|
||||||
- **FlowLauncherDialog** — `apps/web/src/components/FlowLauncherDialog.tsx`
|
- **FlowLauncherDialog** — `apps/web/src/components/FlowLauncherDialog.tsx`
|
||||||
- **GitDiffView** — props: result, loading, error, mode, onSelectMode, onRefresh, mutating, mutateError, onStage, onUnstage — `apps/web/src/components/GitDiffView.tsx`
|
- **GitDiffView** — props: result, loading, error, mode, onSelectMode, onRefresh, mutating, mutateError, onStage, onUnstage — `apps/web/src/components/GitDiffView.tsx`
|
||||||
- **HtmlArtifactPane** — props: chatId, state, onClose — `apps/web/src/components/HtmlArtifactPane.tsx`
|
- **HtmlArtifactPane** — props: chatId, state, onClose — `apps/web/src/components/HtmlArtifactPane.tsx`
|
||||||
- **InferenceSettings** — `apps/web/src/components/InferenceSettings.tsx`
|
- **InferenceSettings** — `apps/web/src/components/InferenceSettings.tsx`
|
||||||
|
- **InlineReviewEditor** — props: initialBody, onSave, onCancel — `apps/web/src/components/InlineReviewEditor.tsx`
|
||||||
|
- **InlineReviewGutterCell** — props: lineNumber, type, hasComments, canComment, onClick — `apps/web/src/components/InlineReviewGutterCell.tsx`
|
||||||
|
- **InlineReviewThread** — props: comments, onEditComment, onDeleteComment — `apps/web/src/components/InlineReviewThread.tsx`
|
||||||
|
- **KeyboardShortcutsDialog** — props: open, onOpenChange — `apps/web/src/components/KeyboardShortcutsDialog.tsx`
|
||||||
- **MarkdownArtifactPane** — props: chatId, state, onClose — `apps/web/src/components/MarkdownArtifactPane.tsx`
|
- **MarkdownArtifactPane** — props: chatId, state, onClose — `apps/web/src/components/MarkdownArtifactPane.tsx`
|
||||||
- **MarkdownRenderer** — props: content — `apps/web/src/components/MarkdownRenderer.tsx`
|
- **MarkdownRenderer** — props: content — `apps/web/src/components/MarkdownRenderer.tsx`
|
||||||
|
- **McpPermissionDialog** — props: toolCallId, toolName, toolArgs, chatId, open, onClose — `apps/web/src/components/McpPermissionDialog.tsx`
|
||||||
|
- **McpResponseDisplay** — props: toolCall, toolResult — `apps/web/src/components/McpResponseDisplay.tsx`
|
||||||
- **MessageBubble** — props: message, sessionChats, capHitInfo, actions, hideActions, hasCheckpoint, restoreDisabled — `apps/web/src/components/MessageBubble.tsx`
|
- **MessageBubble** — props: message, sessionChats, capHitInfo, actions, hideActions, hasCheckpoint, restoreDisabled — `apps/web/src/components/MessageBubble.tsx`
|
||||||
- **MessageList** — props: messages, sessionChats — `apps/web/src/components/MessageList.tsx`
|
- **MessageList** — props: messages, sessionChats — `apps/web/src/components/MessageList.tsx`
|
||||||
- **MobileTabSwitcher** — props: panes, activePaneIdx, chats, onSwitchPane, onRemovePane, onRenameChat — `apps/web/src/components/MobileTabSwitcher.tsx`
|
- **MobileTabSwitcher** — props: panes, activePaneIdx, chats, onSwitchPane, onRemovePane, onRenameChat — `apps/web/src/components/MobileTabSwitcher.tsx`
|
||||||
@@ -38,12 +49,14 @@
|
|||||||
- **RequestReadAccessCard** — props: toolCall, toolResult, chatId — `apps/web/src/components/RequestReadAccessCard.tsx`
|
- **RequestReadAccessCard** — props: toolCall, toolResult, chatId — `apps/web/src/components/RequestReadAccessCard.tsx`
|
||||||
- **RightRail** — props: projectId, sessionId — `apps/web/src/components/RightRail.tsx`
|
- **RightRail** — props: projectId, sessionId — `apps/web/src/components/RightRail.tsx`
|
||||||
- **SessionLandingPage** — props: projectId, sessionId, agentId, onAgentChange, onSend, onSkillInvoke, createChat, chats, onOpenChat, onUnarchiveChat — `apps/web/src/components/SessionLandingPage.tsx`
|
- **SessionLandingPage** — props: projectId, sessionId, agentId, onAgentChange, onSend, onSkillInvoke, createChat, chats, onOpenChat, onUnarchiveChat — `apps/web/src/components/SessionLandingPage.tsx`
|
||||||
|
- **SessionTimeline** — props: messages, onClose, onScrollToMessage — `apps/web/src/components/SessionTimeline.tsx`
|
||||||
- **SlashCommandPicker** — props: query, items, groups, inputRef, onSelect, onClose, emptyLabel — `apps/web/src/components/SlashCommandPicker.tsx`
|
- **SlashCommandPicker** — props: query, items, groups, inputRef, onSelect, onClose, emptyLabel — `apps/web/src/components/SlashCommandPicker.tsx`
|
||||||
- **StaleStreamBanner** — props: onRetry, onDiscard — `apps/web/src/components/StaleStreamBanner.tsx`
|
- **StaleStreamBanner** — props: onRetry, onDiscard — `apps/web/src/components/StaleStreamBanner.tsx`
|
||||||
- **StatusDot** — props: chatId, className — `apps/web/src/components/StatusDot.tsx`
|
- **StatusDot** — props: chatId, className — `apps/web/src/components/StatusDot.tsx`
|
||||||
- **ThemePicker** — `apps/web/src/components/ThemePicker.tsx`
|
- **ThemePicker** — `apps/web/src/components/ThemePicker.tsx`
|
||||||
- **ToolCallGroup** — props: runs — `apps/web/src/components/ToolCallGroup.tsx`
|
- **ToolCallGroup** — props: runs — `apps/web/src/components/ToolCallGroup.tsx`
|
||||||
- **ToolCallLine** — props: run, insideGroup — `apps/web/src/components/ToolCallLine.tsx`
|
- **ToolCallLine** — props: run, insideGroup, chatId — `apps/web/src/components/ToolCallLine.tsx`
|
||||||
|
- **TraceViewer** — props: chatId — `apps/web/src/components/TraceViewer.tsx`
|
||||||
- **Workspace** — props: sessionId, projectId, agentId, onAgentChange, panesHook, chatsHook, session, project, onAddPane — `apps/web/src/components/Workspace.tsx`
|
- **Workspace** — props: sessionId, projectId, agentId, onAgentChange, panesHook, chatsHook, session, project, onAddPane — `apps/web/src/components/Workspace.tsx`
|
||||||
- **AddProviderModal** — props: open, onOpenChange, onAdded — `apps/web/src/components/coder/AddProviderModal.tsx`
|
- **AddProviderModal** — props: open, onOpenChange, onAdded — `apps/web/src/components/coder/AddProviderModal.tsx`
|
||||||
- **ProvidersSettings** — `apps/web/src/components/coder/ProvidersSettings.tsx`
|
- **ProvidersSettings** — `apps/web/src/components/coder/ProvidersSettings.tsx`
|
||||||
@@ -52,20 +65,30 @@
|
|||||||
- **ThemeFx** — `apps/web/src/components/fx/ThemeFx.tsx`
|
- **ThemeFx** — `apps/web/src/components/fx/ThemeFx.tsx`
|
||||||
- **ClaudeIcon** — props: size, className — `apps/web/src/components/icons/ProviderIcons.tsx`
|
- **ClaudeIcon** — props: size, className — `apps/web/src/components/icons/ProviderIcons.tsx`
|
||||||
- **OpenCodeIcon** — props: size, className — `apps/web/src/components/icons/ProviderIcons.tsx`
|
- **OpenCodeIcon** — props: size, className — `apps/web/src/components/icons/ProviderIcons.tsx`
|
||||||
|
- **ActionRow** — props: message, actions, hiddenSet, hasCheckpoint, restoreDisabled — `apps/web/src/components/message-parts/ActionRow.tsx`
|
||||||
|
- **CompactCard** — props: message, sessionChats — `apps/web/src/components/message-parts/CompactCard.tsx`
|
||||||
|
- **MistakeRecoverySentinel** — props: message — `apps/web/src/components/message-parts/MistakeRecoverySentinel.tsx`
|
||||||
|
- **ReasoningBlock** — props: text, streaming — `apps/web/src/components/message-parts/ReasoningBlock.tsx`
|
||||||
|
- **SendToTerminalMenu** — `apps/web/src/components/message-parts/SendToTerminalMenu.tsx`
|
||||||
|
- **StatsLine** — props: message — `apps/web/src/components/message-parts/StatsLine.tsx`
|
||||||
|
- **SummaryCard** — props: message — `apps/web/src/components/message-parts/SummaryCard.tsx`
|
||||||
- **ArenaPane** — props: state, onClose — `apps/web/src/components/panes/ArenaPane.tsx`
|
- **ArenaPane** — props: state, onClose — `apps/web/src/components/panes/ArenaPane.tsx`
|
||||||
- **ChatPane** — props: sessionId, chatId, projectId, agentId, onAgentChange, sessionChats, webSearchEnabled — `apps/web/src/components/panes/ChatPane.tsx`
|
- **ChatPane** — props: sessionId, chatId, projectId, agentId, onAgentChange, sessionChats, webSearchEnabled — `apps/web/src/components/panes/ChatPane.tsx`
|
||||||
- **CoderMessageList** — props: messages, chatId, footer, actions, checkpointMessageIds, restoreDisabled — `apps/web/src/components/panes/CoderMessageList.tsx`
|
- **CoderMessageList** — props: messages, chatId, footer, actions, checkpointMessageIds, restoreDisabled — `apps/web/src/components/panes/CoderMessageList.tsx`
|
||||||
- **CoderPane** — props: sessionId, paneId, chatId, chatPending, projectPath, onConnectedChange, onAgentLabelChange — `apps/web/src/components/panes/CoderPane.tsx`
|
- **CoderPane** — props: sessionId, paneId, chatId, chatPending, projectPath, onConnectedChange, onAgentLabelChange — `apps/web/src/components/panes/CoderPane.tsx`
|
||||||
- **OrchestratorPane** — props: state, onClose — `apps/web/src/components/panes/OrchestratorPane.tsx`
|
- **OrchestratorPane** — props: state, onClose — `apps/web/src/components/panes/OrchestratorPane.tsx`
|
||||||
- **SettingsPane** — props: session, project, maximized, onToggleMaximize, onClose, isMobile — `apps/web/src/components/panes/SettingsPane.tsx`
|
- **SettingsPane** — props: session, project, maximized, onToggleMaximize, onClose, isMobile — `apps/web/src/components/panes/SettingsPane.tsx`
|
||||||
- **TerminalPane** — props: sessionId, paneId, label, active — `apps/web/src/components/panes/TerminalPane.tsx`
|
- **TerminalPane** — props: sessionId, paneId, label, description, parentAgent, active — `apps/web/src/components/panes/TerminalPane.tsx`
|
||||||
- **FloatingMenu** — props: x, y, hasSelection, chatInputs, onCopy, onPaste, onSelectAll, onSearch, onSendToChat, onDismiss — `apps/web/src/components/panes/terminal/FloatingMenu.tsx`
|
- **FloatingMenu** — props: x, y, hasSelection, chatInputs, onCopy, onPaste, onSelectAll, onSearch, onSendToChat, onDismiss — `apps/web/src/components/panes/terminal/FloatingMenu.tsx`
|
||||||
- **SearchBar** — props: searchRef, theme, onClose — `apps/web/src/components/panes/terminal/SearchBar.tsx`
|
- **SearchBar** — props: searchRef, theme, onClose — `apps/web/src/components/panes/terminal/SearchBar.tsx`
|
||||||
- **TerminalHotkeyBar** — props: ctrlArmed, onSendBytes, onArmCtrl, onFit — `apps/web/src/components/panes/terminal/TerminalHotkeyBar.tsx`
|
- **TerminalHotkeyBar** — props: ctrlArmed, onSendBytes, onArmCtrl, onFit — `apps/web/src/components/panes/terminal/TerminalHotkeyBar.tsx`
|
||||||
- **RightRailDrawerProvider** — `apps/web/src/hooks/useRightRailDrawer.tsx`
|
- **RightRailDrawerProvider** — `apps/web/src/hooks/useRightRailDrawer.tsx`
|
||||||
- **SidebarDrawerProvider** — `apps/web/src/hooks/useSidebarDrawer.tsx`
|
- **SidebarDrawerProvider** — `apps/web/src/hooks/useSidebarDrawer.tsx`
|
||||||
- **PATH_REGEX** — `apps/web/src/lib/linkify-paths.tsx`
|
- **PATH_REGEX** — `apps/web/src/lib/linkify-paths.tsx`
|
||||||
|
- **Analytics** — `apps/web/src/pages/Analytics.tsx`
|
||||||
- **Home** — `apps/web/src/pages/Home.tsx`
|
- **Home** — `apps/web/src/pages/Home.tsx`
|
||||||
|
- **Memory** — `apps/web/src/pages/Memory.tsx`
|
||||||
- **Project** — `apps/web/src/pages/Project.tsx`
|
- **Project** — `apps/web/src/pages/Project.tsx`
|
||||||
|
- **Results** — `apps/web/src/pages/Results.tsx`
|
||||||
- **Session** — `apps/web/src/pages/Session.tsx`
|
- **Session** — `apps/web/src/pages/Session.tsx`
|
||||||
- **Settings** — `apps/web/src/pages/Settings.tsx`
|
- **Settings** — `apps/web/src/pages/Settings.tsx`
|
||||||
|
|||||||
@@ -25,8 +25,11 @@
|
|||||||
- `CONTAINER_GUIDANCE_FILE` **required** — apps/server/src/services/__tests__/system-prompt.test.ts
|
- `CONTAINER_GUIDANCE_FILE` **required** — apps/server/src/services/__tests__/system-prompt.test.ts
|
||||||
- `CONTEXT7_API_KEY` (has default) — .env
|
- `CONTEXT7_API_KEY` (has default) — .env
|
||||||
- `DATABASE_URL` (has default) — .env.example
|
- `DATABASE_URL` (has default) — .env.example
|
||||||
|
- `DEEPSEEK_API_KEY` (has default) — .env
|
||||||
|
- `DEEPSEEK_BASE_URL` (has default) — .env
|
||||||
- `DEFAULT_MODEL` (has default) — .env.example
|
- `DEFAULT_MODEL` (has default) — .env.example
|
||||||
- `DEV_REMOTE_USER` **required** — apps/web/vite.config.ts
|
- `DEV_REMOTE_USER` **required** — apps/web/vite.config.ts
|
||||||
|
- `EMBEDDING_MODEL_PATH` **required** — apps/server/src/services/memory/embeddings.ts
|
||||||
- `GITEA_BASE_URL` (has default) — .env
|
- `GITEA_BASE_URL` (has default) — .env
|
||||||
- `GITEA_SSH_HOST` (has default) — .env
|
- `GITEA_SSH_HOST` (has default) — .env
|
||||||
- `GITEA_TOKEN` (has default) — .env
|
- `GITEA_TOKEN` (has default) — .env
|
||||||
@@ -34,6 +37,7 @@
|
|||||||
- `LLAMA_SWAP_URL` (has default) — .env.example
|
- `LLAMA_SWAP_URL` (has default) — .env.example
|
||||||
- `MCP_TEST_MISSING` **required** — apps/server/src/services/__tests__/mcp-config.test.ts
|
- `MCP_TEST_MISSING` **required** — apps/server/src/services/__tests__/mcp-config.test.ts
|
||||||
- `MCP_TEST_SECRET` **required** — apps/server/src/services/__tests__/mcp-config.test.ts
|
- `MCP_TEST_SECRET` **required** — apps/server/src/services/__tests__/mcp-config.test.ts
|
||||||
|
- `MEMORY_SEARCH` **required** — apps/server/src/services/memory/recall.ts
|
||||||
- `NODE_ENV` (has default) — .env.example
|
- `NODE_ENV` (has default) — .env.example
|
||||||
- `PORT` (has default) — .env.example
|
- `PORT` (has default) — .env.example
|
||||||
- `POSTGRES_PASSWORD` (has default) — .env.example
|
- `POSTGRES_PASSWORD` (has default) — .env.example
|
||||||
@@ -48,3 +52,7 @@
|
|||||||
- `Dockerfile`
|
- `Dockerfile`
|
||||||
- `apps/web/vite.config.ts`
|
- `apps/web/vite.config.ts`
|
||||||
- `docker-compose.yml`
|
- `docker-compose.yml`
|
||||||
|
|
||||||
|
## Key Dependencies
|
||||||
|
|
||||||
|
- better-sqlite3: ^11.10.0
|
||||||
|
|||||||
@@ -2,36 +2,36 @@
|
|||||||
|
|
||||||
## Most Imported Files (change these carefully)
|
## Most Imported Files (change these carefully)
|
||||||
|
|
||||||
- `apps/coder/src/db.ts` — imported by **40** files
|
- `apps/coder/src/db.ts` — imported by **44** files
|
||||||
- `apps/server/src/types/api.ts` — imported by **28** files
|
- `apps/server/src/types/api.ts` — imported by **34** files
|
||||||
- `apps/server/src/db.ts` — imported by **25** files
|
- `apps/server/src/db.ts` — imported by **32** files
|
||||||
- `packages/ion/src/cli/utils.ts` — imported by **24** files
|
- `packages/ion/src/cli/utils.ts` — imported by **24** files
|
||||||
- `apps/coder/src/services/tools/types.ts` — imported by **18** files
|
- `apps/coder/src/services/tools/types.ts` — imported by **18** files
|
||||||
- `apps/coder/src/conductor/types.ts` — imported by **14** files
|
- `apps/coder/src/conductor/types.ts` — imported by **16** files
|
||||||
|
- `apps/server/src/services/tools.ts` — imported by **15** files
|
||||||
- `apps/coder/src/services/agent-backend.ts` — imported by **14** files
|
- `apps/coder/src/services/agent-backend.ts` — imported by **14** files
|
||||||
- `apps/coder/src/services/acp-tool-snapshot.ts` — imported by **14** files
|
- `apps/coder/src/services/acp-tool-snapshot.ts` — imported by **14** files
|
||||||
|
- `apps/server/src/config.ts` — imported by **14** files
|
||||||
- `apps/server/src/services/tools/codecontext/factory.ts` — imported by **14** files
|
- `apps/server/src/services/tools/codecontext/factory.ts` — imported by **14** files
|
||||||
- `apps/server/src/services/tools.ts` — imported by **13** files
|
- `apps/server/src/services/tools/types.ts` — imported by **13** files
|
||||||
- `conductor/src/types.ts` — imported by **13** files
|
- `conductor/src/types.ts` — imported by **13** files
|
||||||
- `apps/coder/src/services/provider-config-registry.ts` — imported by **12** files
|
- `apps/coder/src/services/provider-config-registry.ts` — imported by **12** files
|
||||||
- `apps/server/src/config.ts` — imported by **12** files
|
|
||||||
- `apps/coder/src/config.ts` — imported by **11** files
|
- `apps/coder/src/config.ts` — imported by **11** files
|
||||||
- `apps/coder/src/services/provider-types.ts` — imported by **11** files
|
- `apps/coder/src/services/provider-types.ts` — imported by **11** files
|
||||||
|
- `apps/server/src/services/broker.ts` — imported by **10** files
|
||||||
- `apps/server/src/services/agents.ts` — imported by **10** files
|
- `apps/server/src/services/agents.ts` — imported by **10** files
|
||||||
|
- `apps/server/src/services/path_guard.ts` — imported by **10** files
|
||||||
- `apps/coder/src/services/pending_changes.ts` — imported by **9** files
|
- `apps/coder/src/services/pending_changes.ts` — imported by **9** files
|
||||||
- `apps/server/src/services/broker.ts` — imported by **9** files
|
|
||||||
- `apps/server/src/services/path_guard.ts` — imported by **9** files
|
|
||||||
- `apps/server/src/services/inference/payload.ts` — imported by **9** files
|
|
||||||
|
|
||||||
## Import Map (who imports what)
|
## Import Map (who imports what)
|
||||||
|
|
||||||
- `apps/coder/src/db.ts` ← `apps/coder/src/index.ts`, `apps/coder/src/routes/__tests__/agent-sessions.routes.test.ts`, `apps/coder/src/routes/__tests__/chat-resolve.test.ts`, `apps/coder/src/routes/__tests__/providers.routes.test.ts`, `apps/coder/src/routes/agent-sessions.ts` +35 more
|
- `apps/coder/src/db.ts` ← `apps/coder/src/index.ts`, `apps/coder/src/routes/__tests__/agent-sessions.routes.test.ts`, `apps/coder/src/routes/__tests__/chat-resolve.test.ts`, `apps/coder/src/routes/__tests__/providers.routes.test.ts`, `apps/coder/src/routes/agent-sessions.ts` +39 more
|
||||||
- `apps/server/src/types/api.ts` ← `apps/server/src/routes/chats.ts`, `apps/server/src/routes/messages.ts`, `apps/server/src/routes/models.ts`, `apps/server/src/routes/projects.ts`, `apps/server/src/routes/sessions.ts` +23 more
|
- `apps/server/src/types/api.ts` ← `apps/server/src/routes/chats.ts`, `apps/server/src/routes/messages.ts`, `apps/server/src/routes/models.ts`, `apps/server/src/routes/projects.ts`, `apps/server/src/routes/sessions.ts` +29 more
|
||||||
- `apps/server/src/db.ts` ← `apps/server/src/index.ts`, `apps/server/src/routes/agents.ts`, `apps/server/src/routes/artifacts.ts`, `apps/server/src/routes/chats.ts`, `apps/server/src/routes/messages.ts` +20 more
|
- `apps/server/src/db.ts` ← `apps/server/src/index.ts`, `apps/server/src/routes/agents.ts`, `apps/server/src/routes/analytics.ts`, `apps/server/src/routes/artifacts.ts`, `apps/server/src/routes/chats.ts` +27 more
|
||||||
- `packages/ion/src/cli/utils.ts` ← `packages/ion/src/cli/commands/abandon.ts`, `packages/ion/src/cli/commands/abandon.ts`, `packages/ion/src/cli/commands/approve.ts`, `packages/ion/src/cli/commands/approve.ts`, `packages/ion/src/cli/commands/cleanup.ts` +19 more
|
- `packages/ion/src/cli/utils.ts` ← `packages/ion/src/cli/commands/abandon.ts`, `packages/ion/src/cli/commands/abandon.ts`, `packages/ion/src/cli/commands/approve.ts`, `packages/ion/src/cli/commands/approve.ts`, `packages/ion/src/cli/commands/cleanup.ts` +19 more
|
||||||
- `apps/coder/src/services/tools/types.ts` ← `apps/coder/src/routes/messages.ts`, `apps/coder/src/services/dispatcher.ts`, `apps/coder/src/services/tools/adapter.ts`, `apps/coder/src/services/tools/apply_pending.ts`, `apps/coder/src/services/tools/check_task_status.ts` +13 more
|
- `apps/coder/src/services/tools/types.ts` ← `apps/coder/src/routes/messages.ts`, `apps/coder/src/services/dispatcher.ts`, `apps/coder/src/services/tools/adapter.ts`, `apps/coder/src/services/tools/apply_pending.ts`, `apps/coder/src/services/tools/check_task_status.ts` +13 more
|
||||||
- `apps/coder/src/conductor/types.ts` ← `apps/coder/src/conductor/flows/_util.ts`, `apps/coder/src/conductor/flows/architectural-analysis.ts`, `apps/coder/src/conductor/flows/authoring.ts`, `apps/coder/src/conductor/flows/code-review.ts`, `apps/coder/src/conductor/flows/discovery.ts` +9 more
|
- `apps/coder/src/conductor/types.ts` ← `apps/coder/src/conductor/flows/_util.ts`, `apps/coder/src/conductor/flows/architectural-analysis.ts`, `apps/coder/src/conductor/flows/authoring.ts`, `apps/coder/src/conductor/flows/code-review.ts`, `apps/coder/src/conductor/flows/discovery.ts` +11 more
|
||||||
|
- `apps/server/src/services/tools.ts` ← `apps/server/src/index.ts`, `apps/server/src/services/__tests__/agent-allowlist.test.ts`, `apps/server/src/services/agents.ts`, `apps/server/src/services/inference/stream-phase-adapter.ts`, `apps/server/src/services/inference/stream-phase.ts` +10 more
|
||||||
- `apps/coder/src/services/agent-backend.ts` ← `apps/coder/src/routes/lifecycle.ts`, `apps/coder/src/services/__tests__/stream-json-parser.test.ts`, `apps/coder/src/services/acp-event-map.ts`, `apps/coder/src/services/agent-pool.ts`, `apps/coder/src/services/backends/__tests__/claude-sdk-map.test.ts` +9 more
|
- `apps/coder/src/services/agent-backend.ts` ← `apps/coder/src/routes/lifecycle.ts`, `apps/coder/src/services/__tests__/stream-json-parser.test.ts`, `apps/coder/src/services/acp-event-map.ts`, `apps/coder/src/services/agent-pool.ts`, `apps/coder/src/services/backends/__tests__/claude-sdk-map.test.ts` +9 more
|
||||||
- `apps/coder/src/services/acp-tool-snapshot.ts` ← `apps/coder/src/services/__tests__/acp-event-map.test.ts`, `apps/coder/src/services/__tests__/frame-emitter.test.ts`, `apps/coder/src/services/__tests__/stream-json-parser.test.ts`, `apps/coder/src/services/acp-dispatch.ts`, `apps/coder/src/services/acp-event-map.ts` +9 more
|
- `apps/coder/src/services/acp-tool-snapshot.ts` ← `apps/coder/src/services/__tests__/acp-event-map.test.ts`, `apps/coder/src/services/__tests__/frame-emitter.test.ts`, `apps/coder/src/services/__tests__/stream-json-parser.test.ts`, `apps/coder/src/services/acp-dispatch.ts`, `apps/coder/src/services/acp-event-map.ts` +9 more
|
||||||
- `apps/server/src/services/tools/codecontext/factory.ts` ← `apps/server/src/services/tools/codecontext/get_blast_radius.ts`, `apps/server/src/services/tools/codecontext/get_call_graph.ts`, `apps/server/src/services/tools/codecontext/get_codebase_overview.ts`, `apps/server/src/services/tools/codecontext/get_dependencies.ts`, `apps/server/src/services/tools/codecontext/get_file_analysis.ts` +9 more
|
- `apps/server/src/config.ts` ← `apps/server/src/db.ts`, `apps/server/src/index.ts`, `apps/server/src/routes/chats.ts`, `apps/server/src/routes/messages.ts`, `apps/server/src/routes/models.ts` +9 more
|
||||||
- `apps/server/src/services/tools.ts` ← `apps/server/src/index.ts`, `apps/server/src/services/__tests__/agent-allowlist.test.ts`, `apps/server/src/services/agents.ts`, `apps/server/src/services/inference/stream-phase-adapter.ts`, `apps/server/src/services/inference/stream-phase.ts` +8 more
|
|
||||||
|
|||||||
@@ -14,8 +14,17 @@
|
|||||||
- function ensureSession: (tmuxConfPath, sessionName, projectRoot, log, cols?, rows?) => Promise<void>
|
- function ensureSession: (tmuxConfPath, sessionName, projectRoot, log, cols?, rows?) => Promise<void>
|
||||||
- function killSession: (tmuxConfPath, sessionName) => Promise<boolean>
|
- function killSession: (tmuxConfPath, sessionName) => Promise<boolean>
|
||||||
- function capturePane: (tmuxConfPath, sessionName, lines) => Promise<string>
|
- function capturePane: (tmuxConfPath, sessionName, lines) => Promise<string>
|
||||||
|
- _...1 more_
|
||||||
- `apps/booterm/src/pty/pty.ts` — function attachPty: (opts) => IPty
|
- `apps/booterm/src/pty/pty.ts` — function attachPty: (opts) => IPty
|
||||||
- `apps/booterm/src/ws/attach.ts` — function registerWsAttachRoute: (app, tmuxConfPath) => void
|
- `apps/booterm/src/pty/registry.ts`
|
||||||
|
- function register: (sessionId, paneId, projectPath, title?, opts?) => void
|
||||||
|
- function unregister: (paneId) => void
|
||||||
|
- function touchActivity: (paneId) => void
|
||||||
|
- function list: () => SessionMeta[]
|
||||||
|
- function get: (paneId) => SessionMeta | undefined
|
||||||
|
- function setPendingMetadata: (paneId, meta) => void
|
||||||
|
- _...8 more_
|
||||||
|
- `apps/booterm/src/ws/attach.ts` — function registerWsAttachRoute: (app, tmuxConfPath, idleTimeoutSeconds?, absoluteTimeoutSeconds?) => void
|
||||||
- `apps/coder/src/conductor/contracts.ts`
|
- `apps/coder/src/conductor/contracts.ts`
|
||||||
- function produceContract: (contracts) => string
|
- function produceContract: (contracts) => string
|
||||||
- function reviewContract: (contracts) => string
|
- function reviewContract: (contracts) => string
|
||||||
@@ -102,7 +111,7 @@
|
|||||||
- function classifyLane: (battleType, _identity, model, localModels) => ContestantLane
|
- function classifyLane: (battleType, _identity, model, localModels) => ContestantLane
|
||||||
- function nextLocalContestant: (contestants) => string | null
|
- function nextLocalContestant: (contestants) => string | null
|
||||||
- function isBattleComplete: (contestants) => boolean
|
- function isBattleComplete: (contestants) => boolean
|
||||||
- function computeBenchmark: (startedAt, endedAt, costTokens, lane) => Benchmark
|
- function computeBenchmark: (startedAt, endedAt, costTokens, lane, tokenBreakdown) => Benchmark
|
||||||
- function sanitizeSlug: (s) => string
|
- function sanitizeSlug: (s) => string
|
||||||
- function buildBattleSlug: (battleId, battleType, createdAt) => string
|
- function buildBattleSlug: (battleId, battleType, createdAt) => string
|
||||||
- _...7 more_
|
- _...7 more_
|
||||||
@@ -166,6 +175,7 @@
|
|||||||
- function stepEndedToUsage: (props) => StepUsage
|
- function stepEndedToUsage: (props) => StepUsage
|
||||||
- interface StepEndedProps
|
- interface StepEndedProps
|
||||||
- interface StepUsage
|
- interface StepUsage
|
||||||
|
- `apps/coder/src/services/backends/paseo.ts` — class PaseoBackend, interface PaseoBackendDeps
|
||||||
- `apps/coder/src/services/backends/pushable-iterable.ts` — function createPushable: () => Pushable<T>, interface Pushable
|
- `apps/coder/src/services/backends/pushable-iterable.ts` — function createPushable: () => Pushable<T>, interface Pushable
|
||||||
- `apps/coder/src/services/backends/turn-guard.ts`
|
- `apps/coder/src/services/backends/turn-guard.ts`
|
||||||
- function armAbortGuard: (g) => void
|
- function armAbortGuard: (g) => void
|
||||||
@@ -174,6 +184,30 @@
|
|||||||
- interface AbortTerminalGuard
|
- interface AbortTerminalGuard
|
||||||
- `apps/coder/src/services/backends/warm-acp-routing.ts` — function shouldUseWarmBackend: (task) => boolean, function isTurnOkForStopReason: (stopReason) => boolean
|
- `apps/coder/src/services/backends/warm-acp-routing.ts` — function shouldUseWarmBackend: (task) => boolean, function isTurnOkForStopReason: (stopReason) => boolean
|
||||||
- `apps/coder/src/services/backends/warm-acp.ts` — class WarmAcpBackend, interface WarmAcpBackendDeps
|
- `apps/coder/src/services/backends/warm-acp.ts` — class WarmAcpBackend, interface WarmAcpBackendDeps
|
||||||
|
- `apps/coder/src/services/behavioral/generation.ts`
|
||||||
|
- function createExecutionPlan: (observational, actionable, previouslyApplied, disambiguationGroups, lowCriticality) => BatchExecutionPlan[]
|
||||||
|
- function getRetryTemperatures: (baseTemp, maxAttempts) => number[]
|
||||||
|
- class SchematicGenerator
|
||||||
|
- class DefaultSchematicGenerator
|
||||||
|
- interface ObservationalOutput
|
||||||
|
- interface ActionableOutput
|
||||||
|
- _...7 more_
|
||||||
|
- `apps/coder/src/services/behavioral/matching.ts`
|
||||||
|
- function matchWithRetry: (fn) => void
|
||||||
|
- function executeBatchesParallel: (batches, _generationInfo) => Promise<GuidelineMatchingResult>
|
||||||
|
- function createScoredMatch: (guidelineId, score, rationale) => ScoredMatch
|
||||||
|
- class GuidelineMatchingBatchError
|
||||||
|
- class ObservationalGuidelineMatchingBatch
|
||||||
|
- class ActionableGuidelineMatchingBatch
|
||||||
|
- _...25 more_
|
||||||
|
- `apps/coder/src/services/behavioral/resolver.ts`
|
||||||
|
- class RelationalResolver
|
||||||
|
- interface RelationshipEntity
|
||||||
|
- interface Relationship
|
||||||
|
- interface RelationshipStore
|
||||||
|
- interface ResolvedEntity
|
||||||
|
- interface Resolution
|
||||||
|
- _...8 more_
|
||||||
- `apps/coder/src/services/cancel-registry.ts` — function createCancelRegistry: () => CancelRegistry, interface CancelRegistry
|
- `apps/coder/src/services/cancel-registry.ts` — function createCancelRegistry: () => CancelRegistry, interface CancelRegistry
|
||||||
- `apps/coder/src/services/checkpoints.ts`
|
- `apps/coder/src/services/checkpoints.ts`
|
||||||
- function buildShadowCommitCommand: (worktreePath, id) => string
|
- function buildShadowCommitCommand: (worktreePath, id) => string
|
||||||
@@ -184,7 +218,15 @@
|
|||||||
- interface RestoreCheckpointResult
|
- interface RestoreCheckpointResult
|
||||||
- _...1 more_
|
- _...1 more_
|
||||||
- `apps/coder/src/services/claude-command-discovery.ts` — function discoverClaudeCommands: () => AgentCommand[]
|
- `apps/coder/src/services/claude-command-discovery.ts` — function discoverClaudeCommands: () => AgentCommand[]
|
||||||
|
- `apps/coder/src/services/collision-detector.ts`
|
||||||
|
- function findConflicts: (changedFiles, worktreeId, /** Approximate line range for the proposed changes, keyed by file path */
|
||||||
|
changedRanges, {...}, conflictIndex) => ConflictVerdict[]
|
||||||
|
- interface ConflictVerdict
|
||||||
|
- interface ConflictEntry
|
||||||
|
- type ConflictSeverity
|
||||||
|
- type ConflictIndexData
|
||||||
- `apps/coder/src/services/command-availability.ts` — function isCommandAvailable: (binary) => Promise<boolean>
|
- `apps/coder/src/services/command-availability.ts` — function isCommandAvailable: (binary) => Promise<boolean>
|
||||||
|
- `apps/coder/src/services/conflict-index.ts` — class ConflictIndex, const conflictIndex
|
||||||
- `apps/coder/src/services/correction-service.ts`
|
- `apps/coder/src/services/correction-service.ts`
|
||||||
- function recordCorrection: (originalClaim, correction, principleExtracted, persistedTo, basePath?) => Promise<UserCorrectionRecord>
|
- function recordCorrection: (originalClaim, correction, principleExtracted, persistedTo, basePath?) => Promise<UserCorrectionRecord>
|
||||||
- function scanForCorrections: (auditPath) => Promise<UserCorrectionRecord[]>
|
- function scanForCorrections: (auditPath) => Promise<UserCorrectionRecord[]>
|
||||||
@@ -214,10 +256,11 @@
|
|||||||
- function partitionReady: (ready, ctx) => void
|
- function partitionReady: (ready, ctx) => void
|
||||||
- function isRunComplete: (flow, state) => boolean
|
- function isRunComplete: (flow, state) => boolean
|
||||||
- function isStuck: (flow, state) => boolean
|
- function isStuck: (flow, state) => boolean
|
||||||
- function reconcileResumeStep: (status, taskId, taskState) => ResumeAction
|
- function buildBatchState: (flow, inFlight) => Map<string,
|
||||||
- _...5 more_
|
- _...12 more_
|
||||||
- `apps/coder/src/services/flow-runner.ts`
|
- `apps/coder/src/services/flow-runner.ts`
|
||||||
- function createFlowRunner: (deps) => FlowRunner
|
- function createFlowRunner: (deps) => FlowRunner
|
||||||
|
- function resolveVariables: (prompt, results, string>) => string
|
||||||
- interface LaunchOpts
|
- interface LaunchOpts
|
||||||
- interface FlowRunner
|
- interface FlowRunner
|
||||||
- `apps/coder/src/services/frame-emitter.ts`
|
- `apps/coder/src/services/frame-emitter.ts`
|
||||||
@@ -237,6 +280,19 @@
|
|||||||
- function deleteGuideline: (id, basePath?) => Promise<boolean>
|
- function deleteGuideline: (id, basePath?) => Promise<boolean>
|
||||||
- function findGuideline: (content, basePath?) => Promise<Guideline | null>
|
- function findGuideline: (content, basePath?) => Promise<Guideline | null>
|
||||||
- _...14 more_
|
- _...14 more_
|
||||||
|
- `apps/coder/src/services/hashline/hash-computation.ts`
|
||||||
|
- function computeLineHash: (lineNumber, content) => string
|
||||||
|
- function computeLegacyLineHash: (lineNumber, content) => string
|
||||||
|
- function formatHashLine: (lineNumber, content) => string
|
||||||
|
- function formatHashLines: (content) => string
|
||||||
|
- `apps/coder/src/services/hashline/validation.ts`
|
||||||
|
- function normalizeLineRef: (ref) => string
|
||||||
|
- function parseLineRef: (ref) => LineRef
|
||||||
|
- function validateLineRef: (lines, ref) => void
|
||||||
|
- function validateLineRefs: (lines, refs) => void
|
||||||
|
- class HashlineMismatchError
|
||||||
|
- interface LineRef
|
||||||
|
- `apps/coder/src/services/hashline/xxhash32.ts` — function hashXxh32: (input, seed) => number
|
||||||
- `apps/coder/src/services/host-exec.ts` — function hostExec: (command, opts?) => Promise<HostExecResult>, interface HostExecResult
|
- `apps/coder/src/services/host-exec.ts` — function hostExec: (command, opts?) => Promise<HostExecResult>, interface HostExecResult
|
||||||
- `apps/coder/src/services/lsp/client.ts` — class LspClient
|
- `apps/coder/src/services/lsp/client.ts` — class LspClient
|
||||||
- `apps/coder/src/services/lsp/config.ts` — function getServerConfig: (filePath) => LspServerConfig | null, interface LspServerConfig
|
- `apps/coder/src/services/lsp/config.ts` — function getServerConfig: (filePath) => LspServerConfig | null, interface LspServerConfig
|
||||||
@@ -248,6 +304,44 @@
|
|||||||
- function findReferences: (client, filePath, content, line, character) => Promise<Location[]>
|
- function findReferences: (client, filePath, content, line, character) => Promise<Location[]>
|
||||||
- `apps/coder/src/services/lsp/server-manager.ts` — class LspServerManager, const lspManager
|
- `apps/coder/src/services/lsp/server-manager.ts` — class LspServerManager, const lspManager
|
||||||
- `apps/coder/src/services/mcp-server.ts` — function startMcpServer: (sql) => Promise<void>
|
- `apps/coder/src/services/mcp-server.ts` — function startMcpServer: (sql) => Promise<void>
|
||||||
|
- `apps/coder/src/services/model-resolution/connected-providers-cache.ts`
|
||||||
|
- function readConnectedProvidersCache: () => string[] | null
|
||||||
|
- function findProviderModelMetadata: (_providerID, _modelID) => ModelMetadata | undefined
|
||||||
|
- function readProviderModelsCache: () => ProviderModelsCache | null
|
||||||
|
- interface ProviderModelsCache
|
||||||
|
- interface ConnectedProvidersAdapter
|
||||||
|
- const connectedProvidersAdapter: ConnectedProvidersAdapter
|
||||||
|
- `apps/coder/src/services/model-resolution/fallback-chain-from-models.ts`
|
||||||
|
- function parseFallbackModelEntry: (model, contextProviderID, defaultProviderID) => FallbackEntry | undefined
|
||||||
|
- function parseFallbackModelObjectEntry: (obj, contextProviderID, defaultProviderID) => FallbackEntry | undefined
|
||||||
|
- function findMostSpecificFallbackEntry: (providerID, modelID, chain) => FallbackEntry | undefined
|
||||||
|
- function buildFallbackChainFromModels: (fallbackModels) => void
|
||||||
|
- `apps/coder/src/services/model-resolution/model-availability.ts` — function fuzzyMatchModel: (target, available, providers?) => string | null, function isModelAvailable: (targetModel, availableModels) => boolean
|
||||||
|
- `apps/coder/src/services/model-resolution/model-error-classifier.ts`
|
||||||
|
- function isRetryableModelError: (error) => boolean
|
||||||
|
- function shouldRetryError: (error) => boolean
|
||||||
|
- function getNextFallback: (fallbackChain, attemptCount) => FallbackEntry | undefined
|
||||||
|
- function hasMoreFallbacks: (fallbackChain, attemptCount) => boolean
|
||||||
|
- function selectFallbackProvider: (providers, preferredProviderID?) => string
|
||||||
|
- function selectFallbackProviderWithCache: (providers, providerCache, preferredProviderID?) => string
|
||||||
|
- _...1 more_
|
||||||
|
- `apps/coder/src/services/model-resolution/model-normalization.ts` — function normalizeModel: (model?) => string | undefined, function normalizeModelID: (modelID) => string
|
||||||
|
- `apps/coder/src/services/model-resolution/model-resolution-pipeline.ts`
|
||||||
|
- function _setModelResolutionLogImplementationForTesting: (logImplementation) => void
|
||||||
|
- function resolveModelPipeline: (request, providerCache) => void
|
||||||
|
- type ModelResolutionRequest
|
||||||
|
- type ModelResolutionProvenance
|
||||||
|
- type ModelResolutionResult
|
||||||
|
- type ModelResolutionDeps
|
||||||
|
- `apps/coder/src/services/model-resolution/model-resolver.ts`
|
||||||
|
- function resolveModel: (input) => string | undefined
|
||||||
|
- function resolveModelWithFallback: (input, connectedProvidersAdapter) => ModelResolutionResult | undefined
|
||||||
|
- function normalizeFallbackModels: (models) => void
|
||||||
|
- function flattenToFallbackModelStrings: (models) => void
|
||||||
|
- type ModelResolutionInput
|
||||||
|
- type ModelSource
|
||||||
|
- _...2 more_
|
||||||
|
- `apps/coder/src/services/model-resolution/provider-model-id-transform.ts` — function transformModelForProvider: (provider, model) => string, function transformModelForProviderDisplay: (provider, model) => string
|
||||||
- `apps/coder/src/services/net/port-utils.ts`
|
- `apps/coder/src/services/net/port-utils.ts`
|
||||||
- function reclaimPort: (port) => void
|
- function reclaimPort: (port) => void
|
||||||
- function waitForPortRelease: (port, timeoutMs) => Promise<boolean>
|
- function waitForPortRelease: (port, timeoutMs) => Promise<boolean>
|
||||||
@@ -257,6 +351,13 @@
|
|||||||
- function createOrphanWorktreeReaper: (deps) => void
|
- function createOrphanWorktreeReaper: (deps) => void
|
||||||
- interface OrphanWorktreeReaperDeps
|
- interface OrphanWorktreeReaperDeps
|
||||||
- interface OrphanReaperResult
|
- interface OrphanReaperResult
|
||||||
|
- `apps/coder/src/services/paseo-client.ts`
|
||||||
|
- class PaseoClientError
|
||||||
|
- class PaseoClient
|
||||||
|
- interface PaseoAgentListItem
|
||||||
|
- interface PaseoAgentDetail
|
||||||
|
- interface PaseoSendResult
|
||||||
|
- interface PaseoClientConfig
|
||||||
- `apps/coder/src/services/pending_changes.ts`
|
- `apps/coder/src/services/pending_changes.ts`
|
||||||
- function planEdit: (content, oldStr, newStr) => EditPlan
|
- function planEdit: (content, oldStr, newStr) => EditPlan
|
||||||
- function queueEdit: (sql, sessionId, taskId, filePath, oldString, newString, projectRoot, // v2.6 Phase 1-UX) => void
|
- function queueEdit: (sql, sessionId, taskId, filePath, oldString, newString, projectRoot, // v2.6 Phase 1-UX) => void
|
||||||
@@ -273,6 +374,14 @@
|
|||||||
- function waitForElicitationResponse: (taskId, sessionId, provider, modeId, params, timeoutMs) => Promise<CreateElicitationResponse>
|
- function waitForElicitationResponse: (taskId, sessionId, provider, modeId, params, timeoutMs) => Promise<CreateElicitationResponse>
|
||||||
- function cancelPendingPermission: (taskId) => void
|
- function cancelPendingPermission: (taskId) => void
|
||||||
- _...3 more_
|
- _...3 more_
|
||||||
|
- `apps/coder/src/services/plan-store.ts`
|
||||||
|
- function createPlan: (sql, opts) => Promise<Plan>
|
||||||
|
- function getPlan: (sql, planId) => Promise<Plan | null>
|
||||||
|
- function listPlans: (sql, projectId) => Promise<Plan[]>
|
||||||
|
- function listActivePlans: (sql, projectId) => Promise<Plan[]>
|
||||||
|
- function updatePlan: (sql, planId, opts) => Promise<Plan | null>
|
||||||
|
- function updatePlanFromRun: (sql, runId, runStatus) => Promise<boolean>
|
||||||
|
- _...5 more_
|
||||||
- `apps/coder/src/services/provider-commands.ts`
|
- `apps/coder/src/services/provider-commands.ts`
|
||||||
- function getManifestCommands: (provider) => AgentCommand[]
|
- function getManifestCommands: (provider) => AgentCommand[]
|
||||||
- function mergeCommands: (...lists) => AgentCommand[]
|
- function mergeCommands: (...lists) => AgentCommand[]
|
||||||
@@ -295,13 +404,13 @@
|
|||||||
- interface ProviderManifestEntry
|
- interface ProviderManifestEntry
|
||||||
- const PROVIDER_MANIFEST: Record<string, ProviderManifestEntry>
|
- const PROVIDER_MANIFEST: Record<string, ProviderManifestEntry>
|
||||||
- `apps/coder/src/services/provider-snapshot.ts`
|
- `apps/coder/src/services/provider-snapshot.ts`
|
||||||
|
- function fetchDeepSeekModels: (config) => Promise<ProviderModel[]>
|
||||||
- function fetchLlamaSwapModels: (config) => Promise<ProviderModel[]>
|
- function fetchLlamaSwapModels: (config) => Promise<ProviderModel[]>
|
||||||
- function prefixLlamaSwapModels: (models) => ProviderModel[]
|
- function prefixLlamaSwapModels: (models) => ProviderModel[]
|
||||||
- function mergeModels: (...lists) => ProviderModel[]
|
- function mergeModels: (...lists) => ProviderModel[]
|
||||||
- function getProviderSnapshot: (sql, config, cwd?, force) => Promise<ProviderSnapshotEntry[]>
|
- function getProviderSnapshot: (sql, config, cwd?, force) => Promise<ProviderSnapshotEntry[]>
|
||||||
- function clearProviderSnapshotCache: () => void
|
- function clearProviderSnapshotCache: () => void
|
||||||
- function peekSnapshotEntry: (name, cwd?) => ProviderSnapshotEntry | undefined
|
- _...2 more_
|
||||||
- _...1 more_
|
|
||||||
- `apps/coder/src/services/pty-dispatch.ts`
|
- `apps/coder/src/services/pty-dispatch.ts`
|
||||||
- function dispatchViaPty: (opts) => Promise<DispatchResult>
|
- function dispatchViaPty: (opts) => Promise<DispatchResult>
|
||||||
- interface DispatchResult
|
- interface DispatchResult
|
||||||
@@ -411,6 +520,17 @@
|
|||||||
- function readSession: (sessionId, projectRoot?) => SessionJson | null
|
- function readSession: (sessionId, projectRoot?) => SessionJson | null
|
||||||
- _...9 more_
|
- _...9 more_
|
||||||
- `apps/server/src/services/auto_name.ts` — function maybeAutoNameChat: (ctx, chatId, sessionId) => Promise<void>
|
- `apps/server/src/services/auto_name.ts` — function maybeAutoNameChat: (ctx, chatId, sessionId) => Promise<void>
|
||||||
|
- `apps/server/src/services/background-task.ts`
|
||||||
|
- function setBackgroundInferenceEnqueuer: (enqueue, chatId, assistantMessageId, user) => void
|
||||||
|
- function spawnBackgroundTask: (sql, log, projectId, input, model, agent?, label?) => Promise<BackgroundTask>
|
||||||
|
- function getBackgroundTaskStatus: (sql, taskId) => Promise<BackgroundTask | null>
|
||||||
|
- function getBackgroundTaskResult: (sql, taskId, chatId) => Promise<
|
||||||
|
- function cancelBackgroundTask: (sql, taskId) => Promise<boolean>
|
||||||
|
- interface BackgroundTask
|
||||||
|
- `apps/server/src/services/boocontext_client.ts`
|
||||||
|
- function callBoocontext: (req, log?, msg) => void
|
||||||
|
- interface BoocontextRequest
|
||||||
|
- interface BoocontextResponse
|
||||||
- `apps/server/src/services/broker.ts`
|
- `apps/server/src/services/broker.ts`
|
||||||
- function createBroker: (log?) => Broker
|
- function createBroker: (log?) => Broker
|
||||||
- interface Broker
|
- interface Broker
|
||||||
@@ -429,6 +549,7 @@
|
|||||||
- function select: (messages, contextLimit, tailTurns) => SelectResult
|
- function select: (messages, contextLimit, tailTurns) => SelectResult
|
||||||
- function deriveFilesRead: (head) => string[]
|
- function deriveFilesRead: (head) => string[]
|
||||||
- _...8 more_
|
- _...8 more_
|
||||||
|
- `apps/server/src/services/export-formatter.ts` — function formatJson: (chat, messages, model) => string, function formatMarkdown: (chat, messages, model) => string
|
||||||
- `apps/server/src/services/file_index.ts` — function getProjectFiles: (projectId, projectRoot) => Promise<string[]>
|
- `apps/server/src/services/file_index.ts` — function getProjectFiles: (projectId, projectRoot) => Promise<string[]>
|
||||||
- `apps/server/src/services/file_ops.ts`
|
- `apps/server/src/services/file_ops.ts`
|
||||||
- function listDir: (projectRoot, relPath, opts?) => Promise<ListDirResult>
|
- function listDir: (projectRoot, relPath, opts?) => Promise<ListDirResult>
|
||||||
@@ -453,7 +574,20 @@
|
|||||||
- interface GiteaConfig
|
- interface GiteaConfig
|
||||||
- interface GiteaRepo
|
- interface GiteaRepo
|
||||||
- `apps/server/src/services/grant_resolver.ts` — function resolveGrantRoot: (sql, requestedPath, projectRoot, whitelistRoot) => Promise<GrantResolution>, type GrantResolution
|
- `apps/server/src/services/grant_resolver.ts` — function resolveGrantRoot: (sql, requestedPath, projectRoot, whitelistRoot) => Promise<GrantResolution>, type GrantResolution
|
||||||
|
- `apps/server/src/services/hooks.ts`
|
||||||
|
- function loadHooksConfig: (path) => HooksConfig
|
||||||
|
- function reloadHooksConfig: () => HooksConfig
|
||||||
|
- function createHookRunner: () => HookRunner
|
||||||
|
- interface HookConfig
|
||||||
|
- interface HooksConfig
|
||||||
|
- interface PreToolUsePayload
|
||||||
|
- _...10 more_
|
||||||
- `apps/server/src/services/inference/budget.ts` — function resolveToolBudget: (agent) => number
|
- `apps/server/src/services/inference/budget.ts` — function resolveToolBudget: (agent) => number
|
||||||
|
- `apps/server/src/services/inference/compute-diff.ts`
|
||||||
|
- function computeDiff: (oldStr, newStr, filePath) => string
|
||||||
|
- function isWriteTool: (name) => boolean
|
||||||
|
- function diffFromToolArgs: (name, args, unknown>, filePath?) => string
|
||||||
|
- const WRITE_TOOL_NAMES
|
||||||
- `apps/server/src/services/inference/content-flusher.ts` — function createContentFlusher: (sql, messageId, getContent) => void, interface ContentFlusher
|
- `apps/server/src/services/inference/content-flusher.ts` — function createContentFlusher: (sql, messageId, getContent) => void, interface ContentFlusher
|
||||||
- `apps/server/src/services/inference/dcp/messages.ts`
|
- `apps/server/src/services/inference/dcp/messages.ts`
|
||||||
- function toDcpMessages: (parts) => DcpMessage[]
|
- function toDcpMessages: (parts) => DcpMessage[]
|
||||||
@@ -493,6 +627,10 @@
|
|||||||
- type FailureKind
|
- type FailureKind
|
||||||
- const MISTAKE_THRESHOLD
|
- const MISTAKE_THRESHOLD
|
||||||
- _...1 more_
|
- _...1 more_
|
||||||
|
- `apps/server/src/services/inference/multi-modal.ts`
|
||||||
|
- function hasImageAttachments: (_message) => boolean
|
||||||
|
- function imageAttachmentsToParts: (attachments) => Array<
|
||||||
|
- interface ImageAttachment
|
||||||
- `apps/server/src/services/inference/parts.ts`
|
- `apps/server/src/services/inference/parts.ts`
|
||||||
- function insertParts: (sql, parts) => Promise<void>
|
- function insertParts: (sql, parts) => Promise<void>
|
||||||
- function partsFromAssistantMessage: (args) => void
|
- function partsFromAssistantMessage: (args) => void
|
||||||
@@ -505,10 +643,13 @@
|
|||||||
- function maybeFlagForCompaction: (ctx, chatId, updated) => Promise<void>
|
- function maybeFlagForCompaction: (ctx, chatId, updated) => Promise<void>
|
||||||
- interface OpenAiMessage
|
- interface OpenAiMessage
|
||||||
- `apps/server/src/services/inference/provider.ts`
|
- `apps/server/src/services/inference/provider.ts`
|
||||||
- function resolveRoute: (agent, config?) => RoutingInfo
|
- function isDeepSeekModel: (modelId) => boolean
|
||||||
|
- function resolveRoute: (agent, config?, modelId?) => RoutingInfo
|
||||||
- function upstreamModel: (config, modelId, agent?) => LanguageModel
|
- function upstreamModel: (config, modelId, agent?) => LanguageModel
|
||||||
|
- function resolveModelEndpoint: (config, modelId) => void
|
||||||
|
- function resetDeepSeekProvider: () => void
|
||||||
- interface RoutingInfo
|
- interface RoutingInfo
|
||||||
- type InferenceRoute
|
- _...1 more_
|
||||||
- `apps/server/src/services/inference/prune.ts`
|
- `apps/server/src/services/inference/prune.ts`
|
||||||
- function selectPruneTargets: (partsNewestFirst, tailStartCreatedAt) => void
|
- function selectPruneTargets: (partsNewestFirst, tailStartCreatedAt) => void
|
||||||
- function prune: (args) => Promise<PruneResult>
|
- function prune: (args) => Promise<PruneResult>
|
||||||
@@ -529,6 +670,12 @@
|
|||||||
- function isAnySentinel: (m) => boolean
|
- function isAnySentinel: (m) => boolean
|
||||||
- const DOOM_LOOP_THRESHOLD
|
- const DOOM_LOOP_THRESHOLD
|
||||||
- _...1 more_
|
- _...1 more_
|
||||||
|
- `apps/server/src/services/inference/state-graph.ts`
|
||||||
|
- function createDefaultGraph: () => GraphNode[]
|
||||||
|
- function runGraph: (ctx, args, extra) => Promise<GraphResult>
|
||||||
|
- interface GraphState
|
||||||
|
- interface GraphResult
|
||||||
|
- type GraphNodeType
|
||||||
- `apps/server/src/services/inference/step-decision.ts`
|
- `apps/server/src/services/inference/step-decision.ts`
|
||||||
- function decideStep: (input) => PreStepDecision
|
- function decideStep: (input) => PreStepDecision
|
||||||
- function decidePostToolAction: (action, mistakeTracker) => PostToolDecision
|
- function decidePostToolAction: (action, mistakeTracker) => PostToolDecision
|
||||||
@@ -545,12 +692,14 @@
|
|||||||
- `apps/server/src/services/inference/stream-phase.ts` — function executeStreamPhase: (ctx, args, session, messages, state, agent, // v1.11.8, web_search and web_fetch are stripped from the
|
- `apps/server/src/services/inference/stream-phase.ts` — function executeStreamPhase: (ctx, args, session, messages, state, agent, // v1.11.8, web_search and web_fetch are stripped from the
|
||||||
// tool list sent to the LLM, so the model can't even attempt them.
|
// tool list sent to the LLM, so the model can't even attempt them.
|
||||||
webToolsEnabled) => Promise<StreamResult>
|
webToolsEnabled) => Promise<StreamResult>
|
||||||
|
- `apps/server/src/services/inference/supervisor.ts` — function resolveSupervisorTurn: (latestUserMessage, agents, fallbackModel?) => Promise<SupervisorRoute | null>, interface SupervisorRoute
|
||||||
- `apps/server/src/services/inference/tool-call-parser.ts`
|
- `apps/server/src/services/inference/tool-call-parser.ts`
|
||||||
- function stripToolMarkup: (text, opts?) => string
|
- function stripToolMarkup: (text, opts?) => string
|
||||||
- function extractToolCallBlocks: (buffer, log?) => ToolCallExtraction
|
- function extractToolCallBlocks: (buffer, log?) => ToolCallExtraction
|
||||||
- interface ParsedCall
|
- interface ParsedCall
|
||||||
- interface ToolCallExtraction
|
- interface ToolCallExtraction
|
||||||
- `apps/server/src/services/inference/tool-phase.ts` — function executeToolPhase: (ctx, args, result, startedAt, session, projectRoot, agent?) => Promise<ToolPhaseResult>, interface ToolPhaseResult
|
- `apps/server/src/services/inference/tool-input-repair.ts` — function repairToolInput: (schema, unknown> | undefined, args, unknown>) => void, interface ToolInputRepair
|
||||||
|
- `apps/server/src/services/inference/tool-phase.ts` — function executeToolPhase: (ctx, args, result, startedAt, session, projectRoot, agent?, turnNumber?) => Promise<ToolPhaseResult>, interface ToolPhaseResult
|
||||||
- `apps/server/src/services/inference/tool-shim.ts`
|
- `apps/server/src/services/inference/tool-shim.ts`
|
||||||
- function extractToolCalls: (text) => ParsedToolCall[]
|
- function extractToolCalls: (text) => ParsedToolCall[]
|
||||||
- function hasToolCallMarkup: (text) => boolean
|
- function hasToolCallMarkup: (text) => boolean
|
||||||
@@ -566,20 +715,26 @@
|
|||||||
- `apps/server/src/services/inference/turn.ts`
|
- `apps/server/src/services/inference/turn.ts`
|
||||||
- function runAssistantTurn: (ctx, args) => Promise<void>
|
- function runAssistantTurn: (ctx, args) => Promise<void>
|
||||||
- function runInference: (ctx, sessionId, chatId, assistantMessageId, signal?) => Promise<void>
|
- function runInference: (ctx, sessionId, chatId, assistantMessageId, signal?) => Promise<void>
|
||||||
|
- function runInferenceWithModel: (ctx, sessionId, chatId, assistantMessageId, modelOverride, compareGroupId, signal?) => Promise<void>
|
||||||
- function createInferenceRunner: (ctx, 'publishUser'>, publishUserFn, frame) => void
|
- function createInferenceRunner: (ctx, 'publishUser'>, publishUserFn, frame) => void
|
||||||
- `apps/server/src/services/mcp-client.ts`
|
- `apps/server/src/services/mcp-client.ts`
|
||||||
- function initialize: (entries, logger) => Promise<void>
|
- function initialize: (entries, logger) => Promise<void>
|
||||||
- function callTool: (prefixedName, args, unknown>) => Promise<unknown>
|
- function callTool: (prefixedName, args, unknown>) => Promise<unknown>
|
||||||
|
- function getServerPermission: (prefixedToolName) => McpPermission
|
||||||
|
- function setServerPermission: (serverName, permission) => void
|
||||||
|
- function getServerName: (prefixedToolName) => string | null
|
||||||
- function getTools: () => ToolDef<Record<string, unknown>>[]
|
- function getTools: () => ToolDef<Record<string, unknown>>[]
|
||||||
- function getMcpServers: () => Array<
|
- _...6 more_
|
||||||
- function shutdown: () => Promise<void>
|
|
||||||
- function wrapMcpTool: (serverName, mcpTool) => ToolDef<Record<string, unknown>>
|
|
||||||
- _...2 more_
|
|
||||||
- `apps/server/src/services/mcp-config.ts`
|
- `apps/server/src/services/mcp-config.ts`
|
||||||
- function substituteEnvVars: (value, log, unsetVars?) => unknown
|
- function substituteEnvVars: (value, log, unsetVars?) => unknown
|
||||||
- function loadMcpConfig: (configPath, log) => McpServerEntry[]
|
- function loadMcpConfig: (configPath, log) => McpServerEntry[]
|
||||||
- interface McpServerEntry
|
- interface McpServerEntry
|
||||||
- type McpServerConfig
|
- type McpServerConfig
|
||||||
|
- `apps/server/src/services/memory/bm25.ts` — class Bm25Ranker
|
||||||
|
- `apps/server/src/services/memory/embeddings.ts`
|
||||||
|
- function isEmbeddingAvailable: () => boolean
|
||||||
|
- function initEmbeddings: (modelPath?) => Promise<boolean>
|
||||||
|
- function embed: (texts) => Promise<number[][] | null>
|
||||||
- `apps/server/src/services/memory/entries.ts` — function parseMemoryEntries: (fileName, markdown) => MemoryEntry[], interface MemoryEntry
|
- `apps/server/src/services/memory/entries.ts` — function parseMemoryEntries: (fileName, markdown) => MemoryEntry[], interface MemoryEntry
|
||||||
- `apps/server/src/services/memory/paths.ts`
|
- `apps/server/src/services/memory/paths.ts`
|
||||||
- function getMemoryRoot: (projectRoot) => string
|
- function getMemoryRoot: (projectRoot) => string
|
||||||
@@ -587,7 +742,10 @@
|
|||||||
- function ensureMemoryScaffold: (root) => Promise<void>
|
- function ensureMemoryScaffold: (root) => Promise<void>
|
||||||
- type MemoryTopic
|
- type MemoryTopic
|
||||||
- `apps/server/src/services/memory/prompt.ts` — function formatMemoryBlock: (entries) => string
|
- `apps/server/src/services/memory/prompt.ts` — function formatMemoryBlock: (entries) => string
|
||||||
- `apps/server/src/services/memory/recall.ts` — function rankByRelevance: (query, entries) => MemoryEntry[], function loadMemoryForSession: (projectRoot, _sessionId?, query?) => Promise<string[]>
|
- `apps/server/src/services/memory/recall.ts`
|
||||||
|
- function rankByRelevance: (query, entries) => MemoryEntry[]
|
||||||
|
- function rankByHybrid: (query, entries) => Promise<MemoryEntry[]>
|
||||||
|
- function loadMemoryForSession: (projectRoot, _sessionId?, query?) => Promise<string[]>
|
||||||
- `apps/server/src/services/memory/scan.ts`
|
- `apps/server/src/services/memory/scan.ts`
|
||||||
- function scanMemoryScopes: (scope) => Promise<MemoryEntry[]>
|
- function scanMemoryScopes: (scope) => Promise<MemoryEntry[]>
|
||||||
- function scanProjectMemory: (projectRoot) => Promise<MemoryEntry[]>
|
- function scanProjectMemory: (projectRoot) => Promise<MemoryEntry[]>
|
||||||
@@ -618,6 +776,11 @@
|
|||||||
- function filterSecretEntries: (entries, pathOf) => void
|
- function filterSecretEntries: (entries, pathOf) => void
|
||||||
- class SecretBlockedError
|
- class SecretBlockedError
|
||||||
- const DEFAULT_SECURITY_IGNORE_FILETYPES: ReadonlyArray<string>
|
- const DEFAULT_SECURITY_IGNORE_FILETYPES: ReadonlyArray<string>
|
||||||
|
- `apps/server/src/services/session-snapshots.ts`
|
||||||
|
- function saveAgentSnapshot: (sql, chatId, data) => Promise<void>
|
||||||
|
- function loadAgentSnapshot: (sql, chatId) => Promise<AgentSnapshot | null>
|
||||||
|
- function deleteAgentSnapshot: (sql, chatId) => Promise<void>
|
||||||
|
- interface AgentSnapshot
|
||||||
- `apps/server/src/services/skill-invoke.ts`
|
- `apps/server/src/services/skill-invoke.ts`
|
||||||
- function runSkillInvokeTransaction: (sql, args) => Promise<
|
- function runSkillInvokeTransaction: (sql, args) => Promise<
|
||||||
- function buildSkillInvokeSyntheticFrames: (chatId, result, toolCall, skillBody) => SkillInvokeSessionFrame[]
|
- function buildSkillInvokeSyntheticFrames: (chatId, result, toolCall, skillBody) => SkillInvokeSessionFrame[]
|
||||||
@@ -648,8 +811,53 @@
|
|||||||
- _...2 more_
|
- _...2 more_
|
||||||
- `apps/server/src/services/task-model.ts` — function taskModelCompletion: (opts) => Promise<string>
|
- `apps/server/src/services/task-model.ts` — function taskModelCompletion: (opts) => Promise<string>
|
||||||
- `apps/server/src/services/task-search-rewrite.ts` — function rewriteSearchQuery: (userMessage) => Promise<string>
|
- `apps/server/src/services/task-search-rewrite.ts` — function rewriteSearchQuery: (userMessage) => Promise<string>
|
||||||
|
- `apps/server/src/services/tool-traces.ts`
|
||||||
|
- function insertToolTrace: (sql, insert) => Promise<ToolTrace>
|
||||||
|
- function updateToolTrace: (sql, id, updates) => Promise<ToolTrace | null>
|
||||||
|
- interface ToolTrace
|
||||||
|
- interface ToolTraceInsert
|
||||||
|
- interface ToolTraceUpdate
|
||||||
|
- `apps/server/src/services/tools/background-subagent-tools.ts`
|
||||||
|
- function executeSpawnSubagent: (input, sql, sessionId) => Promise<Record<string, unknown>>
|
||||||
|
- function executeSubagentStatus: (input, sql) => Promise<Record<string, unknown>>
|
||||||
|
- function executeSubagentResult: (input, sql) => Promise<Record<string, unknown>>
|
||||||
|
- type SpawnSubagentInputT
|
||||||
|
- type SubagentStatusInputT
|
||||||
|
- type SubagentResultInputT
|
||||||
|
- _...6 more_
|
||||||
- `apps/server/src/services/tools/codecontext/factory.ts` — function makeCodecontextTool: (opts, unknown>;
|
- `apps/server/src/services/tools/codecontext/factory.ts` — function makeCodecontextTool: (opts, unknown>;
|
||||||
mapArgs) => void
|
mapArgs) => void
|
||||||
|
- `apps/server/src/services/tools/codecontext/get_code_health.ts`
|
||||||
|
- function executeGetCodeHealth: (input, projectPath) => Promise<string>
|
||||||
|
- type GetCodeHealthInputT
|
||||||
|
- const GetCodeHealthInput
|
||||||
|
- const getCodeHealth: ToolDef<GetCodeHealthInputT>
|
||||||
|
- `apps/server/src/services/tools/codecontext/get_code_impact.ts`
|
||||||
|
- function executeGetCodeImpact: (input, projectPath) => Promise<CodecontextResponse>
|
||||||
|
- type GetCodeImpactInputT
|
||||||
|
- const GetCodeImpactInput
|
||||||
|
- const getCodeImpact: ToolDef<GetCodeImpactInputT>
|
||||||
|
- `apps/server/src/services/tools/codecontext/get_code_map.ts`
|
||||||
|
- function executeGetCodeMap: (input, projectRoot) => Promise<CodeMapResponse>
|
||||||
|
- interface CodeMapResponse
|
||||||
|
- type GetCodeMapInputT
|
||||||
|
- const GetCodeMapInput
|
||||||
|
- const getCodeMap: ToolDef<GetCodeMapInputT>
|
||||||
|
- `apps/server/src/services/tools/codecontext/get_type_info.ts`
|
||||||
|
- function executeGetTypeInfo: (input, _projectPath?) => Promise<CodecontextResponse>
|
||||||
|
- type GetTypeInfoInputT
|
||||||
|
- const GetTypeInfoInput
|
||||||
|
- const getTypeInfo: ToolDef<GetTypeInfoInputT>
|
||||||
|
- `apps/server/src/services/tools/codecontext/get_wiki_article.ts`
|
||||||
|
- function executeGetWikiArticle: (input, projectPath) => Promise<string>
|
||||||
|
- type GetWikiArticleInputT
|
||||||
|
- const GetWikiArticleInput
|
||||||
|
- const getWikiArticle: ToolDef<GetWikiArticleInputT>
|
||||||
|
- `apps/server/src/services/tools/execute-command.ts`
|
||||||
|
- function executeRunCommand: (input, projectRoot) => Promise<RunCommandOutput>
|
||||||
|
- type RunCommandInputT
|
||||||
|
- type RunCommandOutput
|
||||||
|
- const runCommand: ToolDef<RunCommandInputT>
|
||||||
- `apps/server/src/services/tools/registry.ts` — function appendMcpTools: (mcpTools) => void, function toolJsonSchemas: () => ToolJsonSchema[]
|
- `apps/server/src/services/tools/registry.ts` — function appendMcpTools: (mcpTools) => void, function toolJsonSchemas: () => ToolJsonSchema[]
|
||||||
- `apps/server/src/services/tools/tiers.ts`
|
- `apps/server/src/services/tools/tiers.ts`
|
||||||
- function resolveToolTier: (tier) => readonly string[]
|
- function resolveToolTier: (tier) => readonly string[]
|
||||||
@@ -675,6 +883,39 @@
|
|||||||
- interface WebSearchOutput
|
- interface WebSearchOutput
|
||||||
- type WebSearchInputT
|
- type WebSearchInputT
|
||||||
- const webSearch: ToolDef<WebSearchInputT>
|
- const webSearch: ToolDef<WebSearchInputT>
|
||||||
|
- `apps/server/src/services/workflow/catalog.ts`
|
||||||
|
- function fingerprintAgentTask: (prompt, spec, unknown>, args) => string
|
||||||
|
- function getBuiltinWorkflows: () => BuiltinWorkflow[]
|
||||||
|
- function getBuiltinWorkflow: (name) => BuiltinWorkflow | undefined
|
||||||
|
- function mergeBuiltinWorkflows: (fileWorkflows) => Array<
|
||||||
|
- interface BuiltinWorkflow
|
||||||
|
- const meta
|
||||||
|
- `apps/server/src/services/workflow/discovery.ts`
|
||||||
|
- function isBuiltinWorkflow: (meta) => boolean
|
||||||
|
- function discoverWorkflows: (projectRoot) => WorkflowMeta[]
|
||||||
|
- function findWorkflow: (name, projectRoot) => WorkflowMeta | undefined
|
||||||
|
- function isValidWorkflowPath: (filePath) => boolean
|
||||||
|
- interface WorkflowMeta
|
||||||
|
- `apps/server/src/services/workflow/manager.ts`
|
||||||
|
- class WorkflowManager
|
||||||
|
- interface WorkflowMetaInfo
|
||||||
|
- type WorkflowEventHandler
|
||||||
|
- `apps/server/src/services/workflow/resumability.ts`
|
||||||
|
- function cacheKey: (spec, args) => string
|
||||||
|
- function getCachedResult: (key) => CachedResult | null
|
||||||
|
- function setCachedResult: (key, result) => void
|
||||||
|
- function invalidateRun: (runKey) => void
|
||||||
|
- function clearCache: () => void
|
||||||
|
- function cacheSize: () => number
|
||||||
|
- _...1 more_
|
||||||
|
- `apps/server/src/services/workflow/sandbox.ts`
|
||||||
|
- function transformEsmToCjs: (code) => string
|
||||||
|
- function name: (...) => void
|
||||||
|
- function isEsmSyntax: (code) => boolean
|
||||||
|
- function buildSandbox: (context) => Record<string, unknown>
|
||||||
|
- function loadWorkflowScript: (sourceFile, context) => (...args: unknown[]) => Promise<unknown>
|
||||||
|
- function loadWorkflowScriptFromCode: (code, context, filename?) => (...args: unknown[]) => Promise<unknown>
|
||||||
|
- _...3 more_
|
||||||
- `apps/server/src/utils/string-utils.ts` — function stripQuotes: (s) => string
|
- `apps/server/src/utils/string-utils.ts` — function stripQuotes: (s) => string
|
||||||
- `apps/web/src/api/client.ts`
|
- `apps/web/src/api/client.ts`
|
||||||
- class ApiError
|
- class ApiError
|
||||||
@@ -695,7 +936,7 @@
|
|||||||
- interface TerminalSelectionActions
|
- interface TerminalSelectionActions
|
||||||
- interface TerminalSelection
|
- interface TerminalSelection
|
||||||
- `apps/web/src/hooks/terminal/useTerminalSocket.ts`
|
- `apps/web/src/hooks/terminal/useTerminalSocket.ts`
|
||||||
- function useTerminalSocket: ({...}, sessionId, paneId, fit, getSize, setSize, }) => TerminalSocket
|
- function useTerminalSocket: ({...}, sessionId, paneId, description, parentAgent, fit, getSize, setSize, }) => TerminalSocket
|
||||||
- interface TerminalSocket
|
- interface TerminalSocket
|
||||||
- type ConnState
|
- type ConnState
|
||||||
- `apps/web/src/hooks/useActivePane.ts`
|
- `apps/web/src/hooks/useActivePane.ts`
|
||||||
@@ -719,7 +960,8 @@
|
|||||||
- interface ThroughputSample
|
- interface ThroughputSample
|
||||||
- `apps/web/src/hooks/useCoderUserEvents.ts` — function useCoderUserEvents: () => void
|
- `apps/web/src/hooks/useCoderUserEvents.ts` — function useCoderUserEvents: () => void
|
||||||
- `apps/web/src/hooks/useDiffPreferences.ts` — function useDiffPreferences: () => void, interface DiffPreferences
|
- `apps/web/src/hooks/useDiffPreferences.ts` — function useDiffPreferences: () => void, interface DiffPreferences
|
||||||
- `apps/web/src/hooks/useGitDiff.ts` — function useGitDiff: (projectId) => void
|
- `apps/web/src/hooks/useDraftPersistence.ts` — function useDraftPersistence: (chatId) => DraftPersistenceResult, interface DraftPersistenceResult
|
||||||
|
- `apps/web/src/hooks/useGitDiff.ts` — function useGitDiff: (projectId, hideWhitespace) => void
|
||||||
- `apps/web/src/hooks/useLongPress.ts` — function useLongPress: (callback) => void
|
- `apps/web/src/hooks/useLongPress.ts` — function useLongPress: (callback) => void
|
||||||
- `apps/web/src/hooks/useProjectGit.ts` — function useProjectGit: (projectId) => GitMeta | null
|
- `apps/web/src/hooks/useProjectGit.ts` — function useProjectGit: (projectId) => GitMeta | null
|
||||||
- `apps/web/src/hooks/useProviderSnapshot.ts` — function refreshProviderSnapshot: (cwd?) => Promise<ProviderSnapshotEntry[]>, function useProviderSnapshot: (cwd?) => ProviderSnapshotEntry[] | null
|
- `apps/web/src/hooks/useProviderSnapshot.ts` — function refreshProviderSnapshot: (cwd?) => Promise<ProviderSnapshotEntry[]>, function useProviderSnapshot: (cwd?) => ProviderSnapshotEntry[] | null
|
||||||
@@ -732,6 +974,7 @@
|
|||||||
- `apps/web/src/hooks/useSessions.ts` — function useSessions: (projectId) => void
|
- `apps/web/src/hooks/useSessions.ts` — function useSessions: (projectId) => void
|
||||||
- `apps/web/src/hooks/useSidebar.ts` — function useSidebar: () => void
|
- `apps/web/src/hooks/useSidebar.ts` — function useSidebar: () => void
|
||||||
- `apps/web/src/hooks/useSkills.ts` — function useSkills: () => void
|
- `apps/web/src/hooks/useSkills.ts` — function useSkills: () => void
|
||||||
|
- `apps/web/src/hooks/useTerminals.ts` — function useTerminals: () => TerminalRegistration[]
|
||||||
- `apps/web/src/hooks/useUserEvents.ts` — function useUserEvents: () => void
|
- `apps/web/src/hooks/useUserEvents.ts` — function useUserEvents: () => void
|
||||||
- `apps/web/src/hooks/useViewport.ts` — function useViewport: () => ViewportSnapshot, interface ViewportSnapshot
|
- `apps/web/src/hooks/useViewport.ts` — function useViewport: () => ViewportSnapshot, interface ViewportSnapshot
|
||||||
- `apps/web/src/hooks/useWorkspacePanes.ts`
|
- `apps/web/src/hooks/useWorkspacePanes.ts`
|
||||||
@@ -794,7 +1037,16 @@
|
|||||||
- interface ThemeMeta
|
- interface ThemeMeta
|
||||||
- type ThemeId
|
- type ThemeId
|
||||||
- _...5 more_
|
- _...5 more_
|
||||||
|
- `apps/web/src/lib/tool-utils.ts`
|
||||||
|
- function isMcpTool: (name) => boolean
|
||||||
|
- function extractServerName: (name) => string | null
|
||||||
|
- function extractToolName: (name) => string | null
|
||||||
|
- const BUILT_IN_TOOLS
|
||||||
- `apps/web/src/lib/utils.ts` — function cn: (...inputs) => void
|
- `apps/web/src/lib/utils.ts` — function cn: (...inputs) => void
|
||||||
|
- `apps/web/src/stores/useDiffCommentStore.ts`
|
||||||
|
- function useDiffComments: (sessionId, mode) => void
|
||||||
|
- interface DiffComment
|
||||||
|
- interface DiffCommentTarget
|
||||||
- `apps/web/src/utils/diff-layout.ts`
|
- `apps/web/src/utils/diff-layout.ts`
|
||||||
- function parseDiff: (diffBody) => ParsedDiffFile[]
|
- function parseDiff: (diffBody) => ParsedDiffFile[]
|
||||||
- function buildSplitRows: (file) => SplitRow[]
|
- function buildSplitRows: (file) => SplitRow[]
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
- turn-guard — `apps/coder/src/services/backends/turn-guard.ts`
|
- turn-guard — `apps/coder/src/services/backends/turn-guard.ts`
|
||||||
- get_middleware — `apps/server/src/services/tools/codecontext/get_middleware.ts`
|
- get_middleware — `apps/server/src/services/tools/codecontext/get_middleware.ts`
|
||||||
- authoring — `conductor/src/flows/authoring.ts`
|
- authoring — `conductor/src/flows/authoring.ts`
|
||||||
|
- spec — `openspec/changes/add-behavioral-engine/specs/audit-middleware/spec.md`
|
||||||
|
|
||||||
## custom
|
## custom
|
||||||
- write_guard.test — `apps/coder/src/services/__tests__/write_guard.test.ts`
|
- write_guard.test — `apps/coder/src/services/__tests__/write_guard.test.ts`
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
## CRUD Resources
|
## CRUD Resources
|
||||||
|
|
||||||
- **`/api/battles`** GET | POST | GET/:id → Battle
|
- **`/api/battles`** GET | POST | GET/:id → Battle
|
||||||
|
- **`/api/plans`** GET | POST | GET/:id | PATCH/:id → Plan
|
||||||
- **`/api/runs`** GET | POST | GET/:id → Run
|
- **`/api/runs`** GET | POST | GET/:id → Run
|
||||||
- **`/api/tasks`** GET | POST | GET/:id → Task
|
- **`/api/tasks`** GET | POST | GET/:id → Task
|
||||||
- **`/api/chats/:id/messages`** GET | POST | GET/:id | DELETE/:id → Message
|
- **`/api/chats/:id/messages`** GET | POST | GET/:id | DELETE/:id → Message
|
||||||
@@ -14,11 +15,16 @@
|
|||||||
### fastify
|
### fastify
|
||||||
|
|
||||||
- `GET` `/api/term/health` params()
|
- `GET` `/api/term/health` params()
|
||||||
|
- `GET` `/api/term/sessions/:sid/panes/:pid/search` params(sid, pid) [auth]
|
||||||
|
- `GET` `/api/term/sessions` params() [auth]
|
||||||
- `POST` `/api/term/sessions/:sid/panes/:pid/start` params(sid, pid) [auth]
|
- `POST` `/api/term/sessions/:sid/panes/:pid/start` params(sid, pid) [auth]
|
||||||
- `POST` `/api/term/sessions/:sid/panes/:pid/kill` params(sid, pid) [auth]
|
- `POST` `/api/term/sessions/:sid/panes/:pid/kill` params(sid, pid) [auth]
|
||||||
- `GET` `/ws/term/sessions/:sid/panes/:pid` params(sid, pid) [auth]
|
- `GET` `/ws/term/sessions/:sid/panes/:pid` params(sid, pid) [auth]
|
||||||
- `GET` `/api/health` params() [auth, db, queue, ai]
|
- `GET` `/api/health` params() [auth, db, queue, ai]
|
||||||
- `GET` `/api/sessions/:sessionId/agent-sessions` params(sessionId) [auth, db]
|
- `GET` `/api/sessions/:sessionId/agent-sessions` params(sessionId) [auth, db]
|
||||||
|
- `GET` `/api/analytics/summary` params() [auth, db]
|
||||||
|
- `GET` `/api/analytics/sessions` params() [auth, db]
|
||||||
|
- `GET` `/api/analytics/token-breakdown` params() [auth, db]
|
||||||
- `POST` `/api/battles/generate-prompt` params() [auth, db]
|
- `POST` `/api/battles/generate-prompt` params() [auth, db]
|
||||||
- `POST` `/api/battles/:id/stop` params(id) [auth, db]
|
- `POST` `/api/battles/:id/stop` params(id) [auth, db]
|
||||||
- `GET` `/api/battles/:id/analysis` params(id) [auth, db]
|
- `GET` `/api/battles/:id/analysis` params(id) [auth, db]
|
||||||
@@ -42,6 +48,7 @@
|
|||||||
- `POST` `/api/pending/:id/apply` params(id) [auth, db, queue]
|
- `POST` `/api/pending/:id/apply` params(id) [auth, db, queue]
|
||||||
- `POST` `/api/pending/:id/reject` params(id) [auth, db, queue]
|
- `POST` `/api/pending/:id/reject` params(id) [auth, db, queue]
|
||||||
- `POST` `/api/pending/:id/rewind` params(id) [auth, db, queue]
|
- `POST` `/api/pending/:id/rewind` params(id) [auth, db, queue]
|
||||||
|
- `GET` `/api/plans/active` params() [db]
|
||||||
- `GET` `/api/providers/snapshot` params() [db, cache]
|
- `GET` `/api/providers/snapshot` params() [db, cache]
|
||||||
- `GET` `/api/providers/config` params() [db, cache]
|
- `GET` `/api/providers/config` params() [db, cache]
|
||||||
- `PATCH` `/api/providers/config` params() [db, cache]
|
- `PATCH` `/api/providers/config` params() [db, cache]
|
||||||
@@ -59,19 +66,22 @@
|
|||||||
- `GET` `/api/ws/sessions/:sessionId` params(sessionId) [auth, db]
|
- `GET` `/api/ws/sessions/:sessionId` params(sessionId) [auth, db]
|
||||||
- `GET` `/api/ws/user` params() [auth, db]
|
- `GET` `/api/ws/user` params() [auth, db]
|
||||||
- `GET` `/api/projects/:id/agents` params(id) [db, cache]
|
- `GET` `/api/projects/:id/agents` params(id) [db, cache]
|
||||||
|
- `GET` `/api/analytics/context` params() [auth, db]
|
||||||
- `POST` `/api/chats/:id/messages/:msg_id/artifacts/download` params(id, msg_id) [auth, db]
|
- `POST` `/api/chats/:id/messages/:msg_id/artifacts/download` params(id, msg_id) [auth, db]
|
||||||
- `GET` `/api/chats/:id/messages/:msg_id/html_artifact` params(id, msg_id) [auth, db]
|
- `GET` `/api/chats/:id/messages/:msg_id/html_artifact` params(id, msg_id) [auth, db]
|
||||||
- `GET` `/api/projects/:project_id/artifacts/:filename` params(project_id, filename) [auth, db]
|
- `GET` `/api/projects/:project_id/artifacts/:filename` params(project_id, filename) [auth, db]
|
||||||
- `GET` `/api/sessions/:id/chats` params(id) [auth, db]
|
- `GET` `/api/sessions/:id/chats` params(id) [auth, db, queue]
|
||||||
- `POST` `/api/sessions/:id/chats` params(id) [auth, db]
|
- `POST` `/api/sessions/:id/chats` params(id) [auth, db, queue]
|
||||||
- `PATCH` `/api/chats/:id` params(id) [auth, db]
|
- `PATCH` `/api/chats/:id` params(id) [auth, db, queue]
|
||||||
- `POST` `/api/sessions/:id/chats/archive-all` params(id) [auth, db]
|
- `POST` `/api/sessions/:id/chats/archive-all` params(id) [auth, db, queue]
|
||||||
- `GET` `/api/sessions/:id/chats/open-count` params(id) [auth, db]
|
- `GET` `/api/sessions/:id/chats/open-count` params(id) [auth, db, queue]
|
||||||
- `POST` `/api/chats/:id/archive` params(id) [auth, db]
|
- `POST` `/api/chats/:id/archive` params(id) [auth, db, queue]
|
||||||
- `POST` `/api/chats/:id/unarchive` params(id) [auth, db]
|
- `POST` `/api/chats/:id/unarchive` params(id) [auth, db, queue]
|
||||||
- `DELETE` `/api/chats/:id` params(id) [auth, db]
|
- `DELETE` `/api/chats/:id` params(id) [auth, db, queue]
|
||||||
- `POST` `/api/chats/:id/fork` params(id) [auth, db]
|
- `POST` `/api/chats/:id/fork` params(id) [auth, db, queue]
|
||||||
- `POST` `/api/chats/:id/discard_stale` params(id) [auth, db]
|
- `POST` `/api/chats/:id/discard_stale` params(id) [auth, db, queue]
|
||||||
|
- `GET` `/api/chats/:id/export` params(id) [auth, db, queue]
|
||||||
|
- `POST` `/api/chats/:id/compare` params(id) [auth, db, queue]
|
||||||
- `GET` `/api/coder/ws/sessions/:sessionId` params(sessionId) [auth]
|
- `GET` `/api/coder/ws/sessions/:sessionId` params(sessionId) [auth]
|
||||||
- `ALL` `/api/coder/*` params() [auth]
|
- `ALL` `/api/coder/*` params() [auth]
|
||||||
- `GET` `/api/settings/inference` params() [cache]
|
- `GET` `/api/settings/inference` params() [cache]
|
||||||
@@ -83,7 +93,9 @@
|
|||||||
- `POST` `/api/chats/:id/continue` params(id) [auth, db, queue]
|
- `POST` `/api/chats/:id/continue` params(id) [auth, db, queue]
|
||||||
- `POST` `/api/chats/:id/force_send` params(id) [auth, db, queue]
|
- `POST` `/api/chats/:id/force_send` params(id) [auth, db, queue]
|
||||||
- `POST` `/api/chats/:id/grant_read_access` params(id) [auth, db, queue]
|
- `POST` `/api/chats/:id/grant_read_access` params(id) [auth, db, queue]
|
||||||
- `GET` `/api/models` params()
|
- `POST` `/api/chats/:id/mcp-approve` params(id) [auth, db, queue]
|
||||||
|
- `POST` `/api/chats/:id/messages/:message_id/feedback` params(id, message_id) [auth, db, queue]
|
||||||
|
- `GET` `/api/models` params() [auth]
|
||||||
- `POST` `/api/projects/create` params() [auth, db]
|
- `POST` `/api/projects/create` params() [auth, db]
|
||||||
- `POST` `/api/projects/:id/archive` params(id) [auth, db]
|
- `POST` `/api/projects/:id/archive` params(id) [auth, db]
|
||||||
- `POST` `/api/projects/:id/unarchive` params(id) [auth, db]
|
- `POST` `/api/projects/:id/unarchive` params(id) [auth, db]
|
||||||
@@ -111,6 +123,7 @@
|
|||||||
- `GET` `/api/skills` params() [auth, db, queue]
|
- `GET` `/api/skills` params() [auth, db, queue]
|
||||||
- `POST` `/api/chats/:id/skill_invoke` params(id) [auth, db, queue]
|
- `POST` `/api/chats/:id/skill_invoke` params(id) [auth, db, queue]
|
||||||
- `GET` `/api/tools/cost_stats` params() [auth, db]
|
- `GET` `/api/tools/cost_stats` params() [auth, db]
|
||||||
|
- `GET` `/api/chats/:id/traces` params(id) [db]
|
||||||
- `GET` `/api/ws/sessions/:id` params(id) [auth, db]
|
- `GET` `/api/ws/sessions/:id` params(id) [auth, db]
|
||||||
|
|
||||||
### go-net-http
|
### go-net-http
|
||||||
|
|||||||
@@ -118,6 +118,25 @@
|
|||||||
- model: text (required)
|
- model: text (required)
|
||||||
- verdict: text
|
- verdict: text
|
||||||
|
|
||||||
|
### flow_step_events
|
||||||
|
- id: uuid (pk)
|
||||||
|
- run_id: uuid (required, fk)
|
||||||
|
- step_id: varchar (required, fk)
|
||||||
|
- event: varchar (required)
|
||||||
|
- payload: jsonb
|
||||||
|
|
||||||
|
### plans
|
||||||
|
- id: uuid (pk)
|
||||||
|
- project_id: uuid (required, fk)
|
||||||
|
- title: text (required)
|
||||||
|
- description: text
|
||||||
|
- status: text (required)
|
||||||
|
- flow_run_id: uuid (fk)
|
||||||
|
- progress_pct: integer (required)
|
||||||
|
- items_total: integer (required)
|
||||||
|
- items_completed: integer (required)
|
||||||
|
- metadata: jsonb
|
||||||
|
|
||||||
### projects
|
### projects
|
||||||
- id: uuid (pk)
|
- id: uuid (pk)
|
||||||
- name: text (required)
|
- name: text (required)
|
||||||
@@ -139,6 +158,8 @@
|
|||||||
- content: text (required)
|
- content: text (required)
|
||||||
- status: text (required)
|
- status: text (required)
|
||||||
- last_seq: integer (required)
|
- last_seq: integer (required)
|
||||||
|
- cache_tokens: integer
|
||||||
|
- reasoning_tokens: integer
|
||||||
|
|
||||||
### message_parts
|
### message_parts
|
||||||
- id: uuid (pk)
|
- id: uuid (pk)
|
||||||
@@ -155,3 +176,42 @@
|
|||||||
- session_id: uuid (required, fk)
|
- session_id: uuid (required, fk)
|
||||||
- name: text
|
- name: text
|
||||||
- status: text (required)
|
- status: text (required)
|
||||||
|
|
||||||
|
### tool_traces
|
||||||
|
- id: uuid (pk)
|
||||||
|
- session_id: uuid (required, fk)
|
||||||
|
- chat_id: uuid (required, fk)
|
||||||
|
- message_id: uuid (fk)
|
||||||
|
- turn_number: integer (required)
|
||||||
|
- tool_name: text (required)
|
||||||
|
- tool_input: jsonb (required)
|
||||||
|
- tool_output: text
|
||||||
|
- started_at: timestamp(tz) (required)
|
||||||
|
- finished_at: timestamp(tz)
|
||||||
|
- latency_ms: integer
|
||||||
|
- tokens_used: integer
|
||||||
|
- cache_tokens: integer
|
||||||
|
- reasoning_tokens: integer
|
||||||
|
- error: text
|
||||||
|
- outcome: text
|
||||||
|
|
||||||
|
### tool_trace_states
|
||||||
|
- id: uuid (pk)
|
||||||
|
- session_id: uuid (required, fk)
|
||||||
|
- chat_id: uuid (required, fk)
|
||||||
|
- message_id: uuid (fk)
|
||||||
|
- turn_number: integer (required)
|
||||||
|
- tool_name: text (required)
|
||||||
|
- tool_input: jsonb (required)
|
||||||
|
- started_at: timestamp(tz) (required)
|
||||||
|
|
||||||
|
### agent_snapshots
|
||||||
|
- id: uuid (pk)
|
||||||
|
- session_id: uuid (required, fk)
|
||||||
|
- chat_id: uuid (required, fk)
|
||||||
|
- model: text (required)
|
||||||
|
- agent: text
|
||||||
|
- mode: text
|
||||||
|
- turn_number: integer (required)
|
||||||
|
- messages: jsonb (required)
|
||||||
|
- tool_states: jsonb (required)
|
||||||
|
|||||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -21,3 +21,13 @@ data/*
|
|||||||
!data/coder-providers.example.json
|
!data/coder-providers.example.json
|
||||||
codecontext/fork.tar.gz
|
codecontext/fork.tar.gz
|
||||||
/Arena
|
/Arena
|
||||||
|
|
||||||
|
# Auto-generated & scratch artifacts
|
||||||
|
.impeccable/
|
||||||
|
.omo/
|
||||||
|
bun.lock
|
||||||
|
DESIGN.md
|
||||||
|
PRODUCT.md
|
||||||
|
|
||||||
|
# codesight auto-generated analysis cache
|
||||||
|
apps/web/.codesight/
|
||||||
|
|||||||
10
BOOCHAT.md
10
BOOCHAT.md
@@ -1,4 +1,4 @@
|
|||||||
# BooChat
|
# BooChat — v2.7.17 (2026-06-08)
|
||||||
|
|
||||||
## Capabilities
|
## Capabilities
|
||||||
|
|
||||||
@@ -9,6 +9,9 @@
|
|||||||
- `ask_user_input` (interactive option chips)
|
- `ask_user_input` (interactive option chips)
|
||||||
- Opt-in per chat: `web_search`, `web_fetch` (SearXNG-backed, SSRF-guarded)
|
- Opt-in per chat: `web_search`, `web_fetch` (SearXNG-backed, SSRF-guarded)
|
||||||
|
|
||||||
|
## Guidance resolution order
|
||||||
|
When multiple sources conflict: inline file guidance (this file) → per-session `system_prompt` → agent definition → model default. Last wins on samplers, first wins on refusals.
|
||||||
|
|
||||||
## You cannot
|
## You cannot
|
||||||
|
|
||||||
- Write, edit, or delete files
|
- Write, edit, or delete files
|
||||||
@@ -44,6 +47,11 @@
|
|||||||
|
|
||||||
Always-true rules (process discipline, refusals, behavior contracts) live here in `BOOCHAT.md` — and in `BOOCODER.md` / `CLAUDE.md` per their scopes — where they are 100% present in every turn. On-demand recipes (specific procedures, scaffolds, checklists) live in `/data/skills/` and invoke roughly 6% of the time in clean multi-turn flow (Codeminer42 measurement, 2026). Don't file workflow rules as skills — they silently misfire. See Anthropic agent-skills best-practices (platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices) for the canonical conventions.
|
Always-true rules (process discipline, refusals, behavior contracts) live here in `BOOCHAT.md` — and in `BOOCODER.md` / `CLAUDE.md` per their scopes — where they are 100% present in every turn. On-demand recipes (specific procedures, scaffolds, checklists) live in `/data/skills/` and invoke roughly 6% of the time in clean multi-turn flow (Codeminer42 measurement, 2026). Don't file workflow rules as skills — they silently misfire. See Anthropic agent-skills best-practices (platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices) for the canonical conventions.
|
||||||
|
|
||||||
|
## Cross-file invariants
|
||||||
|
|
||||||
|
- **Tool capability lists**: `BOOCHAT.md:5-10` (read-only tools) must stay in sync with `apps/server/src/services/tools/registry.ts` `ALL_TOOLS`. If a tool is added to the registry but not listed here, models won't know to reach for it.
|
||||||
|
- **Capability refusals**: `BOOCHAT.md:12-17` ("You cannot") mirrors the path/secret/url guards in `apps/server/src/services/{path_guard,secret_guard,url_guard}.ts`. Adding a new guard type should update this refusal list.
|
||||||
|
|
||||||
## Verification discipline
|
## Verification discipline
|
||||||
|
|
||||||
- When assessing implementation status, verify against the running container (`curl /api/health`) and latest git commit (`git log --oneline -3`), not just source file contents. Source files can be mid-edit. The deployed state is the truth.
|
- When assessing implementation status, verify against the running container (`curl /api/health`) and latest git commit (`git log --oneline -3`), not just source file contents. Source files can be mid-edit. The deployed state is the truth.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# BooCoder — Container Guidance
|
# BooCoder — Container Guidance — v2.7.x (last meaningful update: 2026-06)
|
||||||
|
|
||||||
You are BooCoder, a write-capable coding agent. You can read AND modify files within the project scope.
|
You are BooCoder, a write-capable coding agent. You can read AND modify files within the project scope.
|
||||||
|
|
||||||
@@ -19,6 +19,10 @@ You are BooCoder, a write-capable coding agent. You can read AND modify files wi
|
|||||||
- Push to git remotes
|
- Push to git remotes
|
||||||
- Access the internet except via configured MCP servers
|
- Access the internet except via configured MCP servers
|
||||||
|
|
||||||
|
## Tool reliability
|
||||||
|
- `edit_file`'s fuzzy match can **succeed on a near-miss** or **return ambiguous** when `old_string` matches multiple locations. Always verify the queued diff before calling `apply_pending` — the diff preview is authoritative, the tool's "success" return is not.
|
||||||
|
- The external agent's worktree diff only shows changes since the **last turn**, not since the project baseline. The DiffPanel merges these, but if you call `git diff` directly, you'll get incomplete results.
|
||||||
|
|
||||||
## Pending changes discipline
|
## Pending changes discipline
|
||||||
|
|
||||||
Every file modification queues in `pending_changes` before touching disk. The user sees a diff preview and approves/rejects each change. Never bypass this queue — it is the safety boundary between inference and the filesystem.
|
Every file modification queues in `pending_changes` before touching disk. The user sees a diff preview and approves/rejects each change. Never bypass this queue — it is the safety boundary between inference and the filesystem.
|
||||||
|
|||||||
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
|
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
|
||||||
|
|
||||||
|
## v2.8.20-paseo-orchestrator-ph3-5 — 2026-06-08
|
||||||
|
|
||||||
|
Completes the Paseo-like Orchestrator with phases 3–5. Phase 3 ships a Dynamic Workflow Engine built on Node's `vm` sandbox — Claude Code compatible JavaScript workflows with `agent()`, `parallel()`, `pipeline()`, `phase()`, and `budget()` primitives. Includes a built-in workflow catalog (`deep-research`, `review-code`, `find-issues`) with SHA-256 hash-based resumability cache that skips completed steps on re-run. Phase 4 adds background subagents — `spawn_subagent` returns immediately, `subagent_status` and `subagent_result` tools let the model poll and collect results. Phase 5 adds a cache shape telemetry badge to the trace viewer (colored bar + hit rate percentage) and a multi-modal attachment stub. Also ships inline diff snippets in the chat stream after write tool calls, and the `run_command` tool with auto-fix loop that detects build failures after edits and injects errors for self-correction.
|
||||||
|
|
||||||
|
## v2.8.19-paseo-orchestrator-ph1-2 — 2026-06-08
|
||||||
|
|
||||||
|
Ships the trace system and session persistence backbone. Every tool call is now timed via `tool_traces` DB table with latency, token counts, cache/reasoning breakdowns, and WS frames streamed live to a new trace viewer pane. Agent sessions survive browser refresh — `agent_snapshots` table persists state on turn boundaries and restores on WebSocket reconnect. A session timeline view shows agent turn history with scroll-to and restore. New frontend components: `TraceViewer` (collapsible panel with timing bars) and `SessionTimeline` (vertical timeline).
|
||||||
|
|
||||||
## v2.8.18-deepseek-whale-lift — 2026-06-08
|
## v2.8.18-deepseek-whale-lift — 2026-06-08
|
||||||
|
|
||||||
Integrates DeepSeek API directly into BooChat and BooCoder via `@ai-sdk/deepseek`, replacing the generic `openai-compatible` wrapper. DeepSeek V4 models (`deepseek-v4-flash`, `deepseek-v4-pro`) with configurable thinking effort levels appear in both chat and coder pane model pickers. Full token tracking — cache hit tokens and reasoning tokens — flow from the API through new DB columns and WS frames into the UI message stats line. Lifts three high-value features from the Whale codebase: a schema-based tool input repair system that coerces types and unwraps markdown autolinks before Zod validation, a shell-based lifecycle hooks system (PreToolUse, PostToolUse, Stop, PreCompact, PostCompact) with JSON stdin/stdout contract, and per-MCP-server permissions (allow/ask/deny) gating tool execution.
|
Integrates DeepSeek API directly into BooChat and BooCoder via `@ai-sdk/deepseek`, replacing the generic `openai-compatible` wrapper. DeepSeek V4 models (`deepseek-v4-flash`, `deepseek-v4-pro`) with configurable thinking effort levels appear in both chat and coder pane model pickers. Full token tracking — cache hit tokens and reasoning tokens — flow from the API through new DB columns and WS frames into the UI message stats line. Lifts three high-value features from the Whale codebase: a schema-based tool input repair system that coerces types and unwraps markdown autolinks before Zod validation, a shell-based lifecycle hooks system (PreToolUse, PostToolUse, Stop, PreCompact, PostCompact) with JSON stdin/stdout contract, and per-MCP-server permissions (allow/ask/deny) gating tool execution.
|
||||||
|
|||||||
11
CLAUDE.md
11
CLAUDE.md
@@ -1,5 +1,13 @@
|
|||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
|
|
||||||
|
<!-- Last meaningful update: 2026-06-08 (v2.8.20-paseo-orchestrator-ph3-5) -->
|
||||||
|
|
||||||
|
## You cannot
|
||||||
|
- Write, edit, or delete files (BooChat only — use BooCoder for writes)
|
||||||
|
- Run shell commands (use booterm terminal panes)
|
||||||
|
- Make commits, push, or pull (Sam reviews and commits manually)
|
||||||
|
- `git add -A` (stage only files you changed)
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
**Cursor agents:** start with `docs/ARCHITECTURE.md` (diagram); this file is the deep engineering reference. `data/AGENTS.md` is the agent *registry*, not navigation (the root navigation `AGENTS.md` was removed).
|
**Cursor agents:** start with `docs/ARCHITECTURE.md` (diagram); this file is the deep engineering reference. `data/AGENTS.md` is the agent *registry*, not navigation (the root navigation `AGENTS.md` was removed).
|
||||||
@@ -51,6 +59,9 @@ Detailed engineering notes live in per-app `CLAUDE.md` files, **auto-loaded when
|
|||||||
|
|
||||||
Cross-app contracts (WS-frame & provider-type parity, sentinels) and everything below stay here.
|
Cross-app contracts (WS-frame & provider-type parity, sentinels) and everything below stay here.
|
||||||
|
|
||||||
|
### Guidance resolution order
|
||||||
|
When multiple sources conflict: `CLAUDE.md` (repo root) → `BOOCHAT.md` / `BOOCODER.md` (per-surface) → per-app `CLAUDE.md` (auto-loaded by file context) → `data/AGENTS.md` (agent preamble beats per-agent body) → session `system_prompt` → user prompt. Last-encountered wins on samplers; refusals cascade downward (you cannot do what any layer forbids).
|
||||||
|
|
||||||
### Data flow for chat
|
### Data flow for chat
|
||||||
|
|
||||||
1. User sends message → POST `/api/sessions/:id/messages` creates user + assistant (status=streaming) rows
|
1. User sends message → POST `/api/sessions/:id/messages` creates user + assistant (status=streaming) rows
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ const ConfigSchema = z.object({
|
|||||||
DATABASE_URL: z.string().url(),
|
DATABASE_URL: z.string().url(),
|
||||||
LOG_LEVEL: z.string().default('info'),
|
LOG_LEVEL: z.string().default('info'),
|
||||||
TMUX_CONF_PATH: z.string().default('/etc/booterm/tmux.conf'),
|
TMUX_CONF_PATH: z.string().default('/etc/booterm/tmux.conf'),
|
||||||
|
PTY_IDLE_TIMEOUT_SECONDS: z.coerce.number().int().min(0).default(0),
|
||||||
|
PTY_ABSOLUTE_TIMEOUT_SECONDS: z.coerce.number().int().min(0).default(0),
|
||||||
});
|
});
|
||||||
|
|
||||||
type Config = z.infer<typeof ConfigSchema>;
|
type Config = z.infer<typeof ConfigSchema>;
|
||||||
|
|||||||
@@ -14,12 +14,13 @@ interface SessionInfo {
|
|||||||
id: string;
|
id: string;
|
||||||
project_id: string;
|
project_id: string;
|
||||||
project_path: string;
|
project_path: string;
|
||||||
|
name: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSessionInfo(sessionId: string): Promise<SessionInfo | null> {
|
export async function getSessionInfo(sessionId: string): Promise<SessionInfo | null> {
|
||||||
if (!pool) throw new Error('db pool not initialized');
|
if (!pool) throw new Error('db pool not initialized');
|
||||||
const res = await pool.query<SessionInfo>(
|
const res = await pool.query<SessionInfo>(
|
||||||
`SELECT s.id, s.project_id, p.path AS project_path
|
`SELECT s.id, s.project_id, p.path AS project_path, s.name
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN projects p ON p.id = s.project_id
|
JOIN projects p ON p.id = s.project_id
|
||||||
WHERE s.id = $1`,
|
WHERE s.id = $1`,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import type { FastifyBaseLogger } from 'fastify';
|
import type { FastifyBaseLogger } from 'fastify';
|
||||||
|
import * as registry from './registry.js';
|
||||||
|
|
||||||
const ID_RE = /^[a-zA-Z0-9_-]{1,64}$/;
|
const ID_RE = /^[a-zA-Z0-9_-]{1,64}$/;
|
||||||
|
|
||||||
@@ -162,3 +163,36 @@ export async function capturePane(
|
|||||||
if (res.code !== 0) return '';
|
if (res.code !== 0) return '';
|
||||||
return res.stdout.replace(/(?:\r?\n)+$/, '');
|
return res.stdout.replace(/(?:\r?\n)+$/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sweep the registry for expired sessions and kill the underlying tmux sessions.
|
||||||
|
* Logs each kill with the expiry reason (idle timeout vs absolute timeout).
|
||||||
|
* Returns the list of paneIds that were killed.
|
||||||
|
*/
|
||||||
|
export async function sweepExpired(
|
||||||
|
tmuxConfPath: string,
|
||||||
|
log: FastifyBaseLogger,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const expired = registry.getTimedOutSessions();
|
||||||
|
const killed: string[] = [];
|
||||||
|
for (const meta of expired) {
|
||||||
|
const reason =
|
||||||
|
meta.idleExpiresAt &&
|
||||||
|
(!meta.absoluteExpiresAt || meta.idleExpiresAt.getTime() <= meta.absoluteExpiresAt.getTime())
|
||||||
|
? 'idle timeout'
|
||||||
|
: 'absolute timeout';
|
||||||
|
log.info({ paneId: meta.paneId, reason }, 'sweeping expired PTY session');
|
||||||
|
const sessionName = tmuxSessionName(meta.paneId);
|
||||||
|
try {
|
||||||
|
const ok = await killSession(tmuxConfPath, sessionName);
|
||||||
|
if (!ok) {
|
||||||
|
log.warn({ paneId: meta.paneId, sessionName }, 'killSession returned false during sweep');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.warn({ paneId: meta.paneId, err }, 'killSession threw during sweep');
|
||||||
|
}
|
||||||
|
registry.unregister(meta.paneId);
|
||||||
|
killed.push(meta.paneId);
|
||||||
|
}
|
||||||
|
return killed;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,17 +3,30 @@ export interface SessionMeta {
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
parentAgent?: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
lastActivityAt: Date;
|
lastActivityAt: Date;
|
||||||
|
timeoutSeconds?: number;
|
||||||
|
idleExpiresAt?: Date;
|
||||||
|
absoluteExpiresAt?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessions = new Map<string, SessionMeta>();
|
const sessions = new Map<string, SessionMeta>();
|
||||||
|
|
||||||
|
export interface RegisterOpts {
|
||||||
|
timeoutSeconds?: number;
|
||||||
|
absoluteTimeoutSeconds?: number;
|
||||||
|
description?: string;
|
||||||
|
parentAgent?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function register(
|
export function register(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
paneId: string,
|
paneId: string,
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
title?: string,
|
title?: string,
|
||||||
|
opts?: RegisterOpts,
|
||||||
): void {
|
): void {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const existing = sessions.get(paneId);
|
const existing = sessions.get(paneId);
|
||||||
@@ -21,13 +34,24 @@ export function register(
|
|||||||
existing.lastActivityAt = now;
|
existing.lastActivityAt = now;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const idleExpiresAt = opts?.timeoutSeconds && opts.timeoutSeconds > 0
|
||||||
|
? new Date(now.getTime() + opts.timeoutSeconds * 1000)
|
||||||
|
: undefined;
|
||||||
|
const absoluteExpiresAt = opts?.absoluteTimeoutSeconds && opts.absoluteTimeoutSeconds > 0
|
||||||
|
? new Date(now.getTime() + opts.absoluteTimeoutSeconds * 1000)
|
||||||
|
: undefined;
|
||||||
sessions.set(paneId, {
|
sessions.set(paneId, {
|
||||||
paneId,
|
paneId,
|
||||||
sessionId,
|
sessionId,
|
||||||
projectPath,
|
projectPath,
|
||||||
title,
|
title,
|
||||||
|
description: opts?.description,
|
||||||
|
parentAgent: opts?.parentAgent,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
lastActivityAt: now,
|
lastActivityAt: now,
|
||||||
|
timeoutSeconds: opts?.timeoutSeconds,
|
||||||
|
idleExpiresAt,
|
||||||
|
absoluteExpiresAt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +60,18 @@ export function unregister(paneId: string): void {
|
|||||||
ringBuffers.delete(paneId);
|
ringBuffers.delete(paneId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bump the lastActivityAt timestamp for a pane.
|
||||||
|
* Called on every PTY data write so the idle-timeout sweep knows when a session
|
||||||
|
* was last active.
|
||||||
|
*/
|
||||||
|
export function touchActivity(paneId: string): void {
|
||||||
|
const meta = sessions.get(paneId);
|
||||||
|
if (meta) {
|
||||||
|
meta.lastActivityAt = new Date();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function list(): SessionMeta[] {
|
export function list(): SessionMeta[] {
|
||||||
return Array.from(sessions.values());
|
return Array.from(sessions.values());
|
||||||
}
|
}
|
||||||
@@ -44,6 +80,30 @@ export function get(paneId: string): SessionMeta | undefined {
|
|||||||
return sessions.get(paneId);
|
return sessions.get(paneId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Pending metadata (POST /start → WS attach handoff) ──────────────────────
|
||||||
|
//
|
||||||
|
// The POST /start route stores optional description/parentAgent here; the WS
|
||||||
|
// attach handler consumes it when calling register(). This avoids coupling the
|
||||||
|
// HTTP route to the WS lifecycle while keeping the handoff single-process and
|
||||||
|
// ephemeral (no DB writes).
|
||||||
|
|
||||||
|
const pendingMetadata = new Map<string, { description?: string; parentAgent?: string }>();
|
||||||
|
|
||||||
|
export function setPendingMetadata(
|
||||||
|
paneId: string,
|
||||||
|
meta: { description?: string; parentAgent?: string },
|
||||||
|
): void {
|
||||||
|
pendingMetadata.set(paneId, meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function consumePendingMetadata(
|
||||||
|
paneId: string,
|
||||||
|
): { description?: string; parentAgent?: string } | undefined {
|
||||||
|
const meta = pendingMetadata.get(paneId);
|
||||||
|
if (meta) pendingMetadata.delete(paneId);
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Ring buffer for PTY output search ──────────────────────────────────────
|
// ── Ring buffer for PTY output search ──────────────────────────────────────
|
||||||
|
|
||||||
export interface SearchMatch {
|
export interface SearchMatch {
|
||||||
@@ -160,3 +220,21 @@ export function searchRingBuffer(
|
|||||||
export function clearBuffer(paneId: string): void {
|
export function clearBuffer(paneId: string): void {
|
||||||
ringBuffers.delete(paneId);
|
ringBuffers.delete(paneId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return all sessions whose idle-expiry or absolute-expiry has passed.
|
||||||
|
* A session with no timeout configured is never included.
|
||||||
|
* Called by the sweepExpired interval in manager.ts.
|
||||||
|
*/
|
||||||
|
export function getTimedOutSessions(): SessionMeta[] {
|
||||||
|
const now = Date.now();
|
||||||
|
const result: SessionMeta[] = [];
|
||||||
|
for (const meta of sessions.values()) {
|
||||||
|
const idleHit = meta.idleExpiresAt && now >= meta.idleExpiresAt.getTime();
|
||||||
|
const absoluteHit = meta.absoluteExpiresAt && now >= meta.absoluteExpiresAt.getTime();
|
||||||
|
if (idleHit || absoluteHit) {
|
||||||
|
result.push(meta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export function registerSessionRoutes(app: FastifyInstance): void {
|
|||||||
sessionId: s.sessionId,
|
sessionId: s.sessionId,
|
||||||
projectPath: s.projectPath,
|
projectPath: s.projectPath,
|
||||||
title: s.title ?? null,
|
title: s.title ?? null,
|
||||||
|
description: s.description ?? null,
|
||||||
|
parentAgent: s.parentAgent ?? null,
|
||||||
createdAt: s.createdAt.toISOString(),
|
createdAt: s.createdAt.toISOString(),
|
||||||
lastActivityAt: s.lastActivityAt.toISOString(),
|
lastActivityAt: s.lastActivityAt.toISOString(),
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
killSession,
|
killSession,
|
||||||
hasSession,
|
hasSession,
|
||||||
} from '../pty/manager.js';
|
} from '../pty/manager.js';
|
||||||
|
import { setPendingMetadata } from '../pty/registry.js';
|
||||||
|
|
||||||
const ParamsSchema = z.object({ sid: z.string(), pid: z.string() });
|
const ParamsSchema = z.object({ sid: z.string(), pid: z.string() });
|
||||||
// v1.10.8c: optional cols/rows on /start so the per-pane tmux session is
|
// v1.10.8c: optional cols/rows on /start so the per-pane tmux session is
|
||||||
@@ -17,6 +18,8 @@ const StartBodySchema = z
|
|||||||
.object({
|
.object({
|
||||||
cols: z.coerce.number().int().min(1).max(2000).optional(),
|
cols: z.coerce.number().int().min(1).max(2000).optional(),
|
||||||
rows: z.coerce.number().int().min(1).max(2000).optional(),
|
rows: z.coerce.number().int().min(1).max(2000).optional(),
|
||||||
|
description: z.string().max(500).optional(),
|
||||||
|
parentAgent: z.string().max(100).optional(),
|
||||||
})
|
})
|
||||||
.partial()
|
.partial()
|
||||||
.optional();
|
.optional();
|
||||||
@@ -29,7 +32,7 @@ export function registerTerminalRoutes(app: FastifyInstance, tmuxConfPath: strin
|
|||||||
// errors as HTTP responses (vs WS 1011 close codes).
|
// errors as HTTP responses (vs WS 1011 close codes).
|
||||||
app.post<{
|
app.post<{
|
||||||
Params: { sid: string; pid: string };
|
Params: { sid: string; pid: string };
|
||||||
Body: { cols?: number; rows?: number } | undefined;
|
Body: { cols?: number; rows?: number; description?: string; parentAgent?: string } | undefined;
|
||||||
}>(
|
}>(
|
||||||
'/api/term/sessions/:sid/panes/:pid/start',
|
'/api/term/sessions/:sid/panes/:pid/start',
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
@@ -43,6 +46,14 @@ export function registerTerminalRoutes(app: FastifyInstance, tmuxConfPath: strin
|
|||||||
const cols = b.success ? b.data?.cols : undefined;
|
const cols = b.success ? b.data?.cols : undefined;
|
||||||
const rows = b.success ? b.data?.rows : undefined;
|
const rows = b.success ? b.data?.rows : undefined;
|
||||||
|
|
||||||
|
// Store optional metadata for the WS attach handler to consume
|
||||||
|
if (b.success && b.data) {
|
||||||
|
const { description, parentAgent } = b.data;
|
||||||
|
if (description || parentAgent) {
|
||||||
|
setPendingMetadata(pid, { description, parentAgent });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const session = await getSessionInfo(sid);
|
const session = await getSessionInfo(sid);
|
||||||
if (!session) return reply.code(404).send({ error: 'unknown_session' });
|
if (!session) return reply.code(404).send({ error: 'unknown_session' });
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,14 @@ import {
|
|||||||
} from '../pty/manager.js';
|
} from '../pty/manager.js';
|
||||||
import { attachPty } from '../pty/pty.js';
|
import { attachPty } from '../pty/pty.js';
|
||||||
import { getUser } from '../auth.js';
|
import { getUser } from '../auth.js';
|
||||||
import { register, unregister, appendOutput } from '../pty/registry.js';
|
import { register, unregister, appendOutput, touchActivity, consumePendingMetadata } from '../pty/registry.js';
|
||||||
|
|
||||||
export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string): void {
|
export function registerWsAttachRoute(
|
||||||
|
app: FastifyInstance,
|
||||||
|
tmuxConfPath: string,
|
||||||
|
idleTimeoutSeconds?: number,
|
||||||
|
absoluteTimeoutSeconds?: number,
|
||||||
|
): void {
|
||||||
app.get<{
|
app.get<{
|
||||||
Params: { sid: string; pid: string };
|
Params: { sid: string; pid: string };
|
||||||
Querystring: { cols?: string; rows?: string };
|
Querystring: { cols?: string; rows?: string };
|
||||||
@@ -58,7 +63,25 @@ export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
register(sid, pid, session.project_path);
|
const pendingMeta = consumePendingMetadata(pid);
|
||||||
|
const regOpts: {
|
||||||
|
timeoutSeconds?: number;
|
||||||
|
absoluteTimeoutSeconds?: number;
|
||||||
|
description?: string;
|
||||||
|
parentAgent?: string;
|
||||||
|
} = {};
|
||||||
|
if (idleTimeoutSeconds && idleTimeoutSeconds > 0) regOpts.timeoutSeconds = idleTimeoutSeconds;
|
||||||
|
if (absoluteTimeoutSeconds && absoluteTimeoutSeconds > 0) regOpts.absoluteTimeoutSeconds = absoluteTimeoutSeconds;
|
||||||
|
if (pendingMeta) {
|
||||||
|
if (pendingMeta.description) regOpts.description = pendingMeta.description;
|
||||||
|
if (pendingMeta.parentAgent) regOpts.parentAgent = pendingMeta.parentAgent;
|
||||||
|
}
|
||||||
|
const hasRegOpts =
|
||||||
|
regOpts.timeoutSeconds !== undefined ||
|
||||||
|
regOpts.absoluteTimeoutSeconds !== undefined ||
|
||||||
|
regOpts.description !== undefined ||
|
||||||
|
regOpts.parentAgent !== undefined;
|
||||||
|
register(sid, pid, session.project_path, session.name ?? undefined, hasRegOpts ? regOpts : undefined);
|
||||||
|
|
||||||
let handle: IPty;
|
let handle: IPty;
|
||||||
try {
|
try {
|
||||||
@@ -108,6 +131,8 @@ export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string
|
|||||||
}
|
}
|
||||||
// Feed the ring buffer for pattern-based search
|
// Feed the ring buffer for pattern-based search
|
||||||
appendOutput(pid, data);
|
appendOutput(pid, data);
|
||||||
|
// Bump activity timestamp for idle-timeout tracking
|
||||||
|
touchActivity(pid);
|
||||||
};
|
};
|
||||||
handle.onData(onData);
|
handle.onData(onData);
|
||||||
|
|
||||||
|
|||||||
@@ -36,9 +36,20 @@ export interface StepContext {
|
|||||||
* Falls back to a default in render functions when absent.
|
* Falls back to a default in render functions when absent.
|
||||||
*/
|
*/
|
||||||
readonly model?: string;
|
readonly model?: string;
|
||||||
|
/**
|
||||||
|
* Inter-agent messaging within the same flow run.
|
||||||
|
* `publish` broadcasts on the user WS channel and delivers to in-process
|
||||||
|
* subscribers via the broker. `subscribe` registers a handler scoped to the
|
||||||
|
* run and channel; returns an unsubscribe function.
|
||||||
|
* Undefined in contexts without a run id (manifest-only contexts).
|
||||||
|
*/
|
||||||
|
readonly messaging?: {
|
||||||
|
publish(channel: string, message: unknown): void;
|
||||||
|
subscribe(channel: string, handler: (msg: unknown) => void): () => void;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StepKind = 'agent' | 'code' | 'approval' | 'switch';
|
export type StepKind = 'agent' | 'code' | 'approval' | 'switch' | 'do_while';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One branch of a SWITCH step. The first case whose condition evaluates to true
|
* One branch of a SWITCH step. The first case whose condition evaluates to true
|
||||||
@@ -89,6 +100,12 @@ export interface Step {
|
|||||||
cases?: SwitchCase[];
|
cases?: SwitchCase[];
|
||||||
/** for kind:'switch' — fallback step ids when no case matches */
|
/** for kind:'switch' — fallback step ids when no case matches */
|
||||||
defaultBranch?: string[];
|
defaultBranch?: string[];
|
||||||
|
/** for kind:'do_while' — step IDs in the loop body (re-evaluated each iteration) */
|
||||||
|
loopBody?: string[];
|
||||||
|
/** for kind:'do_while' — guard evaluated each iteration; terminates when false */
|
||||||
|
loopCondition?: (ctx: StepContext) => boolean;
|
||||||
|
/** for kind:'do_while' — cap on total iterations (default 100) */
|
||||||
|
loopMaxIterations?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Flow {
|
export interface Flow {
|
||||||
|
|||||||
90
apps/coder/src/services/__tests__/collision-detector.test.ts
Normal file
90
apps/coder/src/services/__tests__/collision-detector.test.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { findConflicts } from '../collision-detector.js';
|
||||||
|
import type { ConflictEntry, ConflictIndexData } from '../collision-detector.js';
|
||||||
|
|
||||||
|
function entry(worktreeId: string, agent: string, start?: number, end?: number): ConflictEntry {
|
||||||
|
return {
|
||||||
|
worktreeId,
|
||||||
|
agent,
|
||||||
|
lineRange: start !== undefined && end !== undefined ? { start, end } : undefined,
|
||||||
|
status: 'pending' as const,
|
||||||
|
timestamp: 1000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function index(entries: Array<[string, ConflictEntry[]]>): ConflictIndexData {
|
||||||
|
return new Map(entries.map(([path, es]) => [path, new Set(es)] as const));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('findConflicts', () => {
|
||||||
|
it('returns empty when no files in index', () => {
|
||||||
|
const result = findConflicts(['src/a.ts'], 'wt-1', new Map(), new Map());
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty when only own worktree has the file', () => {
|
||||||
|
const idx = index([['src/a.ts', [entry('wt-1', 'agent-a', 1, 10)]]]);
|
||||||
|
const result = findConflicts(['src/a.ts'], 'wt-1', new Map(), idx);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects same_file conflict from another worktree', () => {
|
||||||
|
const idx = index([['src/a.ts', [entry('wt-2', 'agent-b', 5, 15)]]]);
|
||||||
|
const result = findConflicts(['src/a.ts'], 'wt-1', new Map(), idx);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]!.filePath).toBe('src/a.ts');
|
||||||
|
expect(result[0]!.worktrees).toEqual(['wt-2']);
|
||||||
|
expect(result[0]!.agents).toEqual(['agent-b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports same_line severity when ranges overlap', () => {
|
||||||
|
const idx = index([['src/a.ts', [entry('wt-2', 'agent-b', 10, 20)]]]);
|
||||||
|
const ranges = new Map([['src/a.ts', { start: 15, end: 25 }]]);
|
||||||
|
const result = findConflicts(['src/a.ts'], 'wt-1', ranges, idx);
|
||||||
|
expect(result[0]!.severity).toBe('same_line');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports different_area severity when ranges are far apart', () => {
|
||||||
|
const idx = index([['src/a.ts', [entry('wt-2', 'agent-b', 1, 10)]]]);
|
||||||
|
const ranges = new Map([['src/a.ts', { start: 100, end: 200 }]]);
|
||||||
|
const result = findConflicts(['src/a.ts'], 'wt-1', ranges, idx);
|
||||||
|
expect(result[0]!.severity).toBe('different_area');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports adjacent_line severity when ranges are 3 lines apart', () => {
|
||||||
|
const idx = index([['src/a.ts', [entry('wt-2', 'agent-b', 10, 15)]]]);
|
||||||
|
const ranges = new Map([['src/a.ts', { start: 19, end: 25 }]]);
|
||||||
|
const result = findConflicts(['src/a.ts'], 'wt-1', ranges, idx);
|
||||||
|
expect(result[0]!.severity).toBe('adjacent_line');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns entry for each conflicting file', () => {
|
||||||
|
const idx = index([
|
||||||
|
['src/a.ts', [entry('wt-2', 'agent-b', 1, 10)]],
|
||||||
|
['src/b.ts', [entry('wt-3', 'agent-c', 1, 10)]],
|
||||||
|
]);
|
||||||
|
const result = findConflicts(['src/a.ts', 'src/b.ts', 'src/c.ts'], 'wt-1', new Map(), idx);
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result.map((v) => v.filePath).sort()).toEqual(['src/a.ts', 'src/b.ts']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes entries from the same worktree', () => {
|
||||||
|
const idx = index([['src/a.ts', [entry('wt-1', 'agent-a', 1, 10), entry('wt-2', 'agent-b', 5, 15)]]]);
|
||||||
|
const result = findConflicts(['src/a.ts'], 'wt-1', new Map(), idx);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]!.worktrees).toEqual(['wt-2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates worktree IDs in verdict', () => {
|
||||||
|
const idx = index([['src/a.ts', [entry('wt-2', 'agent-b', 1, 5), entry('wt-2', 'agent-b', 10, 15)]]]);
|
||||||
|
const result = findConflicts(['src/a.ts'], 'wt-1', new Map(), idx);
|
||||||
|
expect(result[0]!.worktrees).toEqual(['wt-2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports same_line when no lineRange on either side (create/delete conflates)', () => {
|
||||||
|
const idx = index([['src/a.ts', [entry('wt-2', 'agent-b')]]]);
|
||||||
|
const result = findConflicts(['src/a.ts'], 'wt-1', new Map(), idx);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]!.severity).toBe('different_area');
|
||||||
|
});
|
||||||
|
});
|
||||||
146
apps/coder/src/services/__tests__/conflict-index.test.ts
Normal file
146
apps/coder/src/services/__tests__/conflict-index.test.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { ConflictIndex } from '../conflict-index.js';
|
||||||
|
|
||||||
|
describe('ConflictIndex', () => {
|
||||||
|
let idx: ConflictIndex;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
idx = new ConflictIndex();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('registerChange', () => {
|
||||||
|
it('adds an entry for a file path', () => {
|
||||||
|
idx.registerChange('src/a.ts', 'wt-1', 'agent-a', { start: 1, end: 10 });
|
||||||
|
const entries = idx.getEntriesFor('src/a.ts');
|
||||||
|
expect(entries.size).toBe(1);
|
||||||
|
const entry = [...entries][0]!;
|
||||||
|
expect(entry.worktreeId).toBe('wt-1');
|
||||||
|
expect(entry.agent).toBe('agent-a');
|
||||||
|
expect(entry.lineRange).toEqual({ start: 1, end: 10 });
|
||||||
|
expect(entry.status).toBe('pending');
|
||||||
|
expect(entry.timestamp).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports multiple entries for the same file path', () => {
|
||||||
|
idx.registerChange('src/a.ts', 'wt-1', 'agent-a', { start: 1, end: 10 });
|
||||||
|
idx.registerChange('src/a.ts', 'wt-2', 'agent-b', { start: 20, end: 30 });
|
||||||
|
expect(idx.getEntriesFor('src/a.ts').size).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows a worktree to have multiple entries (several edits to same file)', () => {
|
||||||
|
idx.registerChange('src/a.ts', 'wt-1', 'agent-a', { start: 1, end: 10 });
|
||||||
|
idx.registerChange('src/a.ts', 'wt-1', 'agent-a', { start: 20, end: 30 });
|
||||||
|
// Duplicate entries with same fields — the Set dedupes by ref,
|
||||||
|
// so a second identical call is still a distinct object (allowed).
|
||||||
|
expect(idx.getEntriesFor('src/a.ts').size).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('separates files into distinct keys', () => {
|
||||||
|
idx.registerChange('src/a.ts', 'wt-1', 'agent-a');
|
||||||
|
idx.registerChange('src/b.ts', 'wt-2', 'agent-b');
|
||||||
|
expect(idx.getEntriesFor('src/a.ts').size).toBe(1);
|
||||||
|
expect(idx.getEntriesFor('src/b.ts').size).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removeWorktree', () => {
|
||||||
|
it('removes all entries for a given worktree', () => {
|
||||||
|
idx.registerChange('src/a.ts', 'wt-1', 'agent-a');
|
||||||
|
idx.registerChange('src/a.ts', 'wt-2', 'agent-b');
|
||||||
|
idx.registerChange('src/b.ts', 'wt-1', 'agent-a');
|
||||||
|
idx.removeWorktree('wt-1');
|
||||||
|
expect(idx.getEntriesFor('src/a.ts').size).toBe(1);
|
||||||
|
expect([...idx.getEntriesFor('src/a.ts')][0]!.worktreeId).toBe('wt-2');
|
||||||
|
expect(idx.getEntriesFor('src/b.ts').size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op when worktree has no entries', () => {
|
||||||
|
idx.registerChange('src/a.ts', 'wt-1', 'agent-a');
|
||||||
|
idx.removeWorktree('wt-ghost');
|
||||||
|
expect(idx.getEntriesFor('src/a.ts').size).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cleans up file key when last entry is removed', () => {
|
||||||
|
idx.registerChange('src/a.ts', 'wt-1', 'agent-a');
|
||||||
|
idx.removeWorktree('wt-1');
|
||||||
|
// After removal the key should be gone
|
||||||
|
expect(idx.snapshot().has('src/a.ts')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sweepStale', () => {
|
||||||
|
it('removes entries older than maxAgeMs', async () => {
|
||||||
|
idx.registerChange('src/a.ts', 'wt-1', 'agent-a');
|
||||||
|
idx.registerChange('src/b.ts', 'wt-2', 'agent-b');
|
||||||
|
// Wait a tick so timestamps diverge
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
idx.registerChange('src/c.ts', 'wt-3', 'agent-c');
|
||||||
|
const removed = idx.sweepStale(5); // 5ms cutoff — entries from before the await are stale
|
||||||
|
expect(removed).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes file key when all entries swept', async () => {
|
||||||
|
idx.registerChange('src/a.ts', 'wt-1', 'agent-a');
|
||||||
|
// Wait so timestamp is definitely older than cutoff
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
const removed = idx.sweepStale(5);
|
||||||
|
expect(removed).toBe(1);
|
||||||
|
expect(idx.snapshot().has('src/a.ts')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 0 when no entries are stale', () => {
|
||||||
|
idx.registerChange('src/a.ts', 'wt-1', 'agent-a');
|
||||||
|
const removed = idx.sweepStale(86_400_000); // 24h
|
||||||
|
expect(removed).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getConflictsFor', () => {
|
||||||
|
it('returns conflicts between worktrees', () => {
|
||||||
|
idx.registerChange('src/a.ts', 'wt-1', 'agent-a', { start: 1, end: 10 });
|
||||||
|
idx.registerChange('src/a.ts', 'wt-2', 'agent-b', { start: 5, end: 15 });
|
||||||
|
const conflicts = idx.getConflictsFor('src/a.ts');
|
||||||
|
expect(conflicts).toHaveLength(1);
|
||||||
|
expect(conflicts[0]!.filePath).toBe('src/a.ts');
|
||||||
|
// getConflictsFor doesn't know the caller's line range,
|
||||||
|
// so severity defaults to 'different_area'
|
||||||
|
expect(conflicts[0]!.severity).toBe('different_area');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty for files with only one worktree', () => {
|
||||||
|
idx.registerChange('src/a.ts', 'wt-1', 'agent-a');
|
||||||
|
expect(idx.getConflictsFor('src/a.ts')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty for files not in index', () => {
|
||||||
|
expect(idx.getConflictsFor('src/never-touched.ts')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('query', () => {
|
||||||
|
it('delegates to findConflicts with proper data', () => {
|
||||||
|
idx.registerChange('src/a.ts', 'wt-2', 'agent-b', { start: 5, end: 15 });
|
||||||
|
const ranges = new Map([['src/a.ts', { start: 10, end: 20 }]]);
|
||||||
|
const result = idx.query(['src/a.ts'], 'wt-1', ranges);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]!.severity).toBe('same_line');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty when no conflicts', () => {
|
||||||
|
idx.registerChange('src/a.ts', 'wt-1', 'agent-a', { start: 1, end: 10 });
|
||||||
|
const result = idx.query(['src/a.ts'], 'wt-1', new Map());
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('snapshot', () => {
|
||||||
|
it('returns a copy of the internal map', () => {
|
||||||
|
idx.registerChange('src/a.ts', 'wt-1', 'agent-a');
|
||||||
|
const snap = idx.snapshot();
|
||||||
|
expect(snap.has('src/a.ts')).toBe(true);
|
||||||
|
// Mutating the snapshot doesn't affect the original
|
||||||
|
idx.removeWorktree('wt-1');
|
||||||
|
expect(snap.has('src/a.ts')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -47,12 +47,21 @@ export interface SchedulerState {
|
|||||||
* remainder of the run — they won't execute and won't block dependents.
|
* remainder of the run — they won't execute and won't block dependents.
|
||||||
*/
|
*/
|
||||||
readonly switchResults: ReadonlyMap<string, { chosenCase: string | null; excluded: ReadonlySet<string> }>;
|
readonly switchResults: ReadonlyMap<string, { chosenCase: string | null; excluded: ReadonlySet<string> }>;
|
||||||
|
/** Per-DO_WHILE iteration count; presence in the map indicates an active loop */
|
||||||
|
readonly loopIterations: ReadonlyMap<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A dependency is satisfied once it is done, skipped, excluded, or timed out. */
|
/** A dependency is satisfied once it is done, skipped, excluded, or timed out.
|
||||||
|
* Dependencies on a running DO_WHILE step are also satisfied so body steps
|
||||||
|
* execute during an active loop iteration. */
|
||||||
function isSatisfied(state: SchedulerState, id: string): boolean {
|
function isSatisfied(state: SchedulerState, id: string): boolean {
|
||||||
const effectiveExcluded = getEffectiveExcluded(state);
|
const effectiveExcluded = getEffectiveExcluded(state);
|
||||||
return state.done.has(id) || state.skipped.has(id) || effectiveExcluded.has(id) || state.timedOut.has(id);
|
if (state.done.has(id) || state.skipped.has(id) || effectiveExcluded.has(id) || state.timedOut.has(id)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// A dependency on a running DO_WHILE step is satisfied (body runs during the loop).
|
||||||
|
if (state.loopIterations.has(id) && state.inFlight.has(id)) return true;
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -375,3 +384,17 @@ export function reconcileRun(
|
|||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when a DO_WHILE loop should stop: the condition returned false or the
|
||||||
|
* iteration cap was reached. Pure — no IO.
|
||||||
|
*
|
||||||
|
* @param step - the DO_WHILE step definition
|
||||||
|
* @param ctx - current step context (input + accumulated results)
|
||||||
|
* @param iterations - number of completed iterations so far
|
||||||
|
*/
|
||||||
|
export function isLoopTerminated(step: Step, ctx: StepContext, iterations: number): boolean {
|
||||||
|
if (iterations >= (step.loopMaxIterations ?? 100)) return true;
|
||||||
|
if (step.loopCondition) return !step.loopCondition(ctx);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
* already emits. (Phase 8 wires the OrchestratorPane's subscription to both.)
|
* already emits. (Phase 8 wires the OrchestratorPane's subscription to both.)
|
||||||
*/
|
*/
|
||||||
import type { Sql } from '../db.js';
|
import type { Sql } from '../db.js';
|
||||||
import type { Broker } from '@boocode/server/broker';
|
import type { Broker, Frame, Listener } from '@boocode/server/broker';
|
||||||
import type { WsFrame } from '@boocode/contracts/ws-frames';
|
import type { WsFrame } from '@boocode/contracts/ws-frames';
|
||||||
import type { FastifyBaseLogger } from 'fastify';
|
import type { FastifyBaseLogger } from 'fastify';
|
||||||
import type { Config } from '../config.js';
|
import type { Config } from '../config.js';
|
||||||
@@ -42,6 +42,7 @@ import type { Band, DispatchFn, Flow, FlowInput, Step, StepContext } from '../co
|
|||||||
import {
|
import {
|
||||||
buildBatchState,
|
buildBatchState,
|
||||||
getReadyInBatch,
|
getReadyInBatch,
|
||||||
|
isLoopTerminated,
|
||||||
isRunComplete,
|
isRunComplete,
|
||||||
manifestSteps,
|
manifestSteps,
|
||||||
partitionReady,
|
partitionReady,
|
||||||
@@ -98,7 +99,7 @@ interface Deps {
|
|||||||
|
|
||||||
interface FlowStepRow {
|
interface FlowStepRow {
|
||||||
step_id: string;
|
step_id: string;
|
||||||
kind: 'agent' | 'code' | 'switch';
|
kind: 'agent' | 'code' | 'switch' | 'do_while';
|
||||||
agent: string | null;
|
agent: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
chat_id: string | null;
|
chat_id: string | null;
|
||||||
@@ -118,6 +119,10 @@ export function createFlowRunner(deps: Deps): FlowRunner {
|
|||||||
// taskId → resolver map. These tasks have NO flow_steps row; handleTaskTerminal
|
// taskId → resolver map. These tasks have NO flow_steps row; handleTaskTerminal
|
||||||
// resolves them here instead of advancing a run.
|
// resolves them here instead of advancing a run.
|
||||||
const subDispatchWaiters = new Map<string, (output: string) => void>();
|
const subDispatchWaiters = new Map<string, (output: string) => void>();
|
||||||
|
/** Per-DO_WHILE step iteration count; persists across advance() calls. */
|
||||||
|
const loopIterations = new Map<string, number>();
|
||||||
|
/** Per-run messaging subscriptions; cleaned up when the run terminates. */
|
||||||
|
const messagingCleanups = new Map<string, Set<() => void>>();
|
||||||
|
|
||||||
function publishUser(frame: Record<string, unknown>): void {
|
function publishUser(frame: Record<string, unknown>): void {
|
||||||
broker.publishUserFrame('default', frame as unknown as WsFrame);
|
broker.publishUserFrame('default', frame as unknown as WsFrame);
|
||||||
@@ -134,8 +139,42 @@ export function createFlowRunner(deps: Deps): FlowRunner {
|
|||||||
results: Record<string, string>,
|
results: Record<string, string>,
|
||||||
model: string,
|
model: string,
|
||||||
dispatch?: DispatchFn,
|
dispatch?: DispatchFn,
|
||||||
|
runId?: string,
|
||||||
|
stepId?: string,
|
||||||
): StepContext {
|
): StepContext {
|
||||||
return { input, results, model, dispatch };
|
let messaging: StepContext['messaging'] = undefined;
|
||||||
|
if (runId) {
|
||||||
|
if (!messagingCleanups.has(runId)) {
|
||||||
|
messagingCleanups.set(runId, new Set());
|
||||||
|
}
|
||||||
|
const subs = messagingCleanups.get(runId)!;
|
||||||
|
messaging = {
|
||||||
|
publish(channel: string, message: unknown) {
|
||||||
|
const content = typeof message === 'string' ? message : JSON.stringify(message);
|
||||||
|
const topic = `run:${runId}:${channel}`;
|
||||||
|
const frame = {
|
||||||
|
type: 'agent_message' as const,
|
||||||
|
run_id: runId,
|
||||||
|
sender_step_id: stepId ?? '',
|
||||||
|
content,
|
||||||
|
...(channel ? { channel } : {}),
|
||||||
|
};
|
||||||
|
broker.publishUserFrame('default', frame as unknown as WsFrame);
|
||||||
|
broker.publish(topic, frame as unknown as Frame);
|
||||||
|
},
|
||||||
|
subscribe(channel: string, handler: (msg: unknown) => void) {
|
||||||
|
const topic = `run:${runId}:${channel}`;
|
||||||
|
const listener: Listener = (f) => { handler(f); };
|
||||||
|
const unsub = broker.subscribe(topic, listener);
|
||||||
|
subs.add(unsub);
|
||||||
|
return () => {
|
||||||
|
unsub();
|
||||||
|
subs.delete(unsub);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { input, results, model, dispatch, messaging };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Latest assistant message text for a chat — the FULL worker output (≤50k as
|
/** Latest assistant message text for a chat — the FULL worker output (≤50k as
|
||||||
@@ -378,7 +417,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
|
|||||||
for (;;) {
|
for (;;) {
|
||||||
// Build per-batch state from the current inFlight set for batch parallelism gating.
|
// Build per-batch state from the current inFlight set for batch parallelism gating.
|
||||||
const batchState = buildBatchState(flow, inFlight);
|
const batchState = buildBatchState(flow, inFlight);
|
||||||
const state: SchedulerState = { done, skipped, inFlight, excluded, timedOut, batchState, switchResults: switchExcluded };
|
const state: SchedulerState = { done, skipped, inFlight, excluded, timedOut, batchState, switchResults: switchExcluded, loopIterations };
|
||||||
|
|
||||||
if (isRunComplete(flow, state)) {
|
if (isRunComplete(flow, state)) {
|
||||||
await finishRun(runId, flow, input, results, model, dispatch);
|
await finishRun(runId, flow, input, results, model, dispatch);
|
||||||
@@ -387,7 +426,46 @@ export function createFlowRunner(deps: Deps): FlowRunner {
|
|||||||
|
|
||||||
const ready = getReadyInBatch(readySteps(flow, state), state, flow);
|
const ready = getReadyInBatch(readySteps(flow, state), state, flow);
|
||||||
if (ready.length === 0) {
|
if (ready.length === 0) {
|
||||||
if (inFlight.size > 0) return; // agents in flight will re-enter via the hook
|
// Before declaring stuck, check for running DO_WHILE steps whose body
|
||||||
|
// is fully done — triggers the next loop iteration or terminates.
|
||||||
|
if (inFlight.size > 0) {
|
||||||
|
let doWhileReEval = false;
|
||||||
|
for (const s of flow.steps) {
|
||||||
|
if (s.kind !== 'do_while' || !s.loopBody || s.loopBody.length === 0) continue;
|
||||||
|
if (!inFlight.has(s.id)) continue;
|
||||||
|
if (!s.loopBody.every((bId) => done.has(bId))) continue;
|
||||||
|
doWhileReEval = true;
|
||||||
|
const iterations = loopIterations.get(s.id) ?? 0;
|
||||||
|
const dwCtx = buildCtx(input, results, model, dispatch);
|
||||||
|
if (isLoopTerminated(s, dwCtx, iterations)) {
|
||||||
|
await markStep(runId, s.id, 'completed');
|
||||||
|
done.add(s.id);
|
||||||
|
results[s.id] = '';
|
||||||
|
inFlight.delete(s.id);
|
||||||
|
publishStep(runId, s.id, 'completed');
|
||||||
|
} else {
|
||||||
|
await sql`
|
||||||
|
UPDATE flow_steps SET status = 'running', updated_at = clock_timestamp()
|
||||||
|
WHERE run_id = ${runId} AND step_id = ${s.id}
|
||||||
|
`;
|
||||||
|
inFlight.add(s.id);
|
||||||
|
loopIterations.set(s.id, iterations + 1);
|
||||||
|
for (const bodyId of s.loopBody) {
|
||||||
|
done.delete(bodyId);
|
||||||
|
delete results[bodyId];
|
||||||
|
await sql`
|
||||||
|
UPDATE flow_steps
|
||||||
|
SET status = 'pending', output = NULL, updated_at = clock_timestamp()
|
||||||
|
WHERE run_id = ${runId} AND step_id = ${bodyId}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
publishStep(runId, s.id, 'running');
|
||||||
|
}
|
||||||
|
break; // one DO_WHILE at a time
|
||||||
|
}
|
||||||
|
if (doWhileReEval) continue;
|
||||||
|
return; // genuine inFlight agents with no ready steps
|
||||||
|
}
|
||||||
await failRun(runId, flow, input, model, 'unsatisfiable dependencies / cycle');
|
await failRun(runId, flow, input, model, 'unsatisfiable dependencies / cycle');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -429,6 +507,49 @@ export function createFlowRunner(deps: Deps): FlowRunner {
|
|||||||
continue; // re-evaluate — excluded steps may unblock dependents
|
continue; // re-evaluate — excluded steps may unblock dependents
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DO_WHILE steps: first-activation only (ready to run for the first time).
|
||||||
|
// Re-evaluation of running DO_WHILE steps whose body is complete is handled
|
||||||
|
// in the `ready.length === 0` block above (Path 1) — this avoids duplicate
|
||||||
|
// SQL updates and competing state mutations.
|
||||||
|
const doWhileReady = toRun.filter((s) => s.kind === 'do_while');
|
||||||
|
if (doWhileReady.length > 0) {
|
||||||
|
for (const s of doWhileReady) {
|
||||||
|
const iterations = loopIterations.get(s.id) ?? 0;
|
||||||
|
const dwCtx = buildCtx(input, results, model, dispatch);
|
||||||
|
if (isLoopTerminated(s, dwCtx, iterations)) {
|
||||||
|
// Loop done — mark DO_WHILE completed. Body steps stay in their
|
||||||
|
// current state (already done from the last iteration).
|
||||||
|
await markStep(runId, s.id, 'completed');
|
||||||
|
done.add(s.id);
|
||||||
|
results[s.id] = '';
|
||||||
|
inFlight.delete(s.id);
|
||||||
|
publishStep(runId, s.id, 'completed');
|
||||||
|
} else {
|
||||||
|
// Start or continue the loop.
|
||||||
|
await sql`
|
||||||
|
UPDATE flow_steps SET status = 'running', updated_at = clock_timestamp()
|
||||||
|
WHERE run_id = ${runId} AND step_id = ${s.id}
|
||||||
|
`;
|
||||||
|
inFlight.add(s.id);
|
||||||
|
loopIterations.set(s.id, iterations + 1);
|
||||||
|
// On re-iteration, reset body steps from 'completed' back to 'pending'.
|
||||||
|
if (iterations > 0 && s.loopBody) {
|
||||||
|
for (const bodyId of s.loopBody) {
|
||||||
|
done.delete(bodyId);
|
||||||
|
delete results[bodyId];
|
||||||
|
await sql`
|
||||||
|
UPDATE flow_steps
|
||||||
|
SET status = 'pending', output = NULL, updated_at = clock_timestamp()
|
||||||
|
WHERE run_id = ${runId} AND step_id = ${bodyId}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
publishStep(runId, s.id, 'running');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue; // re-evaluate — body steps may be newly pending
|
||||||
|
}
|
||||||
|
|
||||||
const codeReady = toRun.filter((s) => s.kind === 'code');
|
const codeReady = toRun.filter((s) => s.kind === 'code');
|
||||||
if (codeReady.length > 0) {
|
if (codeReady.length > 0) {
|
||||||
for (const s of codeReady) {
|
for (const s of codeReady) {
|
||||||
@@ -436,7 +557,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
|
|||||||
try {
|
try {
|
||||||
// Code steps run IN-PROCESS (fold / synthesis-fold / code-review verify).
|
// Code steps run IN-PROCESS (fold / synthesis-fold / code-review verify).
|
||||||
// verify uses ctx.dispatch → dispatchSubAgent (read-only qwen workers).
|
// verify uses ctx.dispatch → dispatchSubAgent (read-only qwen workers).
|
||||||
out = await s.run(buildCtx(input, results, model, dispatch));
|
out = await s.run(buildCtx(input, results, model, dispatch, runId, s.id));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await failRun(runId, flow, input, model, `code step '${s.id}' threw: ${errMsg(err)}`, s.id);
|
await failRun(runId, flow, input, model, `code step '${s.id}' threw: ${errMsg(err)}`, s.id);
|
||||||
return;
|
return;
|
||||||
@@ -559,6 +680,14 @@ export function createFlowRunner(deps: Deps): FlowRunner {
|
|||||||
await appendStepEvent(sql, runId, stepId, status, output ? { outputLength: output.length } : undefined);
|
await appendStepEvent(sql, runId, stepId, status, output ? { outputLength: output.length } : undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cleanupMessaging(runId: string): void {
|
||||||
|
const cleanups = messagingCleanups.get(runId);
|
||||||
|
if (cleanups) {
|
||||||
|
for (const fn of cleanups) fn();
|
||||||
|
messagingCleanups.delete(runId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── run completion ─────────────────────────────────────────────────────────
|
// ─── run completion ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function finishRun(
|
async function finishRun(
|
||||||
@@ -580,12 +709,16 @@ export function createFlowRunner(deps: Deps): FlowRunner {
|
|||||||
UPDATE flow_runs SET status = 'completed', report = ${report}, updated_at = clock_timestamp()
|
UPDATE flow_runs SET status = 'completed', report = ${report}, updated_at = clock_timestamp()
|
||||||
WHERE id = ${runId} AND status = 'running'
|
WHERE id = ${runId} AND status = 'running'
|
||||||
`;
|
`;
|
||||||
if (updated.count === 0) return; // already terminal (e.g. cancelled) — don't publish
|
if (updated.count === 0) {
|
||||||
|
cleanupMessaging(runId);
|
||||||
|
return; // already terminal (e.g. cancelled) — don't publish
|
||||||
|
}
|
||||||
deps.onRunTerminal?.(runId, 'completed');
|
deps.onRunTerminal?.(runId, 'completed');
|
||||||
publishStep(runId, lastAgentStepId(flow, input, model), 'completed', {
|
publishStep(runId, lastAgentStepId(flow, input, model), 'completed', {
|
||||||
run_status: 'completed',
|
run_status: 'completed',
|
||||||
report,
|
report,
|
||||||
});
|
});
|
||||||
|
cleanupMessaging(runId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function failRun(
|
async function failRun(
|
||||||
@@ -606,6 +739,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
|
|||||||
log.warn({ runId, error }, 'flow-runner: run failed');
|
log.warn({ runId, error }, 'flow-runner: run failed');
|
||||||
await appendStepEvent(sql, runId, stepId, 'failed', { error });
|
await appendStepEvent(sql, runId, stepId, 'failed', { error });
|
||||||
publishStep(runId, stepId, 'failed', { run_status: 'failed' });
|
publishStep(runId, stepId, 'failed', { run_status: 'failed' });
|
||||||
|
cleanupMessaging(runId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cancelRun(runId: string): Promise<void> {
|
async function cancelRun(runId: string): Promise<void> {
|
||||||
@@ -633,6 +767,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
log.info({ runId }, 'flow-runner: run cancelled');
|
log.info({ runId }, 'flow-runner: run cancelled');
|
||||||
|
cleanupMessaging(runId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The terminal agent step in roster order — a valid roster step_id to carry the
|
/** The terminal agent step in roster order — a valid roster step_id to carry the
|
||||||
@@ -918,6 +1053,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
|
|||||||
.map((s) => s.task_id);
|
.map((s) => s.task_id);
|
||||||
|
|
||||||
log.info({ runId }, 'flow-runner: run cancelled by request');
|
log.info({ runId }, 'flow-runner: run cancelled by request');
|
||||||
|
cleanupMessaging(runId);
|
||||||
return { cancelled: true, taskIds };
|
return { cancelled: true, taskIds };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
# apps/server — BooChat backend (deep reference)
|
# apps/server — BooChat backend (deep reference) — v2.7.x (last meaningful update: 2026-06)
|
||||||
|
|
||||||
> Per-app engineering notes for `apps/server/src/`. Cross-cutting commands, database, environment, workflow, and cross-app contracts (WS-frame / provider-type parity, sentinels) live in the **root `CLAUDE.md`**. This file auto-loads when you read/edit files under `apps/server/`.
|
> Per-app engineering notes for `apps/server/src/`. Cross-cutting commands, database, environment, workflow, and cross-app contracts (WS-frame / provider-type parity, sentinels) live in the **root `CLAUDE.md`**. This file auto-loads when you read/edit files under `apps/server/`.
|
||||||
|
|
||||||
|
## These gotchas are load-bearing — do not remove or refactor without understanding why
|
||||||
|
- Do NOT remove the abort-signal pinning comment in `stream-phase.ts` — `fullStream` exits cleanly on abort without throwing; the post-iteration `if (signal?.aborted)` check is the only thing that distinguishes cancelled from complete.
|
||||||
|
- Do NOT remove `includeUsage: true` from `provider.ts` — the adapter defaults it false; without it, token counts are always NULL.
|
||||||
|
- Do NOT add raw `broker.publish()`/`publishUser()` calls — always use `publishFrame`/`publishUserFrame` which Zod-validate against `WsFrameSchema`.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
- **Fastify** with `@fastify/websocket` and `@fastify/static` (serves the built frontend).
|
- **Fastify** with `@fastify/websocket` and `@fastify/static` (serves the built frontend).
|
||||||
|
|||||||
@@ -1,18 +1,33 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
import type { Sql } from '../db.js';
|
import type { Sql } from '../db.js';
|
||||||
|
import type { Config } from '../config.js';
|
||||||
import type { Broker } from '../services/broker.js';
|
import type { Broker } from '../services/broker.js';
|
||||||
import type { Chat, Message } from '../types/api.js';
|
import type { Chat, Message } from '../types/api.js';
|
||||||
import { getModelContext } from '../services/model-context.js';
|
import { getModelContext } from '../services/model-context.js';
|
||||||
import { notifyCoderClose } from '../services/coder-notify.js';
|
import { notifyCoderClose } from '../services/coder-notify.js';
|
||||||
import { MESSAGE_COLUMNS } from '../services/message-columns.js';
|
import { MESSAGE_COLUMNS } from '../services/message-columns.js';
|
||||||
|
import { formatJson, formatMarkdown } from '../services/export-formatter.js';
|
||||||
|
export interface CompareHandlers {
|
||||||
|
enqueueCompare: (
|
||||||
|
sessionId: string,
|
||||||
|
chatId: string,
|
||||||
|
assistantMessageId: string,
|
||||||
|
modelOverride: string,
|
||||||
|
compareGroupId: string,
|
||||||
|
) => void;
|
||||||
|
cancelInference: (sessionId: string, chatId: string) => Promise<boolean>;
|
||||||
|
hasActiveInference: (chatId: string) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
const CreateBody = z.object({
|
const CreateBody = z.object({
|
||||||
name: z.string().min(1).max(200).optional(),
|
name: z.string().min(1).max(200).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const PatchBody = z.object({
|
const PatchBody = z.object({
|
||||||
name: z.string().min(1).max(200),
|
name: z.string().min(1).max(200).optional(),
|
||||||
|
model: z.string().min(1).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const ForkBody = z.object({
|
const ForkBody = z.object({
|
||||||
@@ -26,10 +41,17 @@ const DiscardStaleBody = z.object({
|
|||||||
|
|
||||||
const STALE_MIN_AGE_SECONDS = 60;
|
const STALE_MIN_AGE_SECONDS = 60;
|
||||||
|
|
||||||
|
const CompareBody = z.object({
|
||||||
|
message: z.string().min(1).max(64_000),
|
||||||
|
models: z.array(z.string().min(1)).min(2).max(3),
|
||||||
|
});
|
||||||
|
|
||||||
export function registerChatRoutes(
|
export function registerChatRoutes(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
sql: Sql,
|
sql: Sql,
|
||||||
broker: Broker
|
broker: Broker,
|
||||||
|
config?: Config,
|
||||||
|
compareHandlers?: CompareHandlers,
|
||||||
): void {
|
): void {
|
||||||
app.get<{ Params: { id: string }; Querystring: { status?: string } }>(
|
app.get<{ Params: { id: string }; Querystring: { status?: string } }>(
|
||||||
'/api/sessions/:id/chats',
|
'/api/sessions/:id/chats',
|
||||||
@@ -122,12 +144,15 @@ export function registerChatRoutes(
|
|||||||
reply.code(400);
|
reply.code(400);
|
||||||
return { error: 'invalid body', details: parsed.error.flatten() };
|
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||||
}
|
}
|
||||||
|
const { name, model } = parsed.data;
|
||||||
|
const sets: Array<ReturnType<typeof sql>> = [sql`updated_at = clock_timestamp()`];
|
||||||
|
if (name !== undefined) sets.push(sql`name = ${name}`);
|
||||||
|
if (model !== undefined) sets.push(sql`model = ${model}`);
|
||||||
const rows = await sql<Chat[]>`
|
const rows = await sql<Chat[]>`
|
||||||
UPDATE chats
|
UPDATE chats
|
||||||
SET name = ${parsed.data.name},
|
SET ${(sql as any).join(sets, sql`, `)}
|
||||||
updated_at = clock_timestamp()
|
|
||||||
WHERE id = ${req.params.id}
|
WHERE id = ${req.params.id}
|
||||||
RETURNING id, session_id, name, status, created_at, updated_at
|
RETURNING id, session_id, name, model, status, created_at, updated_at
|
||||||
`;
|
`;
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
reply.code(404);
|
reply.code(404);
|
||||||
@@ -448,4 +473,128 @@ export function registerChatRoutes(
|
|||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
app.get<{ Params: { id: string }; Querystring: { format?: string } }>(
|
||||||
|
'/api/chats/:id/export',
|
||||||
|
async (req, reply) => {
|
||||||
|
const format = req.query.format ?? 'json';
|
||||||
|
if (format !== 'json' && format !== 'markdown') {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'format must be json or markdown' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const chat = await sql<Chat[]>`SELECT * FROM chats WHERE id = ${req.params.id}`;
|
||||||
|
if (chat.length === 0) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'chat not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = await sql<Message[]>`
|
||||||
|
SELECT ${sql.unsafe(MESSAGE_COLUMNS)}
|
||||||
|
FROM messages_with_parts
|
||||||
|
WHERE chat_id = ${req.params.id}
|
||||||
|
ORDER BY created_at ASC, id ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (format === 'markdown') {
|
||||||
|
reply.header('Content-Type', 'text/markdown');
|
||||||
|
return formatMarkdown(chat[0]!, messages, chat[0]!.model);
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.header('Content-Type', 'application/json');
|
||||||
|
return formatJson(chat[0]!, messages, chat[0]!.model);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// v2.8-compare: send the same message to N models and stream back parallel
|
||||||
|
// responses. Creates N assistant messages (one per model) and launches N
|
||||||
|
// parallel inference runs with model overrides. Each publishes frames
|
||||||
|
// scoped to the shared compare_group_id so the frontend can group them.
|
||||||
|
if (config && compareHandlers) {
|
||||||
|
app.post<{ Params: { id: string } }>(
|
||||||
|
'/api/chats/:id/compare',
|
||||||
|
async (req, reply) => {
|
||||||
|
const parsed = CompareBody.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { message, models } = parsed.data;
|
||||||
|
|
||||||
|
// Check for active inference first.
|
||||||
|
if (compareHandlers.hasActiveInference(req.params.id)) {
|
||||||
|
reply.code(409);
|
||||||
|
return { error: 'chat is currently streaming; stop it first' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const chatRows = await sql<Chat[]>`
|
||||||
|
SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open'
|
||||||
|
`;
|
||||||
|
if (chatRows.length === 0) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'chat not found' };
|
||||||
|
}
|
||||||
|
const chat = chatRows[0]!;
|
||||||
|
const sessionId = chat.session_id;
|
||||||
|
const compareGroupId = crypto.randomUUID();
|
||||||
|
|
||||||
|
// Insert user message + N assistant messages in a single transaction.
|
||||||
|
const result = await sql.begin(async (tx) => {
|
||||||
|
const [userMsg] = await tx<{ id: string }[]>`
|
||||||
|
INSERT INTO messages (session_id, chat_id, role, content, status, created_at, metadata)
|
||||||
|
VALUES (${sessionId}, ${chat.id}, 'user', ${message}, 'complete', clock_timestamp(), NULL)
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
|
||||||
|
const responses: Array<{ model: string; assistant_message_id: string }> = [];
|
||||||
|
for (const model of models) {
|
||||||
|
const [asst] = await tx<{ id: string }[]>`
|
||||||
|
INSERT INTO messages (session_id, chat_id, role, content, status, created_at, metadata)
|
||||||
|
VALUES (
|
||||||
|
${sessionId}, ${chat.id}, 'assistant', '', 'streaming', clock_timestamp(),
|
||||||
|
${tx.json({ compare_group_id: compareGroupId, model } as never)}
|
||||||
|
)
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
responses.push({ model, assistant_message_id: asst!.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
|
||||||
|
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chat.id}`;
|
||||||
|
|
||||||
|
return { user_message_id: userMsg!.id, responses };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Publish user message frames.
|
||||||
|
broker.publishFrame(sessionId, {
|
||||||
|
type: 'message_started',
|
||||||
|
message_id: result.user_message_id,
|
||||||
|
chat_id: chat.id,
|
||||||
|
role: 'user',
|
||||||
|
});
|
||||||
|
broker.publishFrame(sessionId, {
|
||||||
|
type: 'delta',
|
||||||
|
message_id: result.user_message_id,
|
||||||
|
chat_id: chat.id,
|
||||||
|
content: message,
|
||||||
|
});
|
||||||
|
broker.publishFrame(sessionId, {
|
||||||
|
type: 'message_complete',
|
||||||
|
message_id: result.user_message_id,
|
||||||
|
chat_id: chat.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enqueue N parallel inference runs with model overrides.
|
||||||
|
for (const resp of result.responses) {
|
||||||
|
compareHandlers.enqueueCompare(
|
||||||
|
sessionId, chat.id, resp.assistant_message_id, resp.model, compareGroupId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.code(202);
|
||||||
|
return { compare_group_id: compareGroupId, ...result };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ import { z } from 'zod';
|
|||||||
import type { Sql } from '../db.js';
|
import type { Sql } from '../db.js';
|
||||||
import type { Config } from '../config.js';
|
import type { Config } from '../config.js';
|
||||||
import type { Broker } from '../services/broker.js';
|
import type { Broker } from '../services/broker.js';
|
||||||
import type { Chat, Message, Session, ToolCall } from '../types/api.js';
|
import type { Chat, Message, MessageMetadata, Session, ToolCall } from '../types/api.js';
|
||||||
// v1.13.17-cross-repo-reads: grant_read_access resolves the grant root at
|
// v1.13.17-cross-repo-reads: grant_read_access resolves the grant root at
|
||||||
// decision time (not at request time) so concurrent project changes don't
|
// decision time (not at request time) so concurrent project changes don't
|
||||||
// stale-bind the resolution.
|
// stale-bind the resolution.
|
||||||
import { resolveGrantRoot } from '../services/grant_resolver.js';
|
import { resolveGrantRoot } from '../services/grant_resolver.js';
|
||||||
import { MESSAGE_COLUMNS } from '../services/message-columns.js';
|
import { MESSAGE_COLUMNS } from '../services/message-columns.js';
|
||||||
|
import { setServerPermission, getServerName } from '../services/mcp-client.js';
|
||||||
|
|
||||||
// Shared lookup for the answer_user_input + grant_read_access pause-resume
|
// Shared lookup for the answer_user_input + grant_read_access pause-resume
|
||||||
// endpoints. Finds the originating assistant tool_call by id in message_parts,
|
// endpoints. Finds the originating assistant tool_call by id in message_parts,
|
||||||
@@ -846,4 +847,117 @@ export function registerMessageRoutes(
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// v1.15.0-mcp-permission: approve/deny MCP tool calls for 'ask' state servers.
|
||||||
|
const McpApproveBody = z.object({
|
||||||
|
tool_call_id: z.string().min(1),
|
||||||
|
permission: z.enum(['allow_once', 'allow_always', 'deny']),
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string } }>(
|
||||||
|
'/api/chats/:id/mcp-approve',
|
||||||
|
async (req, reply) => {
|
||||||
|
const parsed = McpApproveBody.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||||
|
}
|
||||||
|
const { tool_call_id, permission } = parsed.data;
|
||||||
|
|
||||||
|
const chatRows = await sql<{ id: string }[]>`
|
||||||
|
SELECT id FROM chats WHERE id = ${req.params.id} AND status = 'open'
|
||||||
|
`;
|
||||||
|
if (chatRows.length === 0) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'chat_not_found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up the tool call to get the prefixed tool name
|
||||||
|
const callerRows = await sql<{
|
||||||
|
payload: { name: string };
|
||||||
|
}[]>`
|
||||||
|
SELECT p.payload
|
||||||
|
FROM message_parts p
|
||||||
|
JOIN messages m ON m.id = p.message_id
|
||||||
|
WHERE m.chat_id = ${req.params.id}
|
||||||
|
AND m.role = 'assistant'
|
||||||
|
AND p.kind = 'tool_call'
|
||||||
|
AND p.payload->>'id' = ${tool_call_id}
|
||||||
|
ORDER BY m.created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
const callerRow = callerRows[0];
|
||||||
|
if (!callerRow) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'tool_call_not_found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolName = callerRow.payload.name;
|
||||||
|
const serverName = getServerName(toolName);
|
||||||
|
if (!serverName) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'not_an_mcp_tool', detail: `tool '${toolName}' is not from an MCP server` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permission === 'allow_always' || permission === 'allow_once') {
|
||||||
|
setServerPermission(serverName, 'allow');
|
||||||
|
} else if (permission === 'deny') {
|
||||||
|
setServerPermission(serverName, 'deny');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const FeedbackBody = z.object({
|
||||||
|
value: z.enum(['up', 'down']),
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string; message_id: string } }>(
|
||||||
|
'/api/chats/:id/messages/:message_id/feedback',
|
||||||
|
async (req, reply) => {
|
||||||
|
const parsed = FeedbackBody.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||||
|
}
|
||||||
|
const { id: chatId, message_id: messageId } = req.params;
|
||||||
|
const { value } = parsed.data;
|
||||||
|
|
||||||
|
const msg = await sql<{ id: string; role: string; metadata: MessageMetadata | null }[]>`
|
||||||
|
SELECT id, role, metadata FROM messages WHERE id = ${messageId} AND chat_id = ${chatId}
|
||||||
|
`;
|
||||||
|
if (msg.length === 0) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'message not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allow feedback on assistant messages.
|
||||||
|
if (msg[0]!.role !== 'assistant') {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'only assistant messages can receive feedback' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if feedback already exists
|
||||||
|
const existingMeta = msg[0]!.metadata;
|
||||||
|
if (existingMeta && existingMeta.kind === 'feedback') {
|
||||||
|
reply.code(409);
|
||||||
|
return { error: 'feedback already recorded' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const feedbackMeta: MessageMetadata = {
|
||||||
|
kind: 'feedback',
|
||||||
|
value,
|
||||||
|
chat_id: chatId,
|
||||||
|
};
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
UPDATE messages
|
||||||
|
SET metadata = ${sql.json(feedbackMeta as never)}, updated_at = clock_timestamp()
|
||||||
|
WHERE id = ${messageId}
|
||||||
|
`;
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ export function registerSessionRoutes(
|
|||||||
}
|
}
|
||||||
const status = req.query.status === 'archived' ? 'archived' : 'open';
|
const status = req.query.status === 'archived' ? 'archived' : 'open';
|
||||||
const rows = await sql<Session[]>`
|
const rows = await sql<Session[]>`
|
||||||
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled, workspace_panes, allowed_read_paths
|
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled, workspace_panes, allowed_read_paths, state_graph_enabled
|
||||||
FROM sessions
|
FROM sessions
|
||||||
WHERE project_id = ${req.params.id} AND status = ${status}
|
WHERE project_id = ${req.params.id} AND status = ${status}
|
||||||
ORDER BY updated_at DESC
|
ORDER BY updated_at DESC
|
||||||
@@ -213,7 +213,7 @@ export function registerSessionRoutes(
|
|||||||
|
|
||||||
app.get<{ Params: { id: string } }>('/api/sessions/:id', async (req, reply) => {
|
app.get<{ Params: { id: string } }>('/api/sessions/:id', async (req, reply) => {
|
||||||
const rows = await sql<Session[]>`
|
const rows = await sql<Session[]>`
|
||||||
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled, workspace_panes, allowed_read_paths
|
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled, workspace_panes, allowed_read_paths, state_graph_enabled
|
||||||
FROM sessions WHERE id = ${req.params.id}
|
FROM sessions WHERE id = ${req.params.id}
|
||||||
`;
|
`;
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
@@ -352,7 +352,7 @@ export function registerSessionRoutes(
|
|||||||
updated_at = clock_timestamp()
|
updated_at = clock_timestamp()
|
||||||
WHERE id = ${req.params.id}
|
WHERE id = ${req.params.id}
|
||||||
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at,
|
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at,
|
||||||
agent_id, web_search_enabled, workspace_panes, allowed_read_paths
|
agent_id, web_search_enabled, workspace_panes, allowed_read_paths, state_graph_enabled
|
||||||
`;
|
`;
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
reply.code(404);
|
reply.code(404);
|
||||||
|
|||||||
@@ -234,6 +234,7 @@ ALTER TABLE sessions ADD COLUMN IF NOT EXISTS workspace_panes JSONB NOT NULL DEF
|
|||||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'open';
|
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'open';
|
||||||
|
|
||||||
-- v1.2: chats table
|
-- v1.2: chats table
|
||||||
|
-- per-chat-model-switching v2.x: ALTER below adds the model override column.
|
||||||
CREATE TABLE IF NOT EXISTS chats (
|
CREATE TABLE IF NOT EXISTS chats (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||||
@@ -244,6 +245,9 @@ CREATE TABLE IF NOT EXISTS chats (
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_chats_session_status ON chats (session_id, status, updated_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_chats_session_status ON chats (session_id, status, updated_at DESC);
|
||||||
|
|
||||||
|
-- v2.7.x: per-chat model override. NULL = inherit from session.model.
|
||||||
|
ALTER TABLE chats ADD COLUMN IF NOT EXISTS model TEXT;
|
||||||
|
|
||||||
-- v1.2: messages.chat_id + messages.kind
|
-- v1.2: messages.chat_id + messages.kind
|
||||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS chat_id UUID REFERENCES chats(id) ON DELETE CASCADE;
|
ALTER TABLE messages ADD COLUMN IF NOT EXISTS chat_id UUID REFERENCES chats(id) ON DELETE CASCADE;
|
||||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS kind TEXT NOT NULL DEFAULT 'message';
|
ALTER TABLE messages ADD COLUMN IF NOT EXISTS kind TEXT NOT NULL DEFAULT 'message';
|
||||||
@@ -320,6 +324,9 @@ BEGIN
|
|||||||
END IF;
|
END IF;
|
||||||
END $$;
|
END $$;
|
||||||
|
|
||||||
|
-- per-chat-model-switching: per-chat model override. NULL = inherit from session model.
|
||||||
|
ALTER TABLE chats ADD COLUMN IF NOT EXISTS model TEXT;
|
||||||
|
|
||||||
-- v1.x-batch9: per-session agent reference. Agent definitions are not stored in
|
-- 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.
|
-- 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".
|
-- agent_id is the slugified agent name. NULL means "use BooCode defaults".
|
||||||
@@ -355,6 +362,11 @@ INSERT INTO settings (key, value) VALUES ('theme_mode', '"dark"') ON CONFLICT (k
|
|||||||
ALTER TABLE projects ADD COLUMN IF NOT EXISTS default_system_prompt TEXT NOT NULL DEFAULT '';
|
ALTER TABLE projects ADD COLUMN IF NOT EXISTS default_system_prompt TEXT NOT NULL DEFAULT '';
|
||||||
ALTER TABLE projects ADD COLUMN IF NOT EXISTS default_web_search_enabled BOOLEAN NOT NULL DEFAULT false;
|
ALTER TABLE projects ADD COLUMN IF NOT EXISTS default_web_search_enabled BOOLEAN NOT NULL DEFAULT false;
|
||||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS web_search_enabled BOOLEAN;
|
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS web_search_enabled BOOLEAN;
|
||||||
|
|
||||||
|
-- v[state-graph]: optional declarative state-graph engine flag. Default OFF
|
||||||
|
-- (existing procedural while loop). When ON, runAssistantTurn routes
|
||||||
|
-- through runGraph in state-graph.ts for node-based execution.
|
||||||
|
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS state_graph_enabled BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
ALTER TABLE sessions DROP COLUMN IF EXISTS tags;
|
ALTER TABLE sessions DROP COLUMN IF EXISTS tags;
|
||||||
|
|
||||||
-- v1.11: anchored rolling compaction.
|
-- v1.11: anchored rolling compaction.
|
||||||
|
|||||||
93
apps/server/src/services/export-formatter.ts
Normal file
93
apps/server/src/services/export-formatter.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import type { Chat, Message } from '../types/api.js';
|
||||||
|
|
||||||
|
interface ExportMessage {
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
model: string | null;
|
||||||
|
created_at: string;
|
||||||
|
tokens_used: number | null;
|
||||||
|
status: string;
|
||||||
|
kind: string;
|
||||||
|
tool_calls: Record<string, unknown>[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExportJson {
|
||||||
|
chat: {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
model: string | null;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
messages: ExportMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatJson(
|
||||||
|
chat: Chat,
|
||||||
|
messages: Message[],
|
||||||
|
model: string | null,
|
||||||
|
): string {
|
||||||
|
const data: ExportJson = {
|
||||||
|
chat: {
|
||||||
|
id: chat.id,
|
||||||
|
name: chat.name,
|
||||||
|
model,
|
||||||
|
created_at: chat.created_at,
|
||||||
|
},
|
||||||
|
messages: messages.map((m) => ({
|
||||||
|
role: m.role,
|
||||||
|
content: m.content,
|
||||||
|
model: m.model ?? null,
|
||||||
|
created_at: m.created_at,
|
||||||
|
tokens_used: m.tokens_used,
|
||||||
|
status: m.status,
|
||||||
|
kind: m.kind,
|
||||||
|
tool_calls: m.tool_calls as Record<string, unknown>[] | null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
return JSON.stringify(data, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMarkdown(
|
||||||
|
chat: Chat,
|
||||||
|
messages: Message[],
|
||||||
|
model: string | null,
|
||||||
|
): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
parts.push(`# ${chat.name ?? 'Untitled Chat'}`);
|
||||||
|
parts.push(`Model: ${model ?? 'unknown'}`);
|
||||||
|
parts.push('');
|
||||||
|
parts.push('---');
|
||||||
|
parts.push('');
|
||||||
|
|
||||||
|
for (const msg of messages) {
|
||||||
|
// Skip system/sentinel messages for a cleaner transcript
|
||||||
|
if (msg.role === 'system') continue;
|
||||||
|
|
||||||
|
const label =
|
||||||
|
msg.role === 'user'
|
||||||
|
? 'User'
|
||||||
|
: msg.role === 'assistant'
|
||||||
|
? 'Assistant'
|
||||||
|
: 'Tool';
|
||||||
|
parts.push(`## ${label}`);
|
||||||
|
parts.push('');
|
||||||
|
|
||||||
|
if (msg.content) {
|
||||||
|
parts.push(msg.content);
|
||||||
|
parts.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.tool_calls && msg.tool_calls.length > 0) {
|
||||||
|
for (const tc of msg.tool_calls) {
|
||||||
|
parts.push(`> \`${tc.name}\``);
|
||||||
|
parts.push('');
|
||||||
|
parts.push('```json');
|
||||||
|
parts.push(JSON.stringify(tc.args, null, 2));
|
||||||
|
parts.push('```');
|
||||||
|
parts.push('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join('\n');
|
||||||
|
}
|
||||||
@@ -74,6 +74,7 @@ export async function handleAbortOrError(
|
|||||||
type: 'message_complete',
|
type: 'message_complete',
|
||||||
message_id: assistantMessageId,
|
message_id: assistantMessageId,
|
||||||
chat_id: chatId,
|
chat_id: chatId,
|
||||||
|
...(args.compareGroupId ? { compare_group_id: args.compareGroupId } : {}),
|
||||||
});
|
});
|
||||||
ctx.log.info({ sessionId, chatId, assistantMessageId }, 'inference cancelled');
|
ctx.log.info({ sessionId, chatId, assistantMessageId }, 'inference cancelled');
|
||||||
} else {
|
} else {
|
||||||
@@ -90,6 +91,7 @@ export async function handleAbortOrError(
|
|||||||
chat_id: chatId,
|
chat_id: chatId,
|
||||||
error: errMsg,
|
error: errMsg,
|
||||||
reason: 'llm_provider_error',
|
reason: 'llm_provider_error',
|
||||||
|
...(args.compareGroupId ? { compare_group_id: args.compareGroupId } : {}),
|
||||||
});
|
});
|
||||||
ctx.log.error({ err, sessionId, assistantMessageId }, 'inference failed');
|
ctx.log.error({ err, sessionId, assistantMessageId }, 'inference failed');
|
||||||
}
|
}
|
||||||
@@ -125,6 +127,7 @@ export async function finalizeStreamedRow(
|
|||||||
cacheTokens?: number | null;
|
cacheTokens?: number | null;
|
||||||
reasoningTokens?: number | null;
|
reasoningTokens?: number | null;
|
||||||
beforeComplete?: () => Promise<void>;
|
beforeComplete?: () => Promise<void>;
|
||||||
|
compareGroupId?: string;
|
||||||
},
|
},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// v1.11.3: see executeToolPhase for the rationale.
|
// v1.11.3: see executeToolPhase for the rationale.
|
||||||
@@ -158,6 +161,7 @@ export async function finalizeStreamedRow(
|
|||||||
started_at: opts.startedAt,
|
started_at: opts.startedAt,
|
||||||
finished_at: updated?.finished_at ?? null,
|
finished_at: updated?.finished_at ?? null,
|
||||||
model: opts.model,
|
model: opts.model,
|
||||||
|
...(opts.compareGroupId ? { compare_group_id: opts.compareGroupId } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,6 +186,7 @@ export async function finalizeEmpty(
|
|||||||
type: 'message_complete',
|
type: 'message_complete',
|
||||||
message_id: assistantMessageId,
|
message_id: assistantMessageId,
|
||||||
chat_id: chatId,
|
chat_id: chatId,
|
||||||
|
...(args.compareGroupId ? { compare_group_id: args.compareGroupId } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,6 +286,7 @@ export async function finalizeCompletion(
|
|||||||
started_at: startedAt,
|
started_at: startedAt,
|
||||||
finished_at: updated?.finished_at ?? null,
|
finished_at: updated?.finished_at ?? null,
|
||||||
model: session.model,
|
model: session.model,
|
||||||
|
...(args.compareGroupId ? { compare_group_id: args.compareGroupId } : {}),
|
||||||
});
|
});
|
||||||
ctx.log.info(
|
ctx.log.info(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export {
|
|||||||
createInferenceRunner,
|
createInferenceRunner,
|
||||||
MAX_STEPS,
|
MAX_STEPS,
|
||||||
runInference,
|
runInference,
|
||||||
|
runInferenceWithModel,
|
||||||
} from './turn.js';
|
} from './turn.js';
|
||||||
// P5: the shared pipeline types moved from turn.ts to types.ts (breaking the
|
// P5: the shared pipeline types moved from turn.ts to types.ts (breaking the
|
||||||
// hub-and-leaf near-cycle). Re-exported here so the public surface is unchanged.
|
// hub-and-leaf near-cycle). Re-exported here so the public surface is unchanged.
|
||||||
@@ -21,3 +22,4 @@ export type {
|
|||||||
export type { ToolPhaseResult } from './tool-phase.js';
|
export type { ToolPhaseResult } from './tool-phase.js';
|
||||||
export { detectDoomLoop, DOOM_LOOP_THRESHOLD } from './sentinels.js';
|
export { detectDoomLoop, DOOM_LOOP_THRESHOLD } from './sentinels.js';
|
||||||
export { buildMessagesPayload } from './payload.js';
|
export { buildMessagesPayload } from './payload.js';
|
||||||
|
export { runGraph, type GraphNodeType, type GraphState, type GraphResult } from './state-graph.js';
|
||||||
|
|||||||
@@ -194,6 +194,14 @@ export async function buildMessagesPayload(
|
|||||||
out.push(msg);
|
out.push(msg);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// TODO(vDeepSeek): when m has image attachments, use a content array
|
||||||
|
// with text + image parts (see multi-modal.ts:imageAttachmentsToParts).
|
||||||
|
// The AI SDK ModelMessage content shape supports:
|
||||||
|
// content: [
|
||||||
|
// { type: 'text', text: '...' },
|
||||||
|
// { type: 'image', image: 'data:image/png;base64,...' }
|
||||||
|
// ]
|
||||||
|
// The @ai-sdk/deepseek provider handles the image parts natively.
|
||||||
out.push({ role: 'user', content: m.content });
|
out.push({ role: 'user', content: m.content });
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
@@ -206,7 +214,7 @@ export async function loadContext(
|
|||||||
): Promise<{ session: Session; project: Project; history: Message[] } | null> {
|
): Promise<{ session: Session; project: Project; history: Message[] } | null> {
|
||||||
const sessionRows = await sql<Session[]>`
|
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, web_search_enabled, allowed_read_paths
|
agent_id, web_search_enabled, allowed_read_paths, state_graph_enabled
|
||||||
FROM sessions WHERE id = ${sessionId}
|
FROM sessions WHERE id = ${sessionId}
|
||||||
`;
|
`;
|
||||||
if (sessionRows.length === 0) return null;
|
if (sessionRows.length === 0) return null;
|
||||||
|
|||||||
531
apps/server/src/services/inference/state-graph.ts
Normal file
531
apps/server/src/services/inference/state-graph.ts
Normal file
@@ -0,0 +1,531 @@
|
|||||||
|
// P5: Optional declarative state graph engine for the inference turn loop.
|
||||||
|
//
|
||||||
|
// Replaces the procedural `while (stepNumber < effectiveCap)` in turn.ts
|
||||||
|
// with a node-based execution model. Default OFF via
|
||||||
|
// session.state_graph_enabled — zero behavior change when disabled.
|
||||||
|
//
|
||||||
|
// Nodes wrap EXISTING infrastructure (no new I/O patterns):
|
||||||
|
// PLAN → top-of-loop gate, compaction, loadContext, buildMessagesPayload,
|
||||||
|
// executeStreamPhase
|
||||||
|
// CALL_TOOL → executeToolPhase
|
||||||
|
// OBSERVE → process tool results, update loop locals
|
||||||
|
// REFLECT → decidePostToolAction, sentinel insertion, mistake tracker
|
||||||
|
// SYNTHESIZE → terminal (graph loop exits)
|
||||||
|
|
||||||
|
import type { Agent, Project, Session, ToolCall } from '../../types/api.js';
|
||||||
|
import { resolveProjectRoot } from '../path_guard.js';
|
||||||
|
import { rewriteSearchQuery } from '../task-search-rewrite.js';
|
||||||
|
import * as compaction from '../compaction.js';
|
||||||
|
import { decideStep, decidePostToolAction } from './step-decision.js';
|
||||||
|
import {
|
||||||
|
recordStep,
|
||||||
|
MISTAKE_RECOVERY_NOTE,
|
||||||
|
type MistakeState,
|
||||||
|
} from './mistake-tracker.js';
|
||||||
|
import {
|
||||||
|
buildMessagesPayload,
|
||||||
|
loadContext,
|
||||||
|
} from './payload.js';
|
||||||
|
import { toDcpMessages, transformMessages, fromDcpMessages } from './dcp/index.js';
|
||||||
|
import {
|
||||||
|
finalizeCompletion,
|
||||||
|
finalizeEmpty,
|
||||||
|
handleAbortOrError,
|
||||||
|
} from './error-handler.js';
|
||||||
|
import {
|
||||||
|
executeStreamPhase,
|
||||||
|
} from './stream-phase.js';
|
||||||
|
import { executeToolPhase, type ToolPhaseResult } from './tool-phase.js';
|
||||||
|
import type {
|
||||||
|
InferenceContext,
|
||||||
|
StreamPhaseState,
|
||||||
|
StreamResult,
|
||||||
|
TurnArgs,
|
||||||
|
} from './types.js';
|
||||||
|
import {
|
||||||
|
runCapHitSummary,
|
||||||
|
runDoomLoopSummary,
|
||||||
|
insertMistakeRecoverySentinel,
|
||||||
|
} from './sentinel-summaries.js';
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { readFileSync, existsSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const BUILD_TIMEOUT_MS = 60_000;
|
||||||
|
const BUILD_OUTPUT_CAP = 8_000;
|
||||||
|
|
||||||
|
async function detectAndRunBuild(
|
||||||
|
ctx: InferenceContext,
|
||||||
|
projectRoot: string,
|
||||||
|
sessionId: string,
|
||||||
|
chatId: string,
|
||||||
|
model: string,
|
||||||
|
existingNote: string | undefined,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
if (!model.startsWith('deepseek-')) return undefined;
|
||||||
|
const pkgPath = join(projectRoot, 'package.json');
|
||||||
|
if (!existsSync(pkgPath)) return undefined;
|
||||||
|
let buildCmd: string | null = null;
|
||||||
|
try {
|
||||||
|
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { scripts?: Record<string, string> };
|
||||||
|
if (pkg.scripts?.build) buildCmd = 'build';
|
||||||
|
else if (pkg.scripts?.compile) buildCmd = 'compile';
|
||||||
|
else if (pkg.scripts?.typecheck) buildCmd = 'typecheck';
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!buildCmd) return undefined;
|
||||||
|
const hasPnpm = existsSync(join(projectRoot, 'pnpm-lock.yaml'));
|
||||||
|
const hasYarn = existsSync(join(projectRoot, 'yarn.lock'));
|
||||||
|
const pm = hasPnpm ? 'pnpm' : hasYarn ? 'yarn' : 'npm';
|
||||||
|
try {
|
||||||
|
const out = await new Promise<string>((resolve, reject) => {
|
||||||
|
execFile(pm, ['run', buildCmd!], { cwd: projectRoot, timeout: BUILD_TIMEOUT_MS, maxBuffer: BUILD_OUTPUT_CAP * 2 },
|
||||||
|
(err, stdout, stderr) => {
|
||||||
|
if (err && (err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
resolve('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const merged = (stdout + '\n' + stderr).trim();
|
||||||
|
resolve(merged.slice(0, BUILD_OUTPUT_CAP));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (!out) return undefined;
|
||||||
|
ctx.log.info({ sessionId, chatId, buildCmd, outputLen: out.length }, 'auto-fix: build failed');
|
||||||
|
const combined = existingNote
|
||||||
|
? existingNote + '\n\n--- Build error ---\n' + out.slice(0, BUILD_OUTPUT_CAP - existingNote.length)
|
||||||
|
: '--- Build error ---\n' + out.slice(0, BUILD_OUTPUT_CAP);
|
||||||
|
return combined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Types ----------------------------------------------------------------
|
||||||
|
|
||||||
|
export type GraphNodeType = 'PLAN' | 'CALL_TOOL' | 'OBSERVE' | 'REFLECT' | 'SYNTHESIZE';
|
||||||
|
|
||||||
|
export interface GraphState {
|
||||||
|
stepNumber: number;
|
||||||
|
toolsUsed: number;
|
||||||
|
recentToolCalls: ToolCall[];
|
||||||
|
assistantMessageId: string;
|
||||||
|
mistakeTracker: MistakeState;
|
||||||
|
pendingRecoveryNote?: string;
|
||||||
|
effectiveCap: number;
|
||||||
|
budget: number;
|
||||||
|
projectRoot: string;
|
||||||
|
iterSession?: Session;
|
||||||
|
iterProject?: Project;
|
||||||
|
streamResult?: StreamResult;
|
||||||
|
startedAt?: string | null;
|
||||||
|
toolPhaseResult?: ToolPhaseResult;
|
||||||
|
shouldStop: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GraphNode {
|
||||||
|
type: GraphNodeType;
|
||||||
|
edges: Array<{ to: GraphNodeType; condition: (state: GraphState) => boolean }>;
|
||||||
|
execute: (
|
||||||
|
ctx: InferenceContext,
|
||||||
|
args: TurnArgs,
|
||||||
|
state: GraphState,
|
||||||
|
agent: Agent | null,
|
||||||
|
) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphResult {
|
||||||
|
stepNumber: number;
|
||||||
|
assistantMessageId: string;
|
||||||
|
toolsUsed: number;
|
||||||
|
recentToolCalls: ToolCall[];
|
||||||
|
mistakeTracker: MistakeState;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Default graph --------------------------------------------------------
|
||||||
|
|
||||||
|
export function createDefaultGraph(): GraphNode[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'PLAN',
|
||||||
|
edges: [
|
||||||
|
{ to: 'CALL_TOOL', condition: (s) => !!s.streamResult && s.streamResult.toolCalls.length > 0 },
|
||||||
|
{ to: 'SYNTHESIZE', condition: () => true },
|
||||||
|
],
|
||||||
|
execute: planNode,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'CALL_TOOL',
|
||||||
|
edges: [
|
||||||
|
{ to: 'OBSERVE', condition: () => true },
|
||||||
|
],
|
||||||
|
execute: callToolNode,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'OBSERVE',
|
||||||
|
edges: [
|
||||||
|
{ to: 'REFLECT', condition: (s) => s.toolPhaseResult?.action === 'continue' },
|
||||||
|
{ to: 'SYNTHESIZE', condition: () => true },
|
||||||
|
],
|
||||||
|
execute: observeNode,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'REFLECT',
|
||||||
|
edges: [
|
||||||
|
{ to: 'PLAN', condition: (s) => s.stepNumber < s.effectiveCap },
|
||||||
|
{ to: 'SYNTHESIZE', condition: () => true },
|
||||||
|
],
|
||||||
|
execute: reflectNode,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'SYNTHESIZE',
|
||||||
|
edges: [],
|
||||||
|
execute: async () => {},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Graph runner ---------------------------------------------------------
|
||||||
|
|
||||||
|
export async function runGraph(
|
||||||
|
ctx: InferenceContext,
|
||||||
|
args: TurnArgs,
|
||||||
|
extra: { effectiveCap: number; budget: number; agent: Agent | null; projectRoot: string },
|
||||||
|
): Promise<GraphResult> {
|
||||||
|
const { effectiveCap, budget, agent } = extra;
|
||||||
|
|
||||||
|
const state: GraphState = {
|
||||||
|
stepNumber: 0,
|
||||||
|
toolsUsed: args.toolsUsed,
|
||||||
|
recentToolCalls: args.recentToolCalls,
|
||||||
|
assistantMessageId: args.assistantMessageId,
|
||||||
|
mistakeTracker: args.mistakeTracker,
|
||||||
|
pendingRecoveryNote: args.pendingRecoveryNote,
|
||||||
|
effectiveCap,
|
||||||
|
budget,
|
||||||
|
projectRoot: extra.projectRoot,
|
||||||
|
shouldStop: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const graph = createDefaultGraph();
|
||||||
|
let currentNode: GraphNodeType = 'PLAN';
|
||||||
|
|
||||||
|
while (currentNode !== 'SYNTHESIZE' && !state.shouldStop) {
|
||||||
|
const node = graph.find((n) => n.type === currentNode)!;
|
||||||
|
await node.execute(ctx, args, state, agent);
|
||||||
|
if (state.shouldStop) break;
|
||||||
|
const nextEdge = node.edges.find((e) => e.condition(state));
|
||||||
|
if (!nextEdge) break;
|
||||||
|
currentNode = nextEdge.to;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
stepNumber: state.stepNumber,
|
||||||
|
assistantMessageId: state.assistantMessageId,
|
||||||
|
toolsUsed: state.toolsUsed,
|
||||||
|
recentToolCalls: state.recentToolCalls,
|
||||||
|
mistakeTracker: state.mistakeTracker,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- PLAN node ------------------------------------------------------------
|
||||||
|
// Top-of-loop gate → compaction → loadContext → DCP → buildPayload → stream
|
||||||
|
|
||||||
|
async function planNode(
|
||||||
|
ctx: InferenceContext,
|
||||||
|
args: TurnArgs,
|
||||||
|
state: GraphState,
|
||||||
|
agent: Agent | null,
|
||||||
|
): Promise<void> {
|
||||||
|
const { sessionId, chatId, signal } = args;
|
||||||
|
|
||||||
|
// 1. Top-of-loop gate: doom-loop, then budget (pure decisions)
|
||||||
|
const decision = decideStep({
|
||||||
|
recentToolCalls: state.recentToolCalls,
|
||||||
|
toolsUsed: state.toolsUsed,
|
||||||
|
budget: state.budget,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (decision.kind === 'doom') {
|
||||||
|
const loaded = await loadContext(ctx.sql, sessionId, chatId);
|
||||||
|
if (loaded) {
|
||||||
|
const dlSession = args.modelOverride ? { ...loaded.session, model: args.modelOverride } : loaded.session;
|
||||||
|
const iterArgs: TurnArgs = {
|
||||||
|
sessionId, chatId, assistantMessageId: state.assistantMessageId,
|
||||||
|
toolsUsed: state.toolsUsed, recentToolCalls: state.recentToolCalls,
|
||||||
|
mistakeTracker: state.mistakeTracker, signal,
|
||||||
|
};
|
||||||
|
await runDoomLoopSummary(ctx, iterArgs, dlSession, loaded.project, loaded.history, agent, decision.loop);
|
||||||
|
}
|
||||||
|
state.shouldStop = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decision.kind === 'budget') {
|
||||||
|
const loaded = await loadContext(ctx.sql, sessionId, chatId);
|
||||||
|
if (loaded) {
|
||||||
|
const bhSession = args.modelOverride ? { ...loaded.session, model: args.modelOverride } : loaded.session;
|
||||||
|
const iterArgs: TurnArgs = {
|
||||||
|
sessionId, chatId, assistantMessageId: state.assistantMessageId,
|
||||||
|
toolsUsed: state.toolsUsed, recentToolCalls: state.recentToolCalls,
|
||||||
|
mistakeTracker: state.mistakeTracker, signal,
|
||||||
|
};
|
||||||
|
await runCapHitSummary(ctx, iterArgs, bhSession, loaded.project, loaded.history, agent, state.budget);
|
||||||
|
}
|
||||||
|
state.shouldStop = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// decision.kind === 'stream' → proceed.
|
||||||
|
|
||||||
|
// 2. Compaction check
|
||||||
|
const chatFlag = await ctx.sql<{ needs_compaction: boolean }[]>`
|
||||||
|
SELECT needs_compaction FROM chats WHERE id = ${chatId}
|
||||||
|
`;
|
||||||
|
if (chatFlag[0]?.needs_compaction) {
|
||||||
|
try {
|
||||||
|
await compaction.process({
|
||||||
|
sql: ctx.sql, config: ctx.config, log: ctx.log,
|
||||||
|
broker: ctx.broker, chatId, hooks: ctx.hooks,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
ctx.log.warn({ err, chatId }, 'auto-compaction failed; clearing flag and proceeding');
|
||||||
|
await ctx.sql`UPDATE chats SET needs_compaction = false WHERE id = ${chatId}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Load context (must re-load each iteration — new messages)
|
||||||
|
const loaded = await loadContext(ctx.sql, sessionId, chatId);
|
||||||
|
if (!loaded) {
|
||||||
|
ctx.log.warn({ sessionId }, 'inference: session or project missing mid-loop');
|
||||||
|
state.shouldStop = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let { session: iterSession, project: iterProject, history } = loaded;
|
||||||
|
if (args.modelOverride) {
|
||||||
|
iterSession = { ...iterSession, model: args.modelOverride };
|
||||||
|
}
|
||||||
|
state.iterSession = iterSession;
|
||||||
|
state.iterProject = iterProject;
|
||||||
|
const projectRoot = await resolveProjectRoot(iterProject.path);
|
||||||
|
state.projectRoot = projectRoot;
|
||||||
|
|
||||||
|
// 4. DCP transform
|
||||||
|
try {
|
||||||
|
const dcpMsgs = toDcpMessages(history);
|
||||||
|
const { messages: pruned, stats } = transformMessages(chatId, dcpMsgs);
|
||||||
|
if (stats.removedCount > 0) {
|
||||||
|
ctx.log.info({ chatId, ...stats }, 'dcp: transform removed messages');
|
||||||
|
history = fromDcpMessages(pruned) as typeof history;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
ctx.log.warn({ err: err instanceof Error ? err.message : String(err), chatId }, 'dcp: transform skipped');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Log step boundary
|
||||||
|
ctx.log.info(
|
||||||
|
{ sessionId, chatId, step: state.stepNumber, assistantMessageId: state.assistantMessageId },
|
||||||
|
'step_start',
|
||||||
|
);
|
||||||
|
|
||||||
|
// 6. Build messages + stream phase
|
||||||
|
const messages = await buildMessagesPayload(iterSession, iterProject, history, agent, ctx.log);
|
||||||
|
const webToolsEnabled =
|
||||||
|
iterSession.web_search_enabled ?? iterProject.default_web_search_enabled ?? false;
|
||||||
|
|
||||||
|
if (state.stepNumber === 0 && webToolsEnabled && messages.length >= 2) {
|
||||||
|
const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user');
|
||||||
|
if (lastUserMsg?.content) {
|
||||||
|
const hint = await rewriteSearchQuery(lastUserMsg.content);
|
||||||
|
if (hint && messages[0]?.role === 'system' && messages[0].content) {
|
||||||
|
messages[0].content += `\n\nThe user's search intent can be summarized as: "${hint}"`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.pendingRecoveryNote) {
|
||||||
|
messages.push({ role: 'system', content: state.pendingRecoveryNote });
|
||||||
|
state.pendingRecoveryNote = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Stream phase
|
||||||
|
const iterArgs: TurnArgs = {
|
||||||
|
sessionId, chatId, assistantMessageId: state.assistantMessageId,
|
||||||
|
toolsUsed: state.toolsUsed, recentToolCalls: state.recentToolCalls,
|
||||||
|
mistakeTracker: state.mistakeTracker, signal,
|
||||||
|
};
|
||||||
|
const streamState: StreamPhaseState = { accumulated: '', startedAt: null };
|
||||||
|
try {
|
||||||
|
const result = await executeStreamPhase(ctx, iterArgs, iterSession, messages, streamState, agent, webToolsEnabled);
|
||||||
|
state.streamResult = result;
|
||||||
|
state.startedAt = streamState.startedAt;
|
||||||
|
|
||||||
|
// Non-tool finish: Stop hook + finalize here (edge from PLAN → SYNTHESIZE
|
||||||
|
// will break the graph loop after this node returns).
|
||||||
|
if (result.toolCalls.length === 0) {
|
||||||
|
if (ctx.hooks) {
|
||||||
|
ctx.hooks.run('Stop', {
|
||||||
|
event: 'Stop',
|
||||||
|
session_id: sessionId,
|
||||||
|
chat_id: chatId,
|
||||||
|
last_assistant_text: result.content.slice(0, 500),
|
||||||
|
turn: state.stepNumber,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
await finalizeCompletion(ctx, iterArgs, result, streamState.startedAt, iterSession);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
await handleAbortOrError(ctx, iterArgs, streamState.accumulated, err);
|
||||||
|
state.shouldStop = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- CALL_TOOL node -------------------------------------------------------
|
||||||
|
// Executes the tool phase and stores the result for OBSERVE.
|
||||||
|
|
||||||
|
async function callToolNode(
|
||||||
|
ctx: InferenceContext,
|
||||||
|
args: TurnArgs,
|
||||||
|
state: GraphState,
|
||||||
|
agent: Agent | null,
|
||||||
|
): Promise<void> {
|
||||||
|
const { sessionId, chatId } = args;
|
||||||
|
const result = state.streamResult;
|
||||||
|
if (!result) {
|
||||||
|
ctx.log.warn({ sessionId }, 'state-graph: CALL_TOOL without stream result');
|
||||||
|
state.shouldStop = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const session = state.iterSession;
|
||||||
|
if (!session) {
|
||||||
|
ctx.log.warn({ sessionId }, 'state-graph: CALL_TOOL without iterSession');
|
||||||
|
state.shouldStop = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
state.toolPhaseResult = await executeToolPhase(
|
||||||
|
ctx, args, result, state.startedAt ?? null,
|
||||||
|
session, state.projectRoot, agent, state.stepNumber,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
ctx.log.error({ err, sessionId, chatId, step: state.stepNumber }, 'tool phase threw unexpectedly');
|
||||||
|
state.shouldStop = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- OBSERVE node ---------------------------------------------------------
|
||||||
|
// Processes tool results: updates loop locals, mistake tracking, build errors.
|
||||||
|
|
||||||
|
async function observeNode(
|
||||||
|
ctx: InferenceContext,
|
||||||
|
args: TurnArgs,
|
||||||
|
state: GraphState,
|
||||||
|
_agent: Agent | null,
|
||||||
|
): Promise<void> {
|
||||||
|
const { sessionId, chatId } = args;
|
||||||
|
const tpr = state.toolPhaseResult;
|
||||||
|
if (!tpr) {
|
||||||
|
state.shouldStop = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update loop locals (mirrors the existing while-loop post-tool logic)
|
||||||
|
state.toolsUsed += tpr.toolCallCount;
|
||||||
|
state.recentToolCalls = [...state.recentToolCalls, ...tpr.toolCalls];
|
||||||
|
state.stepNumber++;
|
||||||
|
|
||||||
|
// Fold tool outcomes into the mistake tracker
|
||||||
|
for (const o of tpr.outcomes) {
|
||||||
|
recordStep(state.mistakeTracker, o);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-fix: after write tools, attempt build and inject errors.
|
||||||
|
const WRITE_TOOLS = new Set(['edit_file', 'create_file', 'delete_file', 'apply_pending']);
|
||||||
|
const hasWriteTools = tpr.toolCalls.some((tc) => WRITE_TOOLS.has(tc.name));
|
||||||
|
if (hasWriteTools && state.iterSession) {
|
||||||
|
detectAndRunBuild(ctx, state.projectRoot, sessionId, chatId, state.iterSession.model, state.pendingRecoveryNote)
|
||||||
|
.then((buildError) => {
|
||||||
|
if (buildError) state.pendingRecoveryNote = buildError;
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- REFLECT node ---------------------------------------------------------
|
||||||
|
// Post-tool decision: decidePostToolAction, nudge/escalate/continue handling.
|
||||||
|
|
||||||
|
async function reflectNode(
|
||||||
|
ctx: InferenceContext,
|
||||||
|
args: TurnArgs,
|
||||||
|
state: GraphState,
|
||||||
|
_agent: Agent | null,
|
||||||
|
): Promise<void> {
|
||||||
|
const { sessionId, chatId, signal } = args;
|
||||||
|
const tpr = state.toolPhaseResult;
|
||||||
|
if (!tpr) {
|
||||||
|
state.shouldStop = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const post = decidePostToolAction(tpr.action, state.mistakeTracker);
|
||||||
|
|
||||||
|
if (post === 'stop') {
|
||||||
|
state.shouldStop = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (post === 'nudge') {
|
||||||
|
state.pendingRecoveryNote = MISTAKE_RECOVERY_NOTE;
|
||||||
|
const failureKinds = [...state.mistakeTracker.run];
|
||||||
|
await insertMistakeRecoverySentinel(ctx, sessionId, chatId, {
|
||||||
|
failureKinds,
|
||||||
|
count: failureKinds.length,
|
||||||
|
escalated: false,
|
||||||
|
canContinue: true,
|
||||||
|
});
|
||||||
|
state.mistakeTracker.nudges += 1;
|
||||||
|
state.mistakeTracker.run = [];
|
||||||
|
ctx.log.info(
|
||||||
|
{ sessionId, chatId, step: state.stepNumber, nudges: state.mistakeTracker.nudges, failureKinds },
|
||||||
|
'mistake_recovery nudge',
|
||||||
|
);
|
||||||
|
// Continue to next PLAN node — edges check step < cap.
|
||||||
|
if (state.assistantMessageId !== tpr.nextAssistantId && tpr.nextAssistantId) {
|
||||||
|
state.assistantMessageId = tpr.nextAssistantId;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (post === 'escalate') {
|
||||||
|
const failureKinds = [...state.mistakeTracker.run];
|
||||||
|
if (tpr.nextAssistantId) {
|
||||||
|
state.assistantMessageId = tpr.nextAssistantId;
|
||||||
|
}
|
||||||
|
const escalateArgs: TurnArgs = {
|
||||||
|
sessionId, chatId, assistantMessageId: state.assistantMessageId,
|
||||||
|
toolsUsed: state.toolsUsed, recentToolCalls: state.recentToolCalls,
|
||||||
|
mistakeTracker: state.mistakeTracker, signal,
|
||||||
|
};
|
||||||
|
await finalizeEmpty(ctx, escalateArgs);
|
||||||
|
await insertMistakeRecoverySentinel(ctx, sessionId, chatId, {
|
||||||
|
failureKinds,
|
||||||
|
count: failureKinds.length,
|
||||||
|
escalated: true,
|
||||||
|
canContinue: true,
|
||||||
|
});
|
||||||
|
ctx.publishUser({ type: 'chat_status', chat_id: chatId, status: 'idle', at: new Date().toISOString() });
|
||||||
|
ctx.log.info(
|
||||||
|
{ sessionId, chatId, step: state.stepNumber, failureKinds },
|
||||||
|
'mistake_recovery escalate — stopping turn',
|
||||||
|
);
|
||||||
|
state.shouldStop = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 'continue' — advance to next assistant message.
|
||||||
|
if (tpr.nextAssistantId) {
|
||||||
|
state.assistantMessageId = tpr.nextAssistantId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,6 +56,7 @@ export async function executeStreamPhase(
|
|||||||
message_id: assistantMessageId,
|
message_id: assistantMessageId,
|
||||||
chat_id: chatId,
|
chat_id: chatId,
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
|
...(args.compareGroupId ? { compare_group_id: args.compareGroupId } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const flusher = createContentFlusher(ctx.sql, assistantMessageId, () => state.accumulated);
|
const flusher = createContentFlusher(ctx.sql, assistantMessageId, () => state.accumulated);
|
||||||
@@ -119,6 +120,7 @@ export async function executeStreamPhase(
|
|||||||
message_id: assistantMessageId,
|
message_id: assistantMessageId,
|
||||||
chat_id: chatId,
|
chat_id: chatId,
|
||||||
content: delta,
|
content: delta,
|
||||||
|
...(args.compareGroupId ? { compare_group_id: args.compareGroupId } : {}),
|
||||||
});
|
});
|
||||||
ctx.log.debug({ sessionId, delta }, 'inference delta');
|
ctx.log.debug({ sessionId, delta }, 'inference delta');
|
||||||
flusher.scheduleFlush();
|
flusher.scheduleFlush();
|
||||||
|
|||||||
75
apps/server/src/services/inference/supervisor.ts
Normal file
75
apps/server/src/services/inference/supervisor.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
// Supervisor agent: routes user requests to the best agent via a cheap LLM
|
||||||
|
// classification call. Activated when session.agent_id === 'supervisor'.
|
||||||
|
|
||||||
|
import type { Agent } from '../../types/api.js';
|
||||||
|
import { taskModelCompletion } from '../task-model.js';
|
||||||
|
|
||||||
|
export interface SupervisorRoute {
|
||||||
|
agent_id: string;
|
||||||
|
confidence: number;
|
||||||
|
reasoning: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUPERVISOR_SYSTEM_PROMPT = `You are a router. Given the user's request and the available agents, choose the best agent to handle the request.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Match the request to the agent whose description and toolset best fits the task.
|
||||||
|
- For code review / bug finding requests → code-reviewer
|
||||||
|
- For debugging / diagnosing failures → debugger
|
||||||
|
- For refactoring / simplifying code → refactorer
|
||||||
|
- For architecture / design / planning → architect or planner
|
||||||
|
- For security audits → security-auditor
|
||||||
|
- For building prompts for other agents → prompt-builder
|
||||||
|
- For exploring / understanding unfamiliar code → recon
|
||||||
|
- For implementing / writing code changes → builder
|
||||||
|
- Respond with ONLY the agent id (e.g. "builder") or "none" if no agent fits.
|
||||||
|
- Do not include any other text, punctuation, or explanation.`;
|
||||||
|
|
||||||
|
const MAX_ROUTING_TOKENS = 30;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given the user's latest message and available agents, classifies which agent
|
||||||
|
* should handle this turn. Returns null to fall through to default (no agent).
|
||||||
|
*/
|
||||||
|
export async function resolveSupervisorTurn(
|
||||||
|
latestUserMessage: string,
|
||||||
|
agents: Agent[],
|
||||||
|
fallbackModel?: string,
|
||||||
|
): Promise<SupervisorRoute | null> {
|
||||||
|
// Build agent listing — skip the supervisor itself to avoid self-routing.
|
||||||
|
const agentList = agents
|
||||||
|
.filter((a) => a.id !== 'supervisor')
|
||||||
|
.map((a) => `- ${a.id}: ${a.description} (${a.tools.length} tools)`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
if (!agentList) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userPrompt = `Available agents:\n${agentList}\n\nUser request: ${latestUserMessage.slice(0, 2000)}`;
|
||||||
|
|
||||||
|
const response = await taskModelCompletion({
|
||||||
|
system: SUPERVISOR_SYSTEM_PROMPT,
|
||||||
|
user: userPrompt,
|
||||||
|
maxTokens: MAX_ROUTING_TOKENS,
|
||||||
|
temperature: 0.1,
|
||||||
|
fallbackModel,
|
||||||
|
});
|
||||||
|
|
||||||
|
const agentId = response.trim().toLowerCase();
|
||||||
|
if (!agentId || agentId === 'none') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map back to a real agent to validate the id.
|
||||||
|
const matched = agents.find((a) => a.id === agentId);
|
||||||
|
if (!matched) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
agent_id: matched.id,
|
||||||
|
confidence: 1,
|
||||||
|
reasoning: `supervisor routed to "${matched.name}" based on request classification`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import type {
|
|||||||
import { resolveProjectRoot } from '../path_guard.js';
|
import { resolveProjectRoot } from '../path_guard.js';
|
||||||
import { maybeAutoNameChat } from '../auto_name.js';
|
import { maybeAutoNameChat } from '../auto_name.js';
|
||||||
import { rewriteSearchQuery } from '../task-search-rewrite.js';
|
import { rewriteSearchQuery } from '../task-search-rewrite.js';
|
||||||
import { getAgentById } from '../agents.js';
|
import { getAgentById, getAgentsForProject } from '../agents.js';
|
||||||
import * as compaction from '../compaction.js';
|
import * as compaction from '../compaction.js';
|
||||||
import { resolveTurnConfig } from './turn-config.js';
|
import { resolveTurnConfig } from './turn-config.js';
|
||||||
import { decideStep, decidePostToolAction } from './step-decision.js';
|
import { decideStep, decidePostToolAction } from './step-decision.js';
|
||||||
@@ -49,6 +49,8 @@ import {
|
|||||||
runStepCapSummary,
|
runStepCapSummary,
|
||||||
insertMistakeRecoverySentinel,
|
insertMistakeRecoverySentinel,
|
||||||
} from './sentinel-summaries.js';
|
} from './sentinel-summaries.js';
|
||||||
|
import { resolveSupervisorTurn } from './supervisor.js';
|
||||||
|
import { runGraph } from './state-graph.js';
|
||||||
|
|
||||||
// vWhale: auto-fix — detect build command from package.json, run it, return
|
// vWhale: auto-fix — detect build command from package.json, run it, return
|
||||||
// error text for injection into next iteration. Best-effort, never throws.
|
// error text for injection into next iteration. Best-effort, never throws.
|
||||||
@@ -149,10 +151,39 @@ export async function runAssistantTurn(
|
|||||||
ctx.log.warn({ sessionId }, 'inference: session or project missing');
|
ctx.log.warn({ sessionId }, 'inference: session or project missing');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { session, project } = initialLoaded;
|
let { session, project, history: initialHistory } = initialLoaded;
|
||||||
const agent = session.agent_id
|
if (args.modelOverride) {
|
||||||
|
session = { ...session, model: args.modelOverride };
|
||||||
|
}
|
||||||
|
let agent = session.agent_id
|
||||||
? await getAgentById(project.path, session.agent_id)
|
? await getAgentById(project.path, session.agent_id)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// vSupervisor: if the session is set to supervisor mode, resolve the real
|
||||||
|
// agent via a cheap classification call. Falls through to default (no agent)
|
||||||
|
// if routing returns null.
|
||||||
|
if (agent?.id === 'supervisor') {
|
||||||
|
const { agents: availableAgents } = await getAgentsForProject(project.path);
|
||||||
|
const latestUser = [...initialHistory].reverse().find((m) => m.role === 'user');
|
||||||
|
const userMessage = latestUser?.content ?? '';
|
||||||
|
if (userMessage) {
|
||||||
|
const route = await resolveSupervisorTurn(userMessage, availableAgents, session.model ?? undefined);
|
||||||
|
if (route) {
|
||||||
|
ctx.log.info(
|
||||||
|
{ sessionId, chatId, resolvedAgent: route.agent_id, reasoning: route.reasoning },
|
||||||
|
'supervisor: routed turn',
|
||||||
|
);
|
||||||
|
agent = await getAgentById(project.path, route.agent_id);
|
||||||
|
} else {
|
||||||
|
ctx.log.info({ sessionId, chatId }, 'supervisor: no agent matched, falling through to default');
|
||||||
|
agent = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ctx.log.info({ sessionId, chatId }, 'supervisor: no user message found, falling through to default');
|
||||||
|
agent = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// P5: pure per-turn config (budget + cap math + text-only flag).
|
// P5: pure per-turn config (budget + cap math + text-only flag).
|
||||||
const { effectiveCap, budget, isTextOnly } = resolveTurnConfig(agent);
|
const { effectiveCap, budget, isTextOnly } = resolveTurnConfig(agent);
|
||||||
|
|
||||||
@@ -162,7 +193,8 @@ export async function runAssistantTurn(
|
|||||||
if (isTextOnly) {
|
if (isTextOnly) {
|
||||||
const loaded = await loadContext(ctx.sql, sessionId, chatId);
|
const loaded = await loadContext(ctx.sql, sessionId, chatId);
|
||||||
if (loaded) {
|
if (loaded) {
|
||||||
await runTextOnlyTurn(ctx, args, loaded.session, loaded.project, loaded.history, agent);
|
const txtSession = args.modelOverride ? { ...loaded.session, model: args.modelOverride } : loaded.session;
|
||||||
|
await runTextOnlyTurn(ctx, args, txtSession, loaded.project, loaded.history, agent);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -178,6 +210,16 @@ export async function runAssistantTurn(
|
|||||||
const mistakeTracker = args.mistakeTracker;
|
const mistakeTracker = args.mistakeTracker;
|
||||||
let pendingRecoveryNote: string | undefined = args.pendingRecoveryNote;
|
let pendingRecoveryNote: string | undefined = args.pendingRecoveryNote;
|
||||||
|
|
||||||
|
if (session.state_graph_enabled) {
|
||||||
|
// ---- optional state graph path ----
|
||||||
|
const gProjectRoot = await resolveProjectRoot(project.path);
|
||||||
|
const graphResult = await runGraph(ctx, args, { effectiveCap, budget, agent, projectRoot: gProjectRoot });
|
||||||
|
stepNumber = graphResult.stepNumber;
|
||||||
|
toolsUsed = graphResult.toolsUsed;
|
||||||
|
recentToolCalls = graphResult.recentToolCalls;
|
||||||
|
assistantMessageId = graphResult.assistantMessageId;
|
||||||
|
// mistakeTracker is the same object reference (mutated in place by the graph).
|
||||||
|
} else {
|
||||||
while (stepNumber < effectiveCap) {
|
while (stepNumber < effectiveCap) {
|
||||||
// ---- top-of-loop gate: doom-loop, then budget (pure decision) ----
|
// ---- top-of-loop gate: doom-loop, then budget (pure decision) ----
|
||||||
const decision = decideStep({ recentToolCalls, toolsUsed, budget });
|
const decision = decideStep({ recentToolCalls, toolsUsed, budget });
|
||||||
@@ -185,16 +227,18 @@ export async function runAssistantTurn(
|
|||||||
// Need fresh history for the summary.
|
// Need fresh history for the summary.
|
||||||
const loaded = await loadContext(ctx.sql, sessionId, chatId);
|
const loaded = await loadContext(ctx.sql, sessionId, chatId);
|
||||||
if (loaded) {
|
if (loaded) {
|
||||||
|
const dlSession = args.modelOverride ? { ...loaded.session, model: args.modelOverride } : loaded.session;
|
||||||
const iterArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, mistakeTracker, signal };
|
const iterArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, mistakeTracker, signal };
|
||||||
await runDoomLoopSummary(ctx, iterArgs, loaded.session, loaded.project, loaded.history, agent, decision.loop);
|
await runDoomLoopSummary(ctx, iterArgs, dlSession, loaded.project, loaded.history, agent, decision.loop);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (decision.kind === 'budget') {
|
if (decision.kind === 'budget') {
|
||||||
const loaded = await loadContext(ctx.sql, sessionId, chatId);
|
const loaded = await loadContext(ctx.sql, sessionId, chatId);
|
||||||
if (loaded) {
|
if (loaded) {
|
||||||
|
const bhSession = args.modelOverride ? { ...loaded.session, model: args.modelOverride } : loaded.session;
|
||||||
const iterArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, mistakeTracker, signal };
|
const iterArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, mistakeTracker, signal };
|
||||||
await runCapHitSummary(ctx, iterArgs, loaded.session, loaded.project, loaded.history, agent, budget);
|
await runCapHitSummary(ctx, iterArgs, bhSession, loaded.project, loaded.history, agent, budget);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -230,6 +274,9 @@ export async function runAssistantTurn(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let { session: iterSession, project: iterProject, history } = loaded;
|
let { session: iterSession, project: iterProject, history } = loaded;
|
||||||
|
if (args.modelOverride) {
|
||||||
|
iterSession = { ...iterSession, model: args.modelOverride };
|
||||||
|
}
|
||||||
const projectRoot = await resolveProjectRoot(iterProject.path);
|
const projectRoot = await resolveProjectRoot(iterProject.path);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -401,6 +448,7 @@ export async function runAssistantTurn(
|
|||||||
// 'continue' — advance to next assistant message.
|
// 'continue' — advance to next assistant message.
|
||||||
assistantMessageId = toolPhaseResult.nextAssistantId!;
|
assistantMessageId = toolPhaseResult.nextAssistantId!;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// vWhale: Stop hook at post-loop exit (best-effort, non-blocking).
|
// vWhale: Stop hook at post-loop exit (best-effort, non-blocking).
|
||||||
if (ctx.hooks) {
|
if (ctx.hooks) {
|
||||||
@@ -438,8 +486,9 @@ export async function runAssistantTurn(
|
|||||||
if (stepNumber >= effectiveCap && effectiveCap < Infinity) {
|
if (stepNumber >= effectiveCap && effectiveCap < Infinity) {
|
||||||
const loaded = await loadContext(ctx.sql, sessionId, chatId);
|
const loaded = await loadContext(ctx.sql, sessionId, chatId);
|
||||||
if (loaded) {
|
if (loaded) {
|
||||||
|
const scSession = args.modelOverride ? { ...loaded.session, model: args.modelOverride } : loaded.session;
|
||||||
const capArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, mistakeTracker, signal };
|
const capArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, mistakeTracker, signal };
|
||||||
await runStepCapSummary(ctx, capArgs, loaded.session, loaded.project, loaded.history, agent, stepNumber, effectiveCap);
|
await runStepCapSummary(ctx, capArgs, scSession, loaded.project, loaded.history, agent, stepNumber, effectiveCap);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -510,6 +559,31 @@ export async function runInference(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v2.8-compare: run inference with a model override and compare group id.
|
||||||
|
// Used by the compare endpoint to run the same message through N models in
|
||||||
|
// parallel. Each call publishes frames scoped to its compare_group_id.
|
||||||
|
export async function runInferenceWithModel(
|
||||||
|
ctx: InferenceContext,
|
||||||
|
sessionId: string,
|
||||||
|
chatId: string,
|
||||||
|
assistantMessageId: string,
|
||||||
|
modelOverride: string,
|
||||||
|
compareGroupId: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<void> {
|
||||||
|
return runAssistantTurn(ctx, {
|
||||||
|
sessionId,
|
||||||
|
chatId,
|
||||||
|
assistantMessageId,
|
||||||
|
toolsUsed: 0,
|
||||||
|
recentToolCalls: [],
|
||||||
|
mistakeTracker: freshMistakeState(),
|
||||||
|
modelOverride,
|
||||||
|
compareGroupId,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// v1.8.2: cap-hit summary flow. Called instead of erroring when the loop
|
// v1.8.2: cap-hit summary flow. Called instead of erroring when the loop
|
||||||
// hits its budget. Reuses the in-flight assistant message slot to stream a
|
// hits its budget. Reuses the in-flight assistant message slot to stream a
|
||||||
// short wrap-up reply with the synthetic note prepended and tools disabled,
|
// short wrap-up reply with the synthetic note prepended and tools disabled,
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ export interface InferenceFrame {
|
|||||||
// arena frames
|
// arena frames
|
||||||
| 'battle_started'
|
| 'battle_started'
|
||||||
| 'contestant_updated'
|
| 'contestant_updated'
|
||||||
| 'battle_updated';
|
| 'battle_updated'
|
||||||
|
// inter-agent message
|
||||||
|
| 'agent_message';
|
||||||
message_id?: string;
|
message_id?: string;
|
||||||
message_ids?: string[];
|
message_ids?: string[];
|
||||||
chat_id?: string;
|
chat_id?: string;
|
||||||
@@ -103,6 +105,11 @@ export interface InferenceFrame {
|
|||||||
status?: string;
|
status?: string;
|
||||||
run_status?: 'running' | 'completed' | 'failed' | 'cancelled';
|
run_status?: 'running' | 'completed' | 'failed' | 'cancelled';
|
||||||
report?: string;
|
report?: string;
|
||||||
|
// v2.8-compare: groups messages belonging to the same compare operation.
|
||||||
|
compare_group_id?: string;
|
||||||
|
// inter-agent message
|
||||||
|
sender_step_id?: string;
|
||||||
|
channel?: string;
|
||||||
// arena frames
|
// arena frames
|
||||||
battle_id?: string;
|
battle_id?: string;
|
||||||
battle_type?: 'coding' | 'qa';
|
battle_type?: 'coding' | 'qa';
|
||||||
@@ -177,5 +184,10 @@ export interface TurnArgs {
|
|||||||
// Never persisted — mirrors how the cap-hit/doom-loop notes live only inside
|
// Never persisted — mirrors how the cap-hit/doom-loop notes live only inside
|
||||||
// the summary call's messages array.
|
// the summary call's messages array.
|
||||||
pendingRecoveryNote?: string;
|
pendingRecoveryNote?: string;
|
||||||
|
// v2.8-compare: when set, overrides the session model for this single turn.
|
||||||
|
// Used by the compare endpoint to run the same message through N models.
|
||||||
|
modelOverride?: string;
|
||||||
|
// v2.8-compare: opaque group id that rides on every published frame.
|
||||||
|
compareGroupId?: string;
|
||||||
signal: AbortSignal | undefined;
|
signal: AbortSignal | undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,6 +148,19 @@ export function getServerPermission(prefixedToolName: string): McpPermission {
|
|||||||
return state?.permission ?? 'allow';
|
return state?.permission ?? 'allow';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Override the permission for a server. Used by the approval flow. */
|
||||||
|
export function setServerPermission(serverName: string, permission: McpPermission): void {
|
||||||
|
const state = servers.get(serverName);
|
||||||
|
if (state) {
|
||||||
|
state.permission = permission;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the server name from a prefixed tool name. Returns null if not an MCP tool. */
|
||||||
|
export function getServerName(prefixedToolName: string): string | null {
|
||||||
|
return toolToServer.get(prefixedToolName) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Return all wrapped ToolDefs from all connected servers, flattened. */
|
/** Return all wrapped ToolDefs from all connected servers, flattened. */
|
||||||
export function getTools(): ToolDef<Record<string, unknown>>[] {
|
export function getTools(): ToolDef<Record<string, unknown>>[] {
|
||||||
const all: ToolDef<Record<string, unknown>>[] = [];
|
const all: ToolDef<Record<string, unknown>>[] = [];
|
||||||
|
|||||||
@@ -3,9 +3,4 @@ export { formatMemoryBlock } from './prompt.js';
|
|||||||
export { scanMemoryScopes } from './scan.js';
|
export { scanMemoryScopes } from './scan.js';
|
||||||
export { parseMemoryEntries } from './entries.js';
|
export { parseMemoryEntries } from './entries.js';
|
||||||
export { ensureMemoryScaffold, getMemoryRoot } from './paths.js';
|
export { ensureMemoryScaffold, getMemoryRoot } from './paths.js';
|
||||||
export { ContextTier } from './context-tier.js';
|
|
||||||
export { DeepDream } from './deep-dream.js';
|
|
||||||
export { CoreTier } from './core-tier.js';
|
|
||||||
export type { MemoryEntry } from './entries.js';
|
export type { MemoryEntry } from './entries.js';
|
||||||
export type { ContextTierConfig, ConversationTurn } from './context-tier.js';
|
|
||||||
export type { CoreTierEntry, CoreTierSearchResult, CoreTierSearchOptions } from './core-tier.js';
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import type { ToolDef } from '../types.js';
|
||||||
|
import { callBoocontext } from '../../boocontext_client.js';
|
||||||
|
|
||||||
|
export const GetWikiArticleInput = z.object({
|
||||||
|
article: z.string().min(1).describe('Article name (e.g. "auth", "database", "routes")'),
|
||||||
|
directory: z.string().optional().describe('Project directory'),
|
||||||
|
});
|
||||||
|
export type GetWikiArticleInputT = z.infer<typeof GetWikiArticleInput>;
|
||||||
|
|
||||||
|
const DESCRIPTION =
|
||||||
|
'Returns a persistent codebase wiki article by name (auth, database, routes, etc.). ' +
|
||||||
|
'Generated on first request and cached to disk. Avoids running expensive full-scan tools for targeted documentation.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standalone execute function — calls the boocontext MCP server's
|
||||||
|
* codesight_get_wiki_article tool and returns the article text.
|
||||||
|
*
|
||||||
|
* Structured for direct test access: accepts input + projectPath,
|
||||||
|
* no side effects beyond the MCP call.
|
||||||
|
*/
|
||||||
|
export async function executeGetWikiArticle(
|
||||||
|
input: GetWikiArticleInputT,
|
||||||
|
projectPath: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const args: Record<string, unknown> = { article: input.article };
|
||||||
|
if (input.directory) args['directory'] = input.directory!;
|
||||||
|
const resp = await callBoocontext({ toolName: 'codesight_get_wiki_article', args });
|
||||||
|
return resp.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getWikiArticle: ToolDef<GetWikiArticleInputT> = {
|
||||||
|
name: 'get_wiki_article',
|
||||||
|
description: DESCRIPTION,
|
||||||
|
inputSchema: GetWikiArticleInput,
|
||||||
|
jsonSchema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'get_wiki_article',
|
||||||
|
description: DESCRIPTION,
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
article: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Article name (e.g. "auth", "database", "routes")',
|
||||||
|
},
|
||||||
|
directory: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Project directory',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['article'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async execute(input, projectRoot) {
|
||||||
|
return executeGetWikiArticle(input, projectRoot);
|
||||||
|
},
|
||||||
|
};
|
||||||
160
apps/server/src/services/tools/manage_memory.ts
Normal file
160
apps/server/src/services/tools/manage_memory.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { writeFile, unlink } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import type { ToolDef } from '../tools/types.js';
|
||||||
|
import { ensureMemoryScaffold, getMemoryRoot } from '../memory/paths.js';
|
||||||
|
import { writeEntry, readTopicFiles } from '../memory/store.js';
|
||||||
|
|
||||||
|
const ManageMemoryInput = z.object({
|
||||||
|
topic: z.enum(['project', 'user', 'reference']).describe('Memory topic category'),
|
||||||
|
title: z.string().min(1).max(200).describe('Entry title (used as identifier for update/delete)'),
|
||||||
|
content: z.string().optional().describe('Memory content body (required for create/update)'),
|
||||||
|
tags: z.array(z.string()).optional().describe('Optional tags for search'),
|
||||||
|
action: z.enum(['create', 'update', 'delete']).describe('Action to perform'),
|
||||||
|
});
|
||||||
|
|
||||||
|
type InputT = z.infer<typeof ManageMemoryInput>;
|
||||||
|
|
||||||
|
function titleToFilename(title: string): string {
|
||||||
|
return (
|
||||||
|
title
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/(^-|-$)/g, '') + '.md'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to update the CoreTier SQLite database in addition to the file store.
|
||||||
|
* This is best-effort — CoreTier is optional (file store is primary).
|
||||||
|
*/
|
||||||
|
async function syncCoreTier(
|
||||||
|
_root: string,
|
||||||
|
_topic: string,
|
||||||
|
_title: string,
|
||||||
|
_content: string,
|
||||||
|
_tags: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
// CoreTier SQLite backend is not available in this build — file store only.
|
||||||
|
}
|
||||||
|
|
||||||
|
export const manageMemoryTool: ToolDef<InputT> = {
|
||||||
|
name: 'manage_memory',
|
||||||
|
description:
|
||||||
|
'Create, update, or delete memory entries in .boocode/memory/ for cross-session recall. ' +
|
||||||
|
'Use to persist project conventions, user preferences, and architectural decisions. ' +
|
||||||
|
'Actions: create (write new entry), update (modify existing entry), delete (remove entry).',
|
||||||
|
inputSchema: ManageMemoryInput,
|
||||||
|
jsonSchema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'manage_memory',
|
||||||
|
description: 'Manage memory entries — create, update, or delete',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
topic: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['project', 'user', 'reference'],
|
||||||
|
description: 'Memory topic category',
|
||||||
|
},
|
||||||
|
title: { type: 'string', description: 'Entry title (identifier for update/delete)' },
|
||||||
|
content: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Memory content body (required for create/update)',
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string' },
|
||||||
|
description: 'Optional tags for search',
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['create', 'update', 'delete'],
|
||||||
|
description: 'Action to perform',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['topic', 'title', 'action'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async execute(input: InputT, projectRoot: string): Promise<unknown> {
|
||||||
|
const root = getMemoryRoot(projectRoot);
|
||||||
|
await ensureMemoryScaffold(root);
|
||||||
|
const filename = titleToFilename(input.title);
|
||||||
|
|
||||||
|
if (input.action === 'create') {
|
||||||
|
if (!input.content) {
|
||||||
|
return { error: 'Content is required for create action.' };
|
||||||
|
}
|
||||||
|
await writeEntry(root, input.topic, input.title, input.content, input.tags ?? []);
|
||||||
|
await syncCoreTier(root, input.topic, input.title, input.content, input.tags ?? []);
|
||||||
|
return {
|
||||||
|
result: `Memory entry "${input.title}" created in .boocode/memory/${input.topic}/`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.action === 'update') {
|
||||||
|
if (!input.content) {
|
||||||
|
return { error: 'Content is required for update action.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve target file path — try computed filename first, then heading match
|
||||||
|
let targetPath = join(root, input.topic, filename);
|
||||||
|
if (!existsSync(targetPath)) {
|
||||||
|
const files = await readTopicFiles(root, input.topic);
|
||||||
|
const matched = [...files.keys()].find((name) => {
|
||||||
|
const content = files.get(name);
|
||||||
|
return content?.trimStart().startsWith(`## ${input.topic}: ${input.title}`);
|
||||||
|
});
|
||||||
|
if (matched) {
|
||||||
|
targetPath = join(root, input.topic, matched);
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
error: `Memory entry "${input.title}" not found in .boocode/memory/${input.topic}/`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagLine =
|
||||||
|
(input.tags ?? []).length > 0
|
||||||
|
? `> tags: ${(input.tags ?? []).join(', ')}\n\n`
|
||||||
|
: '\n';
|
||||||
|
const entry = `## ${input.topic}: ${input.title}\n${tagLine}${input.content}\n`;
|
||||||
|
await writeFile(targetPath, entry, 'utf8');
|
||||||
|
|
||||||
|
await syncCoreTier(root, input.topic, input.title, input.content, input.tags ?? []);
|
||||||
|
return {
|
||||||
|
result: `Memory entry "${input.title}" updated in .boocode/memory/${input.topic}/`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.action === 'delete') {
|
||||||
|
// Resolve target file path
|
||||||
|
let targetPath = join(root, input.topic, filename);
|
||||||
|
if (!existsSync(targetPath)) {
|
||||||
|
const files = await readTopicFiles(root, input.topic);
|
||||||
|
const matched = [...files.keys()].find((name) => {
|
||||||
|
const content = files.get(name);
|
||||||
|
return content?.trimStart().startsWith(`## ${input.topic}: ${input.title}`);
|
||||||
|
});
|
||||||
|
if (matched) {
|
||||||
|
targetPath = join(root, input.topic, matched);
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
error: `Memory entry "${input.title}" not found in .boocode/memory/${input.topic}/`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await unlink(targetPath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
result: `Memory entry "${input.title}" deleted from .boocode/memory/${input.topic}/`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { error: `Unknown action: ${input.action}` };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -52,6 +52,9 @@ export interface Session {
|
|||||||
// path_guard's extraRoots check consults this list before refusing reads
|
// path_guard's extraRoots check consults this list before refusing reads
|
||||||
// outside the primary project root.
|
// outside the primary project root.
|
||||||
allowed_read_paths: string[];
|
allowed_read_paths: string[];
|
||||||
|
// v[state-graph]: optional declarative state-graph engine. Default false
|
||||||
|
// when the column is absent on existing DBs (ALTER TABLE .. DEFAULT FALSE).
|
||||||
|
state_graph_enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// v1.14.x-html-artifact-panes: 'markdown_artifact' + 'html_artifact' added.
|
// v1.14.x-html-artifact-panes: 'markdown_artifact' + 'html_artifact' added.
|
||||||
@@ -153,6 +156,7 @@ export interface Chat {
|
|||||||
id: string;
|
id: string;
|
||||||
session_id: string;
|
session_id: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
|
model: string | null;
|
||||||
status: ChatStatus;
|
status: ChatStatus;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# apps/web — BooChat frontend (deep reference)
|
# apps/web — BooChat frontend (deep reference) — v2.7.x (last meaningful update: 2026-06)
|
||||||
|
|
||||||
> Per-app engineering notes for `apps/web/src/`. The frontend is a single React SPA that also hosts the BooCoder `'coder'` pane. Cross-cutting commands, database, environment, workflow, and cross-app contracts (WS-frame / provider-type parity, sentinels) live in the **root `CLAUDE.md`**. This file auto-loads when you read/edit files under `apps/web/`.
|
> Per-app engineering notes for `apps/web/src/`. The frontend is a single React SPA that also hosts the BooCoder `'coder'` pane. Cross-cutting commands, database, environment, workflow, and cross-app contracts (WS-frame / provider-type parity, sentinels) live in the **root `CLAUDE.md`**. This file auto-loads when you read/edit files under `apps/web/`.
|
||||||
|
|
||||||
|
|||||||
@@ -20,12 +20,14 @@
|
|||||||
"@xterm/xterm": "5.5.0",
|
"@xterm/xterm": "5.5.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"framer-motion": "^12.40.0",
|
||||||
"lucide-react": "^1.16.0",
|
"lucide-react": "^1.16.0",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^6.26.0",
|
"react-router-dom": "^6.26.0",
|
||||||
|
"react-virtuoso": "^4.18.7",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"shiki": "^1.29.2",
|
"shiki": "^1.29.2",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
@@ -39,10 +41,12 @@
|
|||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
"shadcn": "^4.7.0",
|
"shadcn": "^4.7.0",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"typescript": "^5.5.0",
|
"typescript": "^5.5.0",
|
||||||
"vite": "^5.3.4"
|
"vite": "^5.3.4",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
},
|
},
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -275,7 +275,7 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(body ?? {}),
|
body: JSON.stringify(body ?? {}),
|
||||||
}),
|
}),
|
||||||
update: (chatId: string, body: { name: string }) =>
|
update: (chatId: string, body: { name?: string; model?: string }) =>
|
||||||
request<Chat>(`/api/chats/${chatId}`, {
|
request<Chat>(`/api/chats/${chatId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
@@ -331,6 +331,17 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ skill_name: skillName, user_message: userMessage }),
|
body: JSON.stringify({ skill_name: skillName, user_message: userMessage }),
|
||||||
}),
|
}),
|
||||||
|
// v2.8-compare: send the same message to N models and stream back
|
||||||
|
// parallel responses. Returns compare_group_id + per-model message ids.
|
||||||
|
compare: (chatId: string, message: string, models: string[]) =>
|
||||||
|
request<{
|
||||||
|
compare_group_id: string;
|
||||||
|
user_message_id: string;
|
||||||
|
responses: Array<{ model: string; assistant_message_id: string }>;
|
||||||
|
}>(`/api/chats/${chatId}/compare`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ message, models }),
|
||||||
|
}),
|
||||||
// v1.13.17-cross-repo-reads: resume a paused request_read_access. On
|
// v1.13.17-cross-repo-reads: resume a paused request_read_access. On
|
||||||
// 'allow' the server re-resolves the grant root and appends it to
|
// 'allow' the server re-resolves the grant root and appends it to
|
||||||
// sessions.allowed_read_paths; the returned list reflects the post-
|
// sessions.allowed_read_paths; the returned list reflects the post-
|
||||||
@@ -348,6 +359,14 @@ export const api = {
|
|||||||
request<ToolTraceResponse>(
|
request<ToolTraceResponse>(
|
||||||
`/api/chats/${chatId}/traces?limit=${limit}&offset=${offset}`,
|
`/api/chats/${chatId}/traces?limit=${limit}&offset=${offset}`,
|
||||||
),
|
),
|
||||||
|
exportChat: (chatId: string, format: 'json' | 'markdown') =>
|
||||||
|
request<string>(`/api/chats/${chatId}/export?format=${format}`),
|
||||||
|
// MCP permission: approve/deny a tool call from an 'ask' state server.
|
||||||
|
mcpApprove: (chatId: string, toolCallId: string, permission: 'allow_once' | 'allow_always' | 'deny') =>
|
||||||
|
request<{ ok: true }>(`/api/chats/${chatId}/mcp-approve`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ tool_call_id: toolCallId, permission }),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
messages: {
|
messages: {
|
||||||
@@ -388,6 +407,11 @@ export const api = {
|
|||||||
request<{ html_content: string; char_count: number; title: string }>(
|
request<{ html_content: string; char_count: number; title: string }>(
|
||||||
`/api/chats/${chatId}/messages/${messageId}/html_artifact`,
|
`/api/chats/${chatId}/messages/${messageId}/html_artifact`,
|
||||||
),
|
),
|
||||||
|
feedback: (chatId: string, messageId: string, value: 'up' | 'down') =>
|
||||||
|
request<{ ok: boolean }>(
|
||||||
|
`/api/chats/${chatId}/messages/${messageId}/feedback`,
|
||||||
|
{ method: 'POST', body: JSON.stringify({ value }) },
|
||||||
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
models: () => request<ModelInfo[]>('/api/models'),
|
models: () => request<ModelInfo[]>('/api/models'),
|
||||||
@@ -654,17 +678,27 @@ export const api = {
|
|||||||
// cols/rows are optional. When passed, booterm sizes the per-pane tmux
|
// cols/rows are optional. When passed, booterm sizes the per-pane tmux
|
||||||
// session at creation time so the inner bash (and any TUI it spawns) is
|
// session at creation time so the inner bash (and any TUI it spawns) is
|
||||||
// born with the correct PTY dimensions instead of tmux's 80x24 default.
|
// born with the correct PTY dimensions instead of tmux's 80x24 default.
|
||||||
start: (sessionId: string, paneId: string, cols?: number, rows?: number) =>
|
start: (
|
||||||
request<{ tmux_session: string }>(
|
sessionId: string,
|
||||||
|
paneId: string,
|
||||||
|
cols?: number,
|
||||||
|
rows?: number,
|
||||||
|
description?: string,
|
||||||
|
parentAgent?: string,
|
||||||
|
) => {
|
||||||
|
const body: Record<string, unknown> = {};
|
||||||
|
if (cols !== undefined) body.cols = cols;
|
||||||
|
if (rows !== undefined) body.rows = rows;
|
||||||
|
if (description !== undefined) body.description = description;
|
||||||
|
if (parentAgent !== undefined) body.parentAgent = parentAgent;
|
||||||
|
return request<{ tmux_session: string }>(
|
||||||
`/api/term/sessions/${sessionId}/panes/${paneId}/start`,
|
`/api/term/sessions/${sessionId}/panes/${paneId}/start`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body:
|
body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined,
|
||||||
cols !== undefined && rows !== undefined
|
},
|
||||||
? JSON.stringify({ cols, rows })
|
);
|
||||||
: undefined,
|
|
||||||
},
|
},
|
||||||
),
|
|
||||||
kill: (sessionId: string, paneId: string) =>
|
kill: (sessionId: string, paneId: string) =>
|
||||||
request<{ ok: true }>(
|
request<{ ok: true }>(
|
||||||
`/api/term/sessions/${sessionId}/panes/${paneId}/kill`,
|
`/api/term/sessions/${sessionId}/panes/${paneId}/kill`,
|
||||||
|
|||||||
@@ -623,6 +623,14 @@ export type WsFrame =
|
|||||||
run_status?: 'running' | 'completed' | 'failed' | 'cancelled';
|
run_status?: 'running' | 'completed' | 'failed' | 'cancelled';
|
||||||
report?: string;
|
report?: string;
|
||||||
}
|
}
|
||||||
|
// inter-agent message frame
|
||||||
|
| {
|
||||||
|
type: 'agent_message';
|
||||||
|
run_id: string;
|
||||||
|
sender_step_id: string;
|
||||||
|
content: string;
|
||||||
|
channel?: string;
|
||||||
|
}
|
||||||
// tool trace frames: per-tool-call lifecycle tracking
|
// tool trace frames: per-tool-call lifecycle tracking
|
||||||
| {
|
| {
|
||||||
type: 'tool_trace_start';
|
type: 'tool_trace_start';
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export function AgentPicker({ projectId, value, onChange }: Props) {
|
|||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex items-center gap-1.5 rounded-full border border-border px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground max-md:min-h-[36px] max-md:min-w-[36px]"
|
className="inline-flex items-center gap-1.5 rounded-full border border-border px-2.5 py-1 text-xs text-muted-foreground motion-reduce:transition-none transition-colors hover:bg-muted hover:text-foreground active:scale-[0.97] max-md:min-h-[36px] max-md:min-w-[36px]"
|
||||||
title={selectedAgent?.name ? `Agent: ${selectedAgent.name}` : 'No agent'}
|
title={selectedAgent?.name ? `Agent: ${selectedAgent.name}` : 'No agent'}
|
||||||
aria-label={`Agent: ${triggerLabel}`}
|
aria-label={`Agent: ${triggerLabel}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function AttachmentChip({ attachment, onRemove, onPreview }: Props) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onPreview(attachment)}
|
onClick={() => onPreview(attachment)}
|
||||||
className="flex items-center gap-1.5 hover:bg-muted/60 transition-colors min-w-0"
|
className="flex items-center gap-1.5 hover:bg-muted/60 motion-reduce:transition-none transition-colors active:scale-[0.97] min-w-0"
|
||||||
>
|
>
|
||||||
<FileText className="size-3 shrink-0 text-muted-foreground" />
|
<FileText className="size-3 shrink-0 text-muted-foreground" />
|
||||||
<span className="truncate max-w-[200px]">{label}</span>
|
<span className="truncate max-w-[200px]">{label}</span>
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export function BottomSheet({ open, onClose, children, title }: Props) {
|
|||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed inset-x-0 bottom-0 z-50 rounded-t-2xl border-t border-border bg-popover text-popover-foreground shadow-2xl',
|
'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',
|
'motion-reduce:transition-none transition-transform duration-150 will-change-transform',
|
||||||
'max-h-[70vh] flex flex-col',
|
'max-h-[70vh] flex flex-col',
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -694,7 +694,7 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
|||||||
disabled={disabled || busy || attachments.length >= MAX_ATTACHMENTS}
|
disabled={disabled || busy || attachments.length >= MAX_ATTACHMENTS}
|
||||||
aria-label="Attach file"
|
aria-label="Attach file"
|
||||||
title="Attach file"
|
title="Attach file"
|
||||||
className="inline-flex items-center justify-center rounded-full border border-border px-2.5 py-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-50 max-md:min-h-[36px] max-md:min-w-[36px]"
|
className="inline-flex items-center justify-center rounded-full border border-border px-2.5 py-1 text-muted-foreground motion-reduce:transition-none transition-colors hover:bg-muted hover:text-foreground active:scale-[0.97] disabled:opacity-50 max-md:min-h-[36px] max-md:min-w-[36px]"
|
||||||
>
|
>
|
||||||
<Paperclip className="size-3.5" />
|
<Paperclip className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -707,7 +707,7 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
|||||||
aria-expanded={cmdMenuOpen}
|
aria-expanded={cmdMenuOpen}
|
||||||
aria-label="Slash commands"
|
aria-label="Slash commands"
|
||||||
title="Slash commands"
|
title="Slash commands"
|
||||||
className="inline-flex items-center gap-1.5 rounded-full border border-border px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground max-md:min-h-[36px] max-md:min-w-[36px]"
|
className="inline-flex items-center gap-1.5 rounded-full border border-border px-2.5 py-1 text-xs text-muted-foreground motion-reduce:transition-none transition-colors hover:bg-muted hover:text-foreground active:scale-[0.97] aria-expanded:bg-muted aria-expanded:text-foreground max-md:min-h-[36px] max-md:min-w-[36px]"
|
||||||
>
|
>
|
||||||
<SquareSlash className="size-3.5" />
|
<SquareSlash className="size-3.5" />
|
||||||
<span className="max-md:hidden">{slashItems.length}</span>
|
<span className="max-md:hidden">{slashItems.length}</span>
|
||||||
@@ -720,7 +720,7 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
|||||||
onClick={() => sessionEvents.emit({ type: 'open_flow_launcher', project_id: projectId })}
|
onClick={() => sessionEvents.emit({ type: 'open_flow_launcher', project_id: projectId })}
|
||||||
aria-label="Flow launcher"
|
aria-label="Flow launcher"
|
||||||
title="Open flow launcher"
|
title="Open flow launcher"
|
||||||
className="inline-flex items-center gap-1.5 rounded-full border border-border px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground max-md:min-h-[36px] max-md:min-w-[36px]"
|
className="inline-flex items-center gap-1.5 rounded-full border border-border px-2.5 py-1 text-xs text-muted-foreground motion-reduce:transition-none transition-colors hover:bg-muted hover:text-foreground active:scale-[0.97] max-md:min-h-[36px] max-md:min-w-[36px]"
|
||||||
>
|
>
|
||||||
<Workflow className="size-3.5" />
|
<Workflow className="size-3.5" />
|
||||||
<span className="max-md:hidden">Flows</span>
|
<span className="max-md:hidden">Flows</span>
|
||||||
@@ -739,7 +739,7 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
|||||||
}}
|
}}
|
||||||
aria-pressed={webSearchEnabled === true}
|
aria-pressed={webSearchEnabled === true}
|
||||||
title="Web search & fetch"
|
title="Web search & fetch"
|
||||||
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs transition-colors max-md:min-h-[36px] max-md:min-w-[36px] ${
|
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs motion-reduce:transition-none transition-colors active:scale-[0.97] max-md:min-h-[36px] max-md:min-w-[36px] ${
|
||||||
webSearchEnabled === true
|
webSearchEnabled === true
|
||||||
? 'border-primary/40 bg-primary/10 text-primary'
|
? 'border-primary/40 bg-primary/10 text-primary'
|
||||||
: 'border-border text-muted-foreground hover:bg-muted hover:text-foreground'
|
: 'border-border text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Check, Copy } from 'lucide-react';
|
import { Check, Copy, Moon, Sun, WrapText } from 'lucide-react';
|
||||||
import { codeToHtml } from 'shiki';
|
import { codeToHtml } from 'shiki';
|
||||||
|
|
||||||
// NOTE: spec calls for syntax-highlighted code blocks. Added Shiki in v1.1.
|
// NOTE: spec calls for syntax-highlighted code blocks. Added Shiki in v1.1.
|
||||||
@@ -45,35 +45,111 @@ const LANG_MAP: Record<string, string> = {
|
|||||||
css: 'css',
|
css: 'css',
|
||||||
};
|
};
|
||||||
|
|
||||||
const SHIKI_THEME = 'github-dark';
|
// ── LRU highlight cache (module-scoped) ──────────────────────────
|
||||||
|
// Key = `${code}|${theme}|${mappedLang}`, max 50 entries.
|
||||||
|
// Avoids redundant codeToHtml calls when the same code/theme/lang
|
||||||
|
// combination is rendered multiple times (e.g. across messages).
|
||||||
|
const HIGHLIGHT_CACHE = new Map<string, string>();
|
||||||
|
const MAX_CACHE_ENTRIES = 50;
|
||||||
|
|
||||||
|
function cacheGet(key: string): string | undefined {
|
||||||
|
if (!HIGHLIGHT_CACHE.has(key)) return undefined;
|
||||||
|
const val = HIGHLIGHT_CACHE.get(key)!;
|
||||||
|
// LRU touch — delete & re-set to move to end (most recently used)
|
||||||
|
HIGHLIGHT_CACHE.delete(key);
|
||||||
|
HIGHLIGHT_CACHE.set(key, val);
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheSet(key: string, html: string): void {
|
||||||
|
if (HIGHLIGHT_CACHE.size >= MAX_CACHE_ENTRIES) {
|
||||||
|
const oldest = HIGHLIGHT_CACHE.keys().next().value;
|
||||||
|
if (oldest !== undefined) HIGHLIGHT_CACHE.delete(oldest);
|
||||||
|
}
|
||||||
|
HIGHLIGHT_CACHE.set(key, html);
|
||||||
|
}
|
||||||
|
|
||||||
export function CodeBlock({ code, lang }: Props) {
|
export function CodeBlock({ code, lang }: Props) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [html, setHtml] = useState<string | null>(null);
|
const [html, setHtml] = useState<string | null>(null);
|
||||||
const highlightRef = useRef<HTMLDivElement | null>(null);
|
const [theme, setTheme] = useState<'github-dark' | 'github-light'>(() => {
|
||||||
|
try {
|
||||||
|
if (localStorage.getItem('codeblock-theme') === 'github-light') return 'github-light';
|
||||||
|
} catch {
|
||||||
|
/* localStorage unavailable */
|
||||||
|
}
|
||||||
|
return 'github-dark';
|
||||||
|
});
|
||||||
|
const [wordWrap, setWordWrap] = useState(false);
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const highlightRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// ── Derived state ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Diff mode: detect `diff-` prefix (e.g. diff-ts, diff-py).
|
||||||
|
// The actual lang for highlighting is the part after `diff-`.
|
||||||
|
const isDiff = !!lang && lang.startsWith('diff-');
|
||||||
|
const actualLang = isDiff && lang ? lang.slice('diff-'.length) : lang ?? '';
|
||||||
|
const mappedLang = actualLang ? (LANG_MAP[actualLang.toLowerCase()] ?? null) : null;
|
||||||
|
|
||||||
|
// Strip leading `+`/`-` from code lines when in diff mode.
|
||||||
|
// The markers are rendered in the gutter instead.
|
||||||
|
const cleanCode = useMemo(
|
||||||
|
() => (isDiff ? code.replace(/^[+-]/gm, '') : code),
|
||||||
|
[code, isDiff],
|
||||||
|
);
|
||||||
|
|
||||||
|
const codeLines = useMemo(() => code.split('\n'), [code]);
|
||||||
|
const totalLines = codeLines.length;
|
||||||
|
|
||||||
|
// Gutter is hidden entirely when code has >= 1000 lines.
|
||||||
|
const showGutter = totalLines < 1000;
|
||||||
|
|
||||||
|
// Collapsible: auto-collapse to 15 lines when >= 30 lines total.
|
||||||
|
const isLong = totalLines >= 30;
|
||||||
|
const collapsed = isLong && !expanded;
|
||||||
|
const visibleLines = collapsed ? codeLines.slice(0, 15) : codeLines;
|
||||||
|
|
||||||
|
// Diff marker array: '+' / '-' / '' per line for the gutter.
|
||||||
|
const diffMarkers = useMemo(
|
||||||
|
() => (isDiff ? codeLines.map((l) => (l[0] === '+' ? '+' : l[0] === '-' ? '-' : '')) : null),
|
||||||
|
[isDiff, codeLines],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Shiki highlighting ─────────────────────────────────────────
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const mappedLang = (lang && LANG_MAP[lang.toLowerCase()]) ?? null;
|
|
||||||
if (!mappedLang) {
|
if (!mappedLang) {
|
||||||
setHtml(null);
|
setHtml(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cacheKey = `${cleanCode}|${theme}|${mappedLang}`;
|
||||||
|
const cached = cacheGet(cacheKey);
|
||||||
|
if (cached !== undefined) {
|
||||||
|
setHtml(cached);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const result = await codeToHtml(code, { lang: mappedLang, theme: SHIKI_THEME });
|
const result = await codeToHtml(cleanCode, { lang: mappedLang, theme });
|
||||||
|
cacheSet(cacheKey, result);
|
||||||
if (!cancelled) setHtml(result);
|
if (!cancelled) setHtml(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('shiki failed', err);
|
console.warn('shiki highlight failed:', err);
|
||||||
if (!cancelled) setHtml(null);
|
if (!cancelled) setHtml(null);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [code, lang]);
|
}, [cleanCode, mappedLang, theme]);
|
||||||
|
|
||||||
// Inject Shiki HTML via ref; output is compiler-generated, not user input.
|
// Inject Shiki HTML via ref (output is compiler-generated, not user input)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (highlightRef.current) {
|
if (highlightRef.current) {
|
||||||
// Shiki generates sanitized HTML spans — not user-supplied content.
|
// Shiki generates sanitized HTML spans — not user-supplied content.
|
||||||
@@ -82,23 +158,76 @@ export function CodeBlock({ code, lang }: Props) {
|
|||||||
}
|
}
|
||||||
}, [html]);
|
}, [html]);
|
||||||
|
|
||||||
async function copy() {
|
// Sync word-wrap state to the injected <pre> element inside shiki's output
|
||||||
|
useEffect(() => {
|
||||||
|
const pre = highlightRef.current?.querySelector('pre');
|
||||||
|
if (pre) {
|
||||||
|
pre.style.whiteSpace = wordWrap ? 'pre-wrap' : 'nowrap';
|
||||||
|
}
|
||||||
|
}, [html, wordWrap]);
|
||||||
|
|
||||||
|
// ── Handlers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const handleToggleTheme = useCallback(() => {
|
||||||
|
setTheme((prev) => {
|
||||||
|
const next = prev === 'github-dark' ? 'github-light' : 'github-dark';
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(code);
|
localStorage.setItem('codeblock-theme', next);
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCopy = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(cleanCode);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 1200);
|
setTimeout(() => setCopied(false), 1200);
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
}
|
}, [cleanCode]);
|
||||||
|
|
||||||
|
// ── Shared class segments ──────────────────────────────────────
|
||||||
|
|
||||||
|
const preBaseClass = 'overflow-x-auto px-3 py-2 font-mono text-xs leading-relaxed';
|
||||||
|
const shikiWrapperClass = `${preBaseClass} [&>pre]:!bg-transparent [&>pre]:!m-0 [&>pre]:!p-0`;
|
||||||
|
const collapsedClass = collapsed ? 'max-h-[calc(15*1.625em)] overflow-hidden' : '';
|
||||||
|
|
||||||
|
// ── Render ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border bg-muted/40 overflow-hidden text-sm my-1">
|
<div className="rounded-md border bg-muted/40 overflow-hidden text-sm my-1">
|
||||||
|
{/* ── Toolbar ──────────────────────────────────────────── */}
|
||||||
<div className="flex items-center justify-between px-2 py-1 border-b text-xs text-muted-foreground">
|
<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>
|
<span className="font-mono">{actualLang || lang || 'code'}</span>
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
{/* Theme toggle — persists to localStorage key 'codeblock-theme' */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void copy()}
|
onClick={handleToggleTheme}
|
||||||
|
className="flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted text-foreground"
|
||||||
|
aria-label={`Switch to ${theme === 'github-dark' ? 'light' : 'dark'} theme`}
|
||||||
|
>
|
||||||
|
{theme === 'github-dark' ? <Sun className="size-3" /> : <Moon className="size-3" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Word-wrap toggle */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setWordWrap((prev) => !prev)}
|
||||||
|
className={`flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted text-foreground ${wordWrap ? 'bg-muted' : ''}`}
|
||||||
|
aria-label={wordWrap ? 'Disable word wrap' : 'Enable word wrap'}
|
||||||
|
>
|
||||||
|
<WrapText className="size-3" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Copy button — existing behavior (Check icon, 1200ms revert) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleCopy()}
|
||||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted text-foreground"
|
className="flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted text-foreground"
|
||||||
aria-label="Copy code"
|
aria-label="Copy code"
|
||||||
>
|
>
|
||||||
@@ -106,16 +235,62 @@ export function CodeBlock({ code, lang }: Props) {
|
|||||||
<span>{copied ? 'Copied' : 'Copy'}</span>
|
<span>{copied ? 'Copied' : 'Copy'}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{html !== null ? (
|
</div>
|
||||||
|
|
||||||
|
{/* ── Code body (flex row: gutter + code) ──────────────── */}
|
||||||
|
<div className="flex">
|
||||||
|
{/* Gutter — line numbers or diff markers */}
|
||||||
|
{showGutter && (
|
||||||
<div
|
<div
|
||||||
ref={highlightRef}
|
className="flex-none select-none text-right text-muted-foreground/50 font-mono text-xs leading-relaxed py-2 border-r border-border/30"
|
||||||
className="overflow-x-auto px-3 py-2 font-mono text-xs leading-relaxed [&>pre]:!bg-transparent [&>pre]:!m-0 [&>pre]:!p-0"
|
aria-hidden="true"
|
||||||
/>
|
>
|
||||||
|
{visibleLines.map((line, i) => {
|
||||||
|
const isPlus = diffMarkers?.[i] === '+';
|
||||||
|
const isMinus = diffMarkers?.[i] === '-';
|
||||||
|
let gutterCellClass = 'px-2 leading-relaxed';
|
||||||
|
if (isPlus) gutterCellClass += ' bg-green-500/10 border-l-2 border-green-500';
|
||||||
|
if (isMinus) gutterCellClass += ' bg-red-500/10 border-l-2 border-red-500';
|
||||||
|
const content = diffMarkers ? diffMarkers[i] : String(i + 1);
|
||||||
|
return (
|
||||||
|
<div key={i} className={gutterCellClass}>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Code area */}
|
||||||
|
<div className="relative flex-1 min-w-0">
|
||||||
|
{html !== null ? (
|
||||||
|
<div ref={highlightRef} className={`${shikiWrapperClass} ${collapsedClass}`} />
|
||||||
) : (
|
) : (
|
||||||
<pre className="overflow-x-auto px-3 py-2 font-mono text-xs leading-relaxed">
|
<pre
|
||||||
{code}
|
className={`${preBaseClass} ${collapsedClass}`}
|
||||||
|
style={{ whiteSpace: wordWrap ? 'pre-wrap' : 'nowrap' }}
|
||||||
|
>
|
||||||
|
{collapsed ? codeLines.slice(0, 15).join('\n') : code}
|
||||||
</pre>
|
</pre>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Gradient fade overlay for collapsed state */}
|
||||||
|
{collapsed && (
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-b from-transparent to-background pointer-events-none" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* "Show N more" button for collapsed state */}
|
||||||
|
{collapsed && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded(true)}
|
||||||
|
className="w-full text-xs text-muted-foreground hover:text-foreground py-1 border-t border-border/30 bg-muted/20"
|
||||||
|
>
|
||||||
|
Show {totalLines - 15} more {totalLines - 15 === 1 ? 'line' : 'lines'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
143
apps/web/src/components/ComparePane.tsx
Normal file
143
apps/web/src/components/ComparePane.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { MarkdownRenderer } from './MarkdownRenderer';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface CompareResponse {
|
||||||
|
model: string;
|
||||||
|
assistantMessageId: string;
|
||||||
|
content: string;
|
||||||
|
status: 'streaming' | 'complete' | 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
models: string[];
|
||||||
|
responses: CompareResponse[];
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ComparePane({ models, responses, onClose }: Props) {
|
||||||
|
const panelsRef = useRef<(HTMLDivElement | null)[]>([]);
|
||||||
|
const isSyncingRef = useRef(false);
|
||||||
|
|
||||||
|
// Build a map for quick lookup
|
||||||
|
const responseMap = new Map<string, CompareResponse>();
|
||||||
|
for (const r of responses) {
|
||||||
|
responseMap.set(r.model, r);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Synced scroll: when one panel scrolls, scroll all others to the same ratio
|
||||||
|
const handleScroll = useCallback((sourceIndex: number) => {
|
||||||
|
if (isSyncingRef.current) return;
|
||||||
|
const source = panelsRef.current[sourceIndex];
|
||||||
|
if (!source) return;
|
||||||
|
const ratio = source.scrollTop / (source.scrollHeight - source.clientHeight);
|
||||||
|
isSyncingRef.current = true;
|
||||||
|
for (let i = 0; i < panelsRef.current.length; i++) {
|
||||||
|
if (i === sourceIndex) continue;
|
||||||
|
const target = panelsRef.current[i];
|
||||||
|
if (!target) continue;
|
||||||
|
const targetMax = target.scrollHeight - target.clientHeight;
|
||||||
|
target.scrollTop = targetMax > 0 ? ratio * targetMax : 0;
|
||||||
|
}
|
||||||
|
isSyncingRef.current = false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Refresh scroll sync when content changes (streaming updates)
|
||||||
|
useEffect(() => {
|
||||||
|
if (responses.length === 0) return;
|
||||||
|
const anyStreaming = responses.some((r) => r.status === 'streaming');
|
||||||
|
if (!anyStreaming) return;
|
||||||
|
// Sync scroll to bottom when any panel is still streaming
|
||||||
|
if (!isSyncingRef.current) {
|
||||||
|
for (const panel of panelsRef.current) {
|
||||||
|
if (!panel) continue;
|
||||||
|
panel.scrollTop = panel.scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [responses]);
|
||||||
|
|
||||||
|
const gridCols = models.length === 2 ? 'grid-cols-2' : 'grid-cols-3';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full min-h-0 relative">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-2 border-b border-border bg-muted/20 px-3 py-2 shrink-0">
|
||||||
|
<span className="text-sm font-medium">Compare Models</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{models.length} models
|
||||||
|
</span>
|
||||||
|
<div className="ml-auto flex items-center gap-1 shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded border border-border text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||||
|
>
|
||||||
|
Back to single
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid of response panels */}
|
||||||
|
<div className={cn('flex-1 grid min-h-0', gridCols)}>
|
||||||
|
{models.map((model, idx) => {
|
||||||
|
const resp = responseMap.get(model) ?? {
|
||||||
|
model,
|
||||||
|
assistantMessageId: '',
|
||||||
|
content: '',
|
||||||
|
status: 'streaming' as const,
|
||||||
|
};
|
||||||
|
const isStreaming = resp.status === 'streaming';
|
||||||
|
const isError = resp.status === 'error';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={model}
|
||||||
|
ref={(el) => { panelsRef.current[idx] = el; }}
|
||||||
|
onScroll={() => handleScroll(idx)}
|
||||||
|
className={cn(
|
||||||
|
'overflow-y-auto border-r border-border/50 last:border-r-0',
|
||||||
|
'flex flex-col',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Model header */}
|
||||||
|
<div className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b border-border/50 px-3 py-2 text-xs font-medium text-foreground truncate">
|
||||||
|
{model}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Empty / loading state */}
|
||||||
|
{resp.content.length === 0 && isStreaming && (
|
||||||
|
<div className="flex items-center justify-center flex-1 gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
Generating…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error state */}
|
||||||
|
{isError && resp.content.length === 0 && (
|
||||||
|
<div className="flex items-center justify-center flex-1 text-sm text-destructive px-3">
|
||||||
|
Failed to generate
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{resp.content.length > 0 && (
|
||||||
|
<div className="px-3 py-2 text-sm">
|
||||||
|
<MarkdownRenderer content={resp.content} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Streaming indicator at bottom */}
|
||||||
|
{isStreaming && resp.content.length > 0 && (
|
||||||
|
<div className="sticky bottom-0 bg-background/80 backdrop-blur px-3 py-1.5 flex items-center gap-1.5 text-xs text-muted-foreground border-t border-border/30">
|
||||||
|
<Loader2 size={10} className="animate-spin" />
|
||||||
|
Streaming…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -118,7 +118,7 @@ export function ContextMeter({ messages, modelContextLimit, sessionCostUsd }: Pr
|
|||||||
cy={CENTER}
|
cy={CENTER}
|
||||||
r={RADIUS}
|
r={RADIUS}
|
||||||
fill="none"
|
fill="none"
|
||||||
className={cn('transition-all duration-300', progressClass)}
|
className={cn('transition-all duration-200 motion-reduce:transition-none', progressClass)}
|
||||||
strokeWidth={STROKE}
|
strokeWidth={STROKE}
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeDasharray={CIRCUMFERENCE}
|
strokeDasharray={CIRCUMFERENCE}
|
||||||
|
|||||||
68
apps/web/src/components/EmptyState.tsx
Normal file
68
apps/web/src/components/EmptyState.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Inbox } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface EmptyStateProps {
|
||||||
|
/** Optional icon node shown above the title. Defaults to a muted Inbox icon. */
|
||||||
|
icon?: ReactNode;
|
||||||
|
/** Main heading text (bold, base font-size). */
|
||||||
|
title: string;
|
||||||
|
/** Optional descriptive text shown below the title (muted, sm font-size). */
|
||||||
|
description?: string;
|
||||||
|
/** Optional CTA button rendered below the description. */
|
||||||
|
action?: {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
variant?: 'default' | 'outline';
|
||||||
|
};
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable empty state for lists, search results, and landing pages.
|
||||||
|
*
|
||||||
|
* Renders a centered column with:
|
||||||
|
* 1. Optional icon (default: `Inbox` from lucide-react, drawn at 50% muted
|
||||||
|
* opacity so it sits subtly in the background).
|
||||||
|
* 2. Bold title.
|
||||||
|
* 3. Optional description (constrained to `max-w-sm` for readability).
|
||||||
|
* 4. Optional action button (outline by default).
|
||||||
|
*
|
||||||
|
* Design follows The Data Terminal aesthetic: charcoal canvas, low-chrome
|
||||||
|
* muted icon, high-contrast text, and an outline button that gets an ember
|
||||||
|
* glow on interaction.
|
||||||
|
*/
|
||||||
|
export function EmptyState({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
action,
|
||||||
|
className,
|
||||||
|
}: EmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col items-center justify-center text-center gap-3 px-4 py-12',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="text-muted-foreground/50">
|
||||||
|
{icon ?? <Inbox size={40} strokeWidth={1.5} />}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-base font-semibold text-foreground">{title}</h3>
|
||||||
|
{description && (
|
||||||
|
<p className="text-sm text-muted-foreground max-w-sm">{description}</p>
|
||||||
|
)}
|
||||||
|
{action && (
|
||||||
|
<Button
|
||||||
|
variant={action.variant ?? 'outline'}
|
||||||
|
onClick={action.onClick}
|
||||||
|
className="mt-1"
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -37,11 +37,11 @@ function Switch({ checked, onCheckedChange, id }: {
|
|||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={checked}
|
aria-checked={checked}
|
||||||
onClick={() => onCheckedChange(!checked)}
|
onClick={() => onCheckedChange(!checked)}
|
||||||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full transition-colors ${
|
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full motion-reduce:transition-none transition-colors ${
|
||||||
checked ? 'bg-primary' : 'bg-muted'
|
checked ? 'bg-primary' : 'bg-muted'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-background transition-transform ${
|
<span className={`inline-block h-4 w-4 transform rounded-full bg-background motion-reduce:transition-none transition-transform ${
|
||||||
checked ? 'translate-x-[1.125rem]' : 'translate-x-0.5'
|
checked ? 'translate-x-[1.125rem]' : 'translate-x-0.5'
|
||||||
}`} />
|
}`} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
64
apps/web/src/components/KeyboardShortcutsDialog.tsx
Normal file
64
apps/web/src/components/KeyboardShortcutsDialog.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { KEYBOARD_SHORTCUTS } from '@/lib/keyboard-shortcuts';
|
||||||
|
|
||||||
|
function KeyboardShortcutsDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const isMac =
|
||||||
|
typeof navigator !== 'undefined' &&
|
||||||
|
navigator.platform.toLowerCase().includes('mac');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Keyboard Shortcuts</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="-mx-4 -mb-4 max-h-[70vh] overflow-y-auto overscroll-contain px-4 pb-4">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{KEYBOARD_SHORTCUTS.map((group) => (
|
||||||
|
<div key={group.category}>
|
||||||
|
<h4 className="mb-3 text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||||
|
{group.category}
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{group.shortcuts.map((shortcut, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-center justify-between gap-4"
|
||||||
|
>
|
||||||
|
<span className="text-sm">{shortcut.description}</span>
|
||||||
|
<span className="flex shrink-0 items-center gap-1">
|
||||||
|
{shortcut.keys.map((key, ki) => (
|
||||||
|
<span key={ki} className="flex items-center gap-0.5">
|
||||||
|
{ki > 0 && (
|
||||||
|
<span className="text-muted-foreground/50">+</span>
|
||||||
|
)}
|
||||||
|
<kbd className="rounded border bg-muted px-1.5 py-0.5 font-mono text-xs">
|
||||||
|
{key === 'Ctrl' ? (isMac ? '⌘' : 'Ctrl') : key}
|
||||||
|
</kbd>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { KeyboardShortcutsDialog };
|
||||||
@@ -8,6 +8,7 @@ import Markdown from 'react-markdown';
|
|||||||
import type { Components } from 'react-markdown';
|
import type { Components } from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import { CodeBlock } from './CodeBlock';
|
import { CodeBlock } from './CodeBlock';
|
||||||
|
import { MessageBoundary } from './MessageBoundary';
|
||||||
import { linkifyPaths } from '@/lib/linkify-paths';
|
import { linkifyPaths } from '@/lib/linkify-paths';
|
||||||
|
|
||||||
function linkifyChildren(children: ReactNode, keyPrefix = 'l'): ReactNode {
|
function linkifyChildren(children: ReactNode, keyPrefix = 'l'): ReactNode {
|
||||||
@@ -40,7 +41,11 @@ const codeRenderer = (props: { children?: unknown; className?: string }) => {
|
|||||||
const langMatch = /language-([\w-]+)/.exec(className ?? '');
|
const langMatch = /language-([\w-]+)/.exec(className ?? '');
|
||||||
const isBlock = !!langMatch || text.includes('\n');
|
const isBlock = !!langMatch || text.includes('\n');
|
||||||
if (isBlock) {
|
if (isBlock) {
|
||||||
return <CodeBlock code={text} lang={langMatch?.[1]} />;
|
return (
|
||||||
|
<MessageBoundary fallback={<pre className="overflow-x-auto px-3 py-2 font-mono text-xs leading-relaxed">{text}</pre>}>
|
||||||
|
<CodeBlock code={text} lang={langMatch?.[1]} />
|
||||||
|
</MessageBoundary>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<code
|
<code
|
||||||
@@ -102,8 +107,10 @@ const MARKDOWN_COMPONENTS: Components = {
|
|||||||
|
|
||||||
export const MarkdownRenderer = memo(function MarkdownRenderer({ content }: { content: string }) {
|
export const MarkdownRenderer = memo(function MarkdownRenderer({ content }: { content: string }) {
|
||||||
return (
|
return (
|
||||||
|
<MessageBoundary>
|
||||||
<Markdown remarkPlugins={[remarkGfm]} components={MARKDOWN_COMPONENTS}>
|
<Markdown remarkPlugins={[remarkGfm]} components={MARKDOWN_COMPONENTS}>
|
||||||
{content}
|
{content}
|
||||||
</Markdown>
|
</Markdown>
|
||||||
|
</MessageBoundary>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
113
apps/web/src/components/McpPermissionDialog.tsx
Normal file
113
apps/web/src/components/McpPermissionDialog.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { AlertTriangle, Check, X } from 'lucide-react';
|
||||||
|
import { api } from '@/api/client';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
toolCallId: string;
|
||||||
|
toolName: string;
|
||||||
|
toolArgs: Record<string, unknown>;
|
||||||
|
chatId: string;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseServerName(toolName: string): string {
|
||||||
|
// Tool name format is <serverName>_<originalToolName>
|
||||||
|
const idx = toolName.indexOf('_');
|
||||||
|
if (idx === -1) return toolName;
|
||||||
|
return toolName.slice(0, idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseToolShortName(toolName: string): string {
|
||||||
|
const idx = toolName.indexOf('_');
|
||||||
|
if (idx === -1) return toolName;
|
||||||
|
return toolName.slice(idx + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeArgs(args: Record<string, unknown>): string {
|
||||||
|
const keys = Object.keys(args);
|
||||||
|
if (keys.length === 0) return 'no arguments';
|
||||||
|
const first = keys[0]!;
|
||||||
|
const val = typeof args[first] === 'string' ? args[first] as string : JSON.stringify(args[first]);
|
||||||
|
return `${first}: ${val.length > 60 ? val.slice(0, 59) + '…' : val}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function McpPermissionDialog({ toolCallId, toolName, toolArgs, chatId, open, onClose }: Props) {
|
||||||
|
const [loading, setLoading] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const serverName = parseServerName(toolName);
|
||||||
|
const shortName = parseToolShortName(toolName);
|
||||||
|
const argsSummary = summarizeArgs(toolArgs);
|
||||||
|
|
||||||
|
const handleApprove = async (permission: 'allow_once' | 'allow_always' | 'deny') => {
|
||||||
|
setLoading(permission);
|
||||||
|
try {
|
||||||
|
await api.chats.mcpApprove(chatId, toolCallId, permission);
|
||||||
|
onClose();
|
||||||
|
} catch {
|
||||||
|
// Error is already surfaced by api client
|
||||||
|
} finally {
|
||||||
|
setLoading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(open) => { if (!open) onClose(); }}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="size-4 text-amber-500" />
|
||||||
|
MCP Tool Approval
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="rounded-lg bg-muted/50 p-3 text-xs space-y-1.5">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Server:</span>{' '}
|
||||||
|
<span className="font-mono font-medium">{serverName}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Tool:</span>{' '}
|
||||||
|
<span className="font-mono">{shortName}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Args:</span>{' '}
|
||||||
|
<span className="font-mono text-muted-foreground">{argsSummary}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
This MCP server requires approval before running tools. Choose how to proceed:
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleApprove('allow_once')}
|
||||||
|
disabled={loading !== null}
|
||||||
|
>
|
||||||
|
{loading === 'allow_once' ? '…' : <><Check className="size-3 mr-1" /> Allow Once</>}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleApprove('allow_always')}
|
||||||
|
disabled={loading !== null}
|
||||||
|
>
|
||||||
|
{loading === 'allow_always' ? '…' : <><Check className="size-3 mr-1" /> Always Allow</>}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleApprove('deny')}
|
||||||
|
disabled={loading !== null}
|
||||||
|
>
|
||||||
|
{loading === 'deny' ? '…' : <><X className="size-3 mr-1" /> Deny</>}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
212
apps/web/src/components/McpResponseDisplay.tsx
Normal file
212
apps/web/src/components/McpResponseDisplay.tsx
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import type { ToolCall, ToolResult } from '@/api/types';
|
||||||
|
import { linkifyPaths } from '@/lib/linkify-paths';
|
||||||
|
import { MarkdownRenderer } from './MarkdownRenderer';
|
||||||
|
import { extractServerName, extractToolName } from '@/lib/tool-utils';
|
||||||
|
|
||||||
|
interface McpResponseDisplayProps {
|
||||||
|
toolCall: ToolCall;
|
||||||
|
toolResult: ToolResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DisplayMode = 'plain' | 'markdown' | 'rich';
|
||||||
|
|
||||||
|
const URL_REGEX = /https?:\/\/[^\s<>"']+/g;
|
||||||
|
const IMAGE_EXT_REGEX = /\.(png|jpg|jpeg|gif|webp|svg)(\?.*)?$/i;
|
||||||
|
|
||||||
|
function isImageUrl(url: string): boolean {
|
||||||
|
return IMAGE_EXT_REGEX.test(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContentSegment {
|
||||||
|
type: 'text' | 'image' | 'link' | 'image_line';
|
||||||
|
content: string;
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits tool output into content segments for rich display mode:
|
||||||
|
* - Standalone image URLs → <img> tag
|
||||||
|
* - Markdown image syntax `` → <img> tag
|
||||||
|
* - Standalone non-image URLs → link card
|
||||||
|
* - Everything else → text paragraph
|
||||||
|
*/
|
||||||
|
function parseRichContent(output: string): ContentSegment[] {
|
||||||
|
const segments: ContentSegment[] = [];
|
||||||
|
const lines = output.split('\n');
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trimEnd();
|
||||||
|
|
||||||
|
// Empty line
|
||||||
|
if (!trimmed) {
|
||||||
|
segments.push({ type: 'text', content: line });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Markdown image syntax: 
|
||||||
|
const inlineImgMatch = trimmed.match(/^!\[.*?\]\((https?:\/\/[^\s)]+)\)$/);
|
||||||
|
if (inlineImgMatch) {
|
||||||
|
segments.push({ type: 'image', content: '', url: inlineImgMatch[1] });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standalone URL
|
||||||
|
const urlMatch = trimmed.match(URL_REGEX);
|
||||||
|
if (urlMatch && urlMatch[0] === trimmed) {
|
||||||
|
const url = urlMatch[0];
|
||||||
|
if (isImageUrl(url)) {
|
||||||
|
segments.push({ type: 'image', content: '', url });
|
||||||
|
} else {
|
||||||
|
segments.push({ type: 'link', content: url });
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline image URLs on their own line (no markdown, just raw URL)
|
||||||
|
const imgMatch = trimmed.match(IMAGE_EXT_REGEX);
|
||||||
|
if (imgMatch) {
|
||||||
|
const possibleUrl = trimmed.match(URL_REGEX);
|
||||||
|
if (possibleUrl && possibleUrl[0].length >= trimmed.length * 0.8) {
|
||||||
|
segments.push({ type: 'image', content: '', url: possibleUrl[0] });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline URLs in text — detect and wrap them
|
||||||
|
const inlineUrls = trimmed.match(URL_REGEX);
|
||||||
|
if (inlineUrls) {
|
||||||
|
// Render as text with linkified URLs
|
||||||
|
segments.push({ type: 'text', content: trimmed });
|
||||||
|
} else {
|
||||||
|
segments.push({ type: 'text', content: line });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function McpResponseDisplay({ toolCall, toolResult }: McpResponseDisplayProps) {
|
||||||
|
const [mode, setMode] = useState<DisplayMode>('plain');
|
||||||
|
const serverName = extractServerName(toolCall.name);
|
||||||
|
const toolDisplayName = extractToolName(toolCall.name) ?? toolCall.name;
|
||||||
|
|
||||||
|
const output =
|
||||||
|
typeof toolResult.output === 'string'
|
||||||
|
? toolResult.output
|
||||||
|
: JSON.stringify(toolResult.output, null, 2);
|
||||||
|
|
||||||
|
const modes: { key: DisplayMode; label: string }[] = [
|
||||||
|
{ key: 'plain', label: 'Plain' },
|
||||||
|
{ key: 'markdown', label: 'MD' },
|
||||||
|
{ key: 'rich', label: 'Rich' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{/* Server badge + tool name + permission dot */}
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
{serverName && (
|
||||||
|
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-mono bg-primary/10 text-primary">
|
||||||
|
{serverName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-[11px] font-mono text-muted-foreground">{toolDisplayName}</span>
|
||||||
|
<span
|
||||||
|
className="size-2 rounded-full bg-emerald-500 shrink-0"
|
||||||
|
aria-label="allowed"
|
||||||
|
title="Permission: allowed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Display mode toggle */}
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
{modes.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMode(m.key)}
|
||||||
|
className={`px-2 py-0.5 text-[10px] font-mono rounded transition-colors ${
|
||||||
|
mode === m.key
|
||||||
|
? 'bg-primary/15 text-primary'
|
||||||
|
: 'text-muted-foreground/50 hover:text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{mode === 'plain' && (
|
||||||
|
<pre className="text-[11px] font-mono whitespace-pre-wrap bg-muted/30 rounded px-2 py-1 max-h-72 overflow-y-auto">
|
||||||
|
{toolResult.error ? (
|
||||||
|
<span className="text-destructive">{toolResult.error}</span>
|
||||||
|
) : (
|
||||||
|
linkifyPaths(output)
|
||||||
|
)}
|
||||||
|
{toolResult.truncated && (
|
||||||
|
<div className="text-muted-foreground/60 mt-1">— output truncated —</div>
|
||||||
|
)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === 'markdown' && (
|
||||||
|
<div className="text-[13px] bg-muted/30 rounded px-2 py-1 max-h-96 overflow-y-auto">
|
||||||
|
{toolResult.error ? (
|
||||||
|
<span className="text-destructive">{toolResult.error}</span>
|
||||||
|
) : (
|
||||||
|
<MarkdownRenderer content={output} />
|
||||||
|
)}
|
||||||
|
{toolResult.truncated && (
|
||||||
|
<div className="text-muted-foreground/60 mt-1 text-xs">— output truncated —</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === 'rich' && (
|
||||||
|
<div className="bg-muted/30 rounded px-2 py-1 max-h-96 overflow-y-auto space-y-1.5">
|
||||||
|
{toolResult.error ? (
|
||||||
|
<span className="text-destructive text-[11px]">{toolResult.error}</span>
|
||||||
|
) : (
|
||||||
|
parseRichContent(output).map((seg, i) => {
|
||||||
|
if (seg.type === 'image') {
|
||||||
|
return (
|
||||||
|
<div key={i} className="rounded overflow-hidden">
|
||||||
|
<img
|
||||||
|
src={seg.url}
|
||||||
|
alt="Tool result image"
|
||||||
|
className="max-w-full h-auto rounded"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (seg.type === 'link') {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={i}
|
||||||
|
href={seg.content}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="block text-[11px] font-mono text-primary underline hover:text-primary/80 bg-muted/20 rounded px-2 py-1.5 break-all"
|
||||||
|
>
|
||||||
|
{seg.content}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<p key={i} className="text-[11px] leading-relaxed">
|
||||||
|
{linkifyPaths(seg.content)}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
{toolResult.truncated && (
|
||||||
|
<div className="text-muted-foreground/60 mt-1 text-xs">— output truncated —</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
apps/web/src/components/MessageBoundary.tsx
Normal file
56
apps/web/src/components/MessageBoundary.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { Component } from 'react';
|
||||||
|
import type { ErrorInfo, ReactNode } from 'react';
|
||||||
|
import { AlertCircle, RefreshCw } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
fallback?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MessageBoundary extends Component<Props, State> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props);
|
||||||
|
this.state = { error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||||
|
console.warn('MessageBoundary caught:', error.message, info.componentStack);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleRetry = () => {
|
||||||
|
this.setState({ error: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.error) {
|
||||||
|
if (this.props.fallback !== undefined) {
|
||||||
|
return <>{this.props.fallback}</>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs">
|
||||||
|
<AlertCircle className="size-3.5 text-destructive shrink-0" />
|
||||||
|
<span className="text-muted-foreground flex-1">Rendering failed</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={this.handleRetry}
|
||||||
|
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-foreground hover:bg-muted"
|
||||||
|
aria-label="Retry rendering"
|
||||||
|
title="Retry"
|
||||||
|
>
|
||||||
|
<RefreshCw className="size-3" />
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -299,7 +299,7 @@ function ActionRow({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity max-md:opacity-100">
|
<div className="flex gap-1 opacity-0 group-hover:opacity-100 motion-reduce:transition-none transition-opacity max-md:opacity-100">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void copy()}
|
onClick={() => void copy()}
|
||||||
|
|||||||
@@ -274,7 +274,7 @@ export function MessageList({ messages, sessionChats }: Props) {
|
|||||||
chatId={item.chatId}
|
chatId={item.chatId}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ToolCallLine run={item.run} />
|
<ToolCallLine run={item.run} chatId={item.chatId} />
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<ToolCallGroup runs={item.runs} />
|
<ToolCallGroup runs={item.runs} />
|
||||||
|
|||||||
56
apps/web/src/components/MessageListErrorBoundary.tsx
Normal file
56
apps/web/src/components/MessageListErrorBoundary.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { Component } from 'react';
|
||||||
|
import type { ErrorInfo, ReactNode } from 'react';
|
||||||
|
import { AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MessageListErrorBoundary extends Component<Props, State> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props);
|
||||||
|
this.state = { error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||||
|
console.error('MessageListErrorBoundary caught:', error.message, info.componentStack);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleRetry = () => {
|
||||||
|
this.setState({ error: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.error) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center gap-3 px-4">
|
||||||
|
<AlertCircle className="size-8 text-destructive" />
|
||||||
|
<div className="text-center space-y-1">
|
||||||
|
<p className="text-sm font-medium">Something went wrong</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
The message list encountered an unexpected error.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={this.handleRetry}
|
||||||
|
className="inline-flex items-center gap-1 rounded-md border px-3 py-1.5 text-xs font-medium hover:bg-muted"
|
||||||
|
aria-label="Try again"
|
||||||
|
title="Try again"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ import { useViewport } from '@/hooks/useViewport';
|
|||||||
import { formatModelLabel } from '@/lib/model-label';
|
import { formatModelLabel } from '@/lib/model-label';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
value: string;
|
value: string | null;
|
||||||
onChange: (model: string) => void | Promise<void>;
|
onChange: (model: string) => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ function ModelList({
|
|||||||
}: {
|
}: {
|
||||||
models: ModelInfo[] | null;
|
models: ModelInfo[] | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
value: string;
|
value: string | null;
|
||||||
onPick: (id: string) => void;
|
onPick: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -82,8 +82,8 @@ export function ModelPicker({ value, onChange }: Props) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
aria-label={`Model: ${value}`}
|
aria-label={`Model: ${value ?? 'default'}`}
|
||||||
title={value}
|
title={value ?? undefined}
|
||||||
className="inline-flex items-center justify-center min-h-[36px] min-w-[44px] rounded text-muted-foreground hover:text-foreground"
|
className="inline-flex items-center justify-center min-h-[36px] min-w-[44px] rounded text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<Cpu className="size-4" />
|
<Cpu className="size-4" />
|
||||||
@@ -104,7 +104,7 @@ export function ModelPicker({ value, onChange }: Props) {
|
|||||||
type="button"
|
type="button"
|
||||||
className="text-xs font-mono text-muted-foreground hover:text-foreground flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted/60"
|
className="text-xs font-mono text-muted-foreground hover:text-foreground flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted/60"
|
||||||
>
|
>
|
||||||
{formatModelLabel(value)}
|
{value ? formatModelLabel(value) : 'Model'}
|
||||||
<ChevronDown className="size-3 opacity-70" />
|
<ChevronDown className="size-3 opacity-70" />
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Code, History, MessageSquare, Plus, Terminal, Workflow } from 'lucide-react';
|
import { Code, History, MessageSquare, Plus, Swords, Terminal, Workflow } from 'lucide-react';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import type { FlowRunRow } from '@/api/types';
|
import type { FlowRunRow } from '@/api/types';
|
||||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
@@ -90,6 +90,19 @@ export function NewPaneMenu({ onAddPane, disabled, projectId }: Props) {
|
|||||||
<Workflow size={14} /> New Orchestrator
|
<Workflow size={14} /> New Orchestrator
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{projectId && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() =>
|
||||||
|
sessionEvents.emit({
|
||||||
|
type: 'open_arena_launcher',
|
||||||
|
project_id: projectId,
|
||||||
|
placement: 'new',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Swords size={14} /> New Arena
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
{projectId && (
|
{projectId && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -273,7 +273,7 @@ export function ProjectSidebar() {
|
|||||||
const asideCls = isMobile
|
const asideCls = isMobile
|
||||||
? cn(
|
? cn(
|
||||||
'fixed inset-y-0 left-0 z-40 w-60 border-r bg-sidebar text-sidebar-foreground flex flex-col',
|
'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',
|
'motion-reduce:transition-none transition-transform duration-200 ease-out',
|
||||||
drawerOpen ? 'translate-x-0' : '-translate-x-full',
|
drawerOpen ? 'translate-x-0' : '-translate-x-full',
|
||||||
)
|
)
|
||||||
: 'w-60 shrink-0 border-r bg-sidebar text-sidebar-foreground flex flex-col h-screen';
|
: 'w-60 shrink-0 border-r bg-sidebar text-sidebar-foreground flex flex-col h-screen';
|
||||||
@@ -333,7 +333,7 @@ export function ProjectSidebar() {
|
|||||||
className="flex items-center justify-center text-[10px] uppercase tracking-wide text-muted-foreground border-b overflow-hidden shrink-0"
|
className="flex items-center justify-center text-[10px] uppercase tracking-wide text-muted-foreground border-b overflow-hidden shrink-0"
|
||||||
style={{
|
style={{
|
||||||
height: pull.refreshing ? 32 : Math.min(pull.pullDist, 80),
|
height: pull.refreshing ? 32 : Math.min(pull.pullDist, 80),
|
||||||
transition: pull.pullDist === 0 && !pull.refreshing ? 'height 0.2s ease' : undefined,
|
transition: pull.pullDist === 0 && !pull.refreshing ? 'height 0.2s ease-out' : undefined,
|
||||||
}}
|
}}
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -282,7 +282,7 @@ export function RightRail({ projectId, sessionId }: Props) {
|
|||||||
const asideCls = isMobile
|
const asideCls = isMobile
|
||||||
? cn(
|
? cn(
|
||||||
'fixed inset-y-0 right-0 z-40 w-[85vw] max-w-sm border-l bg-sidebar flex flex-col overflow-hidden',
|
'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',
|
'motion-reduce:transition-none transition-transform duration-200 ease-out',
|
||||||
drawerOpen ? 'translate-x-0' : 'translate-x-full',
|
drawerOpen ? 'translate-x-0' : 'translate-x-full',
|
||||||
)
|
)
|
||||||
: 'w-64 shrink-0 border-l bg-sidebar flex flex-col h-full overflow-hidden';
|
: 'w-64 shrink-0 border-l bg-sidebar flex flex-col h-full overflow-hidden';
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
|
|||||||
import { Archive, ChevronLeft, Code, MessageSquare, RotateCcw, Terminal, Trash2 } from 'lucide-react';
|
import { Archive, ChevronLeft, Code, MessageSquare, RotateCcw, Terminal, Trash2 } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import mascot from '@/assets/brand/banner-mascot.png';
|
import mascot from '@/assets/brand/banner-mascot.png';
|
||||||
|
import { EmptyState } from '@/components/EmptyState';
|
||||||
import { ChatInput } from '@/components/ChatInput';
|
import { ChatInput } from '@/components/ChatInput';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -167,9 +168,11 @@ export function SessionLandingPage({
|
|||||||
<h2 className="text-sm font-medium ml-auto mr-1">Session history</h2>
|
<h2 className="text-sm font-medium ml-auto mr-1">Session history</h2>
|
||||||
</div>
|
</div>
|
||||||
{isEmpty ? (
|
{isEmpty ? (
|
||||||
<p className="text-sm text-muted-foreground text-center py-8">
|
<EmptyState
|
||||||
No conversations yet. Send a message to start.
|
icon={<MessageSquare size={40} strokeWidth={1.5} />}
|
||||||
</p>
|
title="No conversations"
|
||||||
|
description="Your chat history will appear here"
|
||||||
|
/>
|
||||||
) : (<>
|
) : (<>
|
||||||
{openChats.length > 0 && (
|
{openChats.length > 0 && (
|
||||||
<>
|
<>
|
||||||
@@ -200,7 +203,7 @@ export function SessionLandingPage({
|
|||||||
{formatRelative(c.updated_at)}
|
{formatRelative(c.updated_at)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="shrink-0 flex items-center gap-0.5 opacity-0 group-hover/row:opacity-100 focus-within:opacity-100 max-md:opacity-100 transition-opacity">
|
<div className="shrink-0 flex items-center gap-0.5 opacity-0 group-hover/row:opacity-100 focus-within:opacity-100 max-md:opacity-100 motion-reduce:transition-none transition-opacity">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => { e.stopPropagation(); void onArchiveChat(c.id); }}
|
onClick={(e) => { e.stopPropagation(); void onArchiveChat(c.id); }}
|
||||||
@@ -254,7 +257,7 @@ export function SessionLandingPage({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => { e.stopPropagation(); setDeleteConfirm({ id: c.id, name: c.name }); }}
|
onClick={(e) => { e.stopPropagation(); setDeleteConfirm({ id: c.id, name: c.name }); }}
|
||||||
className="shrink-0 inline-flex items-center justify-center size-7 rounded hover:bg-destructive/20 hover:text-destructive max-md:size-9 opacity-0 group-hover/arch:opacity-100 focus-within:opacity-100 max-md:opacity-100 transition-opacity"
|
className="shrink-0 inline-flex items-center justify-center size-7 rounded hover:bg-destructive/20 hover:text-destructive max-md:size-9 opacity-0 group-hover/arch:opacity-100 focus-within:opacity-100 max-md:opacity-100 motion-reduce:transition-none transition-opacity"
|
||||||
aria-label="Delete chat"
|
aria-label="Delete chat"
|
||||||
title="Delete"
|
title="Delete"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -132,13 +132,13 @@ export function ThemePicker() {
|
|||||||
onClick={() => setAnimBg(!animOn)}
|
onClick={() => setAnimBg(!animOn)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent',
|
'relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent',
|
||||||
'transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
'motion-reduce:transition-none transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||||
animOn ? 'bg-primary' : 'bg-input',
|
animOn ? 'bg-primary' : 'bg-input',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'pointer-events-none block h-4 w-4 rounded-full bg-white shadow-lg ring-0 transition-transform duration-200',
|
'pointer-events-none block h-4 w-4 rounded-full bg-white shadow-lg ring-0 motion-reduce:transition-none transition-transform duration-200',
|
||||||
animOn ? 'translate-x-4' : 'translate-x-0',
|
animOn ? 'translate-x-4' : 'translate-x-0',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -33,11 +33,20 @@ export function ToolCallGroup({ runs }: Props) {
|
|||||||
<div className="rounded border border-border/60 bg-muted/20 text-xs">
|
<div className="rounded border border-border/60 bg-muted/20 text-xs">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
tabIndex={0}
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
className="w-full flex items-center gap-1.5 px-2 py-1 hover:bg-muted/40 text-left"
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen((v) => !v);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-1.5 px-2 py-1 hover:bg-muted/40 text-left focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
|
||||||
>
|
>
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
className={`size-3 text-muted-foreground/60 shrink-0 transition-transform ${open ? 'rotate-90' : ''}`}
|
className={`size-3 text-muted-foreground/60 shrink-0 motion-reduce:transition-none transition-transform ${open ? 'rotate-90' : ''}`}
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground/60 select-none shrink-0">⊞</span>
|
<span className="text-muted-foreground/60 select-none shrink-0">⊞</span>
|
||||||
<span className="font-mono text-foreground/90">
|
<span className="font-mono text-foreground/90">
|
||||||
|
|||||||
@@ -123,8 +123,17 @@ export function ToolCallLine({ run, insideGroup, chatId }: Props) {
|
|||||||
<div className="text-xs">
|
<div className="text-xs">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
tabIndex={0}
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
className="flex items-center gap-1.5 w-full text-left hover:bg-muted/40 rounded px-1 py-0.5 -mx-1"
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen((v) => !v);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1.5 w-full text-left hover:bg-muted/40 rounded px-1 py-0.5 -mx-1 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
|
||||||
>
|
>
|
||||||
{/* BooCode 2.0: glowing activity indicator (was ↳ / >_) */}
|
{/* BooCode 2.0: glowing activity indicator (was ↳ / >_) */}
|
||||||
{!insideGroup && (
|
{!insideGroup && (
|
||||||
|
|||||||
@@ -274,6 +274,8 @@ export function Workspace({
|
|||||||
paneId={activePaneChatId(pane) ?? pane.id}
|
paneId={activePaneChatId(pane) ?? pane.id}
|
||||||
label={terminalLabels.get(activePaneChatId(pane) ?? pane.id) ?? 'Terminal'}
|
label={terminalLabels.get(activePaneChatId(pane) ?? pane.id) ?? 'Terminal'}
|
||||||
active={idx === activePaneIdx}
|
active={idx === activePaneIdx}
|
||||||
|
description={undefined}
|
||||||
|
parentAgent={undefined}
|
||||||
/>
|
/>
|
||||||
) : pane.kind === 'coder' ? (
|
) : pane.kind === 'coder' ? (
|
||||||
<CoderPane
|
<CoderPane
|
||||||
|
|||||||
327
apps/web/src/components/message-parts/ActionRow.tsx
Normal file
327
apps/web/src/components/message-parts/ActionRow.tsx
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Copy, RefreshCw, Check, GitFork, Trash2, History, ThumbsUp, ThumbsDown } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import type { Message } from '@/api/types';
|
||||||
|
import type { MessageActions } from '@/components/MessageBubble';
|
||||||
|
import { api } from '@/api/client';
|
||||||
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
|
export function ActionRow({
|
||||||
|
message,
|
||||||
|
actions,
|
||||||
|
hiddenSet,
|
||||||
|
hasCheckpoint = false,
|
||||||
|
restoreDisabled = false,
|
||||||
|
}: {
|
||||||
|
message: Message;
|
||||||
|
actions?: MessageActions;
|
||||||
|
hiddenSet: Set<string>;
|
||||||
|
hasCheckpoint?: boolean;
|
||||||
|
restoreDisabled?: boolean;
|
||||||
|
}) {
|
||||||
|
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);
|
||||||
|
const [restoreOpen, setRestoreOpen] = useState(false);
|
||||||
|
const [restoring, setRestoring] = useState(false);
|
||||||
|
const [feedbackValue, setFeedbackValue] = useState<'up' | 'down' | null>(() => {
|
||||||
|
const m = message.metadata;
|
||||||
|
return m && m.kind === 'feedback' ? m.value : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function copy() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(message.content);
|
||||||
|
setJustCopied(true);
|
||||||
|
setTimeout(() => setJustCopied(false), 1200);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'copy failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function regenerate() {
|
||||||
|
if (regenerating || message.status === 'streaming') return;
|
||||||
|
setRegenerating(true);
|
||||||
|
try {
|
||||||
|
if (actions?.onRegenerate) {
|
||||||
|
await actions.onRegenerate(message.chat_id, message.id);
|
||||||
|
} else {
|
||||||
|
await api.messages.regenerate(message.chat_id, message.id);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'regenerate failed');
|
||||||
|
} finally {
|
||||||
|
setRegenerating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resend() {
|
||||||
|
if (!canResend) return;
|
||||||
|
try {
|
||||||
|
if (actions?.onResend) {
|
||||||
|
await actions.onResend(message.chat_id, message.content!);
|
||||||
|
} else {
|
||||||
|
await api.messages.send(message.chat_id, message.content!);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'resend failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fork() {
|
||||||
|
if (forking || message.status !== 'complete') return;
|
||||||
|
setForking(true);
|
||||||
|
try {
|
||||||
|
if (actions?.onFork) {
|
||||||
|
await actions.onFork(message.chat_id, message.id);
|
||||||
|
} else {
|
||||||
|
const chat = await api.chats.fork(message.chat_id, { messageId: message.id });
|
||||||
|
sessionEvents.emit({ type: 'refetch_messages' });
|
||||||
|
sessionEvents.emit({ type: 'open_chat_in_new_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 {
|
||||||
|
if (actions?.onDelete) {
|
||||||
|
await actions.onDelete(message.chat_id, message.id);
|
||||||
|
} else {
|
||||||
|
await api.messages.remove(message.chat_id, message.id);
|
||||||
|
}
|
||||||
|
setDeleteOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'delete failed');
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmRestore() {
|
||||||
|
if (restoring || !actions?.onRestoreCheckpoint) return;
|
||||||
|
setRestoring(true);
|
||||||
|
try {
|
||||||
|
await actions.onRestoreCheckpoint(message.chat_id, message.id);
|
||||||
|
setRestoreOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'restore failed');
|
||||||
|
} finally {
|
||||||
|
setRestoring(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFeedback(value: 'up' | 'down') {
|
||||||
|
if (feedbackValue) return; // already voted
|
||||||
|
setFeedbackValue(value);
|
||||||
|
try {
|
||||||
|
await api.messages.feedback(message.chat_id, message.id, value);
|
||||||
|
toast.success('Feedback recorded');
|
||||||
|
} catch (err) {
|
||||||
|
setFeedbackValue(null); // revert optimistic update
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to submit feedback');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAssistant = message.role === 'assistant';
|
||||||
|
const isUser = message.role === 'user';
|
||||||
|
const canRegen = isAssistant && message.status !== 'streaming';
|
||||||
|
const canResend = isUser && message.status === 'complete' && !!message.content?.trim();
|
||||||
|
const canFork = message.status === 'complete';
|
||||||
|
const canDelete = message.status !== 'streaming';
|
||||||
|
// write-edit-robustness #4: show "Restore to here" only for a completed
|
||||||
|
// assistant message that has a checkpoint AND when the coder wired the
|
||||||
|
// callback. Disabled (but visible) during an active turn.
|
||||||
|
const canRestore =
|
||||||
|
isAssistant &&
|
||||||
|
hasCheckpoint &&
|
||||||
|
message.status === 'complete' &&
|
||||||
|
!!actions?.onRestoreCheckpoint;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity max-md:opacity-100">
|
||||||
|
<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 max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||||
|
aria-label="Copy message"
|
||||||
|
title="Copy"
|
||||||
|
>
|
||||||
|
{justCopied ? <Check className="size-3" /> : <Copy className="size-3" />}
|
||||||
|
</button>
|
||||||
|
{canResend && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void resend()}
|
||||||
|
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||||
|
aria-label="Resend message"
|
||||||
|
title="Resend"
|
||||||
|
>
|
||||||
|
<RefreshCw className="size-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isAssistant && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
{isAssistant && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleFeedback('up')}
|
||||||
|
disabled={feedbackValue !== null}
|
||||||
|
className={`inline-flex items-center justify-center size-6 rounded hover:bg-muted max-md:min-h-[44px] max-md:min-w-[44px] ${
|
||||||
|
feedbackValue === 'up'
|
||||||
|
? 'text-green-500'
|
||||||
|
: 'text-muted-foreground hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
aria-label="Rate as good"
|
||||||
|
title="Good response"
|
||||||
|
>
|
||||||
|
<ThumbsUp className="size-3" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleFeedback('down')}
|
||||||
|
disabled={feedbackValue !== null}
|
||||||
|
className={`inline-flex items-center justify-center size-6 rounded hover:bg-muted max-md:min-h-[44px] max-md:min-w-[44px] ${
|
||||||
|
feedbackValue === 'down'
|
||||||
|
? 'text-red-500'
|
||||||
|
: 'text-muted-foreground hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
aria-label="Rate as bad"
|
||||||
|
title="Bad response"
|
||||||
|
>
|
||||||
|
<ThumbsDown className="size-3" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!hiddenSet.has('fork') && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void fork()}
|
||||||
|
disabled={!canFork || forking}
|
||||||
|
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||||
|
aria-label="Fork from here"
|
||||||
|
title="Fork from here"
|
||||||
|
>
|
||||||
|
<GitFork className="size-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!hiddenSet.has('delete') && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDeleteOpen(true)}
|
||||||
|
disabled={!canDelete}
|
||||||
|
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-destructive disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||||
|
aria-label="Delete message"
|
||||||
|
title="Delete message"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canRestore && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRestoreOpen(true)}
|
||||||
|
disabled={restoreDisabled || restoring}
|
||||||
|
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="Restore to here"
|
||||||
|
title="Restore worktree to this point"
|
||||||
|
>
|
||||||
|
<History 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>
|
||||||
|
<Dialog
|
||||||
|
open={restoreOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!restoring) setRestoreOpen(open);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Restore to this point?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This resets the worktree to before this turn, removes every later
|
||||||
|
message in this chat, and resets the agent's session. This cannot
|
||||||
|
be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setRestoreOpen(false)}
|
||||||
|
disabled={restoring}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => void confirmRestore()}
|
||||||
|
disabled={restoring}
|
||||||
|
>
|
||||||
|
{restoring ? 'Restoring…' : 'Restore'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
125
apps/web/src/components/message-parts/CompactCard.tsx
Normal file
125
apps/web/src/components/message-parts/CompactCard.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { ChevronDown, ChevronRight, Copy, Check, Share2, RotateCw } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import type { Chat, Message } from '@/api/types';
|
||||||
|
import { api } from '@/api/client';
|
||||||
|
|
||||||
|
export function CompactCard({ message, sessionChats }: { message: Message; sessionChats?: Chat[] }) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [shareOpen, setShareOpen] = useState(false);
|
||||||
|
const [rerunning, setRerunning] = useState(false);
|
||||||
|
|
||||||
|
const headerMatch = message.content.match(/^\[Context compacted — (\d+) messages summarized\]/);
|
||||||
|
const headerText = headerMatch ? headerMatch[0] : 'Context compacted';
|
||||||
|
const summaryText = headerMatch
|
||||||
|
? message.content.slice(headerMatch[0].length).trim()
|
||||||
|
: message.content;
|
||||||
|
|
||||||
|
async function handleCopy() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(summaryText);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1200);
|
||||||
|
toast.success('Summary copied to clipboard');
|
||||||
|
} catch {
|
||||||
|
toast.error('Copy failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleShareToChat(chat: Chat) {
|
||||||
|
try {
|
||||||
|
await api.messages.send(chat.id, summaryText);
|
||||||
|
toast.success(`Summary sent to ${chat.name ?? 'New chat'}`);
|
||||||
|
setShareOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to share');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRerun() {
|
||||||
|
if (rerunning) return;
|
||||||
|
setRerunning(true);
|
||||||
|
try {
|
||||||
|
await api.chats.compact(message.chat_id);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Re-run failed');
|
||||||
|
} finally {
|
||||||
|
setRerunning(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const otherChats = (sessionChats ?? []).filter(
|
||||||
|
(c) => c.id !== message.chat_id && c.status === 'open'
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-muted/30 text-sm">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
className="flex items-center gap-1.5 flex-1 min-w-0 text-left text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||||
|
<span className="text-xs font-medium truncate">{headerText}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleCopy()}
|
||||||
|
className="p-1 rounded hover:bg-muted text-muted-foreground"
|
||||||
|
aria-label="Copy summary"
|
||||||
|
title="Copy summary"
|
||||||
|
>
|
||||||
|
{copied ? <Check size={12} /> : <Copy size={12} />}
|
||||||
|
</button>
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShareOpen(!shareOpen)}
|
||||||
|
className="p-1 rounded hover:bg-muted text-muted-foreground"
|
||||||
|
aria-label="Send to chat"
|
||||||
|
title="Send to chat"
|
||||||
|
>
|
||||||
|
<Share2 size={12} />
|
||||||
|
</button>
|
||||||
|
{shareOpen && (
|
||||||
|
<div className="absolute right-0 top-full mt-1 z-50 bg-popover border rounded-md shadow-md min-w-[180px] py-1">
|
||||||
|
{otherChats.length === 0 ? (
|
||||||
|
<div className="px-3 py-1.5 text-xs text-muted-foreground">
|
||||||
|
No other chats in this session
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
otherChats.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleShareToChat(c)}
|
||||||
|
className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent truncate"
|
||||||
|
>
|
||||||
|
{c.name ?? 'New chat'}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleRerun()}
|
||||||
|
disabled={rerunning}
|
||||||
|
className="p-1 rounded hover:bg-muted text-muted-foreground disabled:opacity-40"
|
||||||
|
aria-label="Re-run compact"
|
||||||
|
title="Re-run compact"
|
||||||
|
>
|
||||||
|
<RotateCw size={12} className={rerunning ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{expanded && (
|
||||||
|
<div className="px-3 pb-3 text-xs leading-relaxed text-muted-foreground whitespace-pre-wrap border-t pt-2">
|
||||||
|
{summaryText}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { AlertCircle } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import type { Message } from '@/api/types';
|
||||||
|
import { api } from '@/api/client';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
// feature #12: mistake-recovery sentinel. Inserted by the backend as a
|
||||||
|
// role='system', metadata.kind='mistake_recovery' row when the model hit
|
||||||
|
// repeated *different* errors (distinct from doom_loop, which is the same
|
||||||
|
// call repeated). Visual treatment mirrors CapHitSentinel / DoomLoopSentinel
|
||||||
|
// (amber card + alert icon). Non-escalated → recovery guidance was injected
|
||||||
|
// and the turn continues. Escalated → the turn was stopped; if can_continue
|
||||||
|
// is set, offer the same Continue affordance as the cap-hit sentinel.
|
||||||
|
// Loose `!= null` guards per the CLAUDE.md coder-message note (coder rows pass
|
||||||
|
// metadata as undefined, not null).
|
||||||
|
export function MistakeRecoverySentinel({ message }: { message: Message }) {
|
||||||
|
const meta = message.metadata;
|
||||||
|
const isMistakeRecovery =
|
||||||
|
meta != null && typeof meta === 'object' && meta.kind === 'mistake_recovery';
|
||||||
|
const failureKinds = isMistakeRecovery ? meta.failure_kinds : [];
|
||||||
|
const escalated = isMistakeRecovery ? meta.escalated : false;
|
||||||
|
const canContinue = isMistakeRecovery ? meta.can_continue === true : false;
|
||||||
|
|
||||||
|
const [continuing, setContinuing] = useState(false);
|
||||||
|
|
||||||
|
async function handleContinue() {
|
||||||
|
if (continuing || !canContinue) return;
|
||||||
|
setContinuing(true);
|
||||||
|
try {
|
||||||
|
await api.chats.continue(message.chat_id, message.id);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'continue failed');
|
||||||
|
} finally {
|
||||||
|
setContinuing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const kindsLabel =
|
||||||
|
Array.isArray(failureKinds) && failureKinds.length > 0
|
||||||
|
? failureKinds.join(', ')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 text-sm">
|
||||||
|
<div className="px-3 py-2 flex items-start gap-2">
|
||||||
|
<AlertCircle className="size-4 text-amber-500 shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1 min-w-0 space-y-1">
|
||||||
|
<div className="text-xs font-medium text-amber-700 dark:text-amber-300">
|
||||||
|
{escalated ? 'Repeated errors — turn stopped' : 'Recovering from repeated errors'}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{escalated
|
||||||
|
? 'Repeated errors persisted — stopped the turn.'
|
||||||
|
: kindsLabel
|
||||||
|
? `Hit repeated different errors (${kindsLabel}) — recovery guidance injected, continuing.`
|
||||||
|
: 'Hit repeated different errors — recovery guidance injected, continuing.'}
|
||||||
|
</div>
|
||||||
|
{escalated && canContinue && (
|
||||||
|
<div className="pt-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void handleContinue()}
|
||||||
|
disabled={continuing}
|
||||||
|
>
|
||||||
|
{continuing ? 'Continuing…' : 'Continue'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
apps/web/src/components/message-parts/ReasoningBlock.tsx
Normal file
33
apps/web/src/components/message-parts/ReasoningBlock.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { ChevronDown, ChevronRight, Brain } from 'lucide-react';
|
||||||
|
|
||||||
|
// Collapsible "Thinking" block for assistant reasoning. Fed by either
|
||||||
|
// reasoning_text (coder wire / live reasoning_delta stream) or reasoning_parts
|
||||||
|
// (native inference, persisted from message_parts). Starts COLLAPSED to start
|
||||||
|
// (a quiet chip) — for native BooChat/BooCode and the external agents (opencode,
|
||||||
|
// claude SDK) alike — so the transcript stays tidy; click to expand. The
|
||||||
|
// `streaming` pulse still animates while the turn runs.
|
||||||
|
export function ReasoningBlock({ text, streaming }: { text: string; streaming: boolean }) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
return (
|
||||||
|
<div className="max-w-[90%] rounded-lg border bg-muted/30 text-sm">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
className="flex items-center gap-1.5 w-full px-3 py-1.5 text-left text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||||
|
<Brain size={13} />
|
||||||
|
<span className="text-xs font-medium">Thinking</span>
|
||||||
|
{streaming && (
|
||||||
|
<span className="ml-1 inline-block w-1.5 h-3 align-baseline bg-muted-foreground/60 animate-pulse" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{expanded && (
|
||||||
|
<div className="px-3 pb-2.5 pt-0.5 text-xs leading-relaxed text-muted-foreground whitespace-pre-wrap break-words border-t">
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
apps/web/src/components/message-parts/SendToTerminalMenu.tsx
Normal file
69
apps/web/src/components/message-parts/SendToTerminalMenu.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { sendToTerminal } from '@/lib/events';
|
||||||
|
import { useTerminals } from '@/hooks/useTerminals';
|
||||||
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuSub,
|
||||||
|
ContextMenuSubContent,
|
||||||
|
ContextMenuSubTrigger,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
} from '@/components/ui/context-menu';
|
||||||
|
|
||||||
|
// Wrap a message body with a right-click context menu offering Copy and
|
||||||
|
// "Send to terminal → <pane name>". Send is disabled when nothing is
|
||||||
|
// selected or no terminal panes are open; clicking a target emits a
|
||||||
|
// sendToTerminal event that TerminalPane subscribes to (filtered by pane_id).
|
||||||
|
export function SendToTerminalMenu({ children }: { children: ReactNode }) {
|
||||||
|
const [selection, setSelection] = useState('');
|
||||||
|
const terminals = useTerminals();
|
||||||
|
const hasSelection = selection.length > 0;
|
||||||
|
const canSend = hasSelection && terminals.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContextMenu
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (open) {
|
||||||
|
const sel = typeof window !== 'undefined' ? window.getSelection()?.toString() ?? '' : '';
|
||||||
|
setSelection(sel);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||||
|
<ContextMenuContent>
|
||||||
|
<ContextMenuItem
|
||||||
|
disabled={!hasSelection}
|
||||||
|
onSelect={() => {
|
||||||
|
void navigator.clipboard.writeText(selection).catch((err) => {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'copy failed');
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuSub>
|
||||||
|
<ContextMenuSubTrigger disabled={!canSend}>Send to terminal</ContextMenuSubTrigger>
|
||||||
|
<ContextMenuSubContent>
|
||||||
|
{terminals.length === 0 ? (
|
||||||
|
<ContextMenuItem disabled>No terminal panes open</ContextMenuItem>
|
||||||
|
) : (
|
||||||
|
terminals.map((t) => (
|
||||||
|
<ContextMenuItem
|
||||||
|
key={t.paneId}
|
||||||
|
onSelect={() => sendToTerminal.emit({ pane_id: t.paneId, text: selection })}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</ContextMenuItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ContextMenuSubContent>
|
||||||
|
</ContextMenuSub>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
apps/web/src/components/message-parts/StatsLine.tsx
Normal file
38
apps/web/src/components/message-parts/StatsLine.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import type { Message } from '@/api/types';
|
||||||
|
|
||||||
|
export function StatsLine({ message }: { message: Message }) {
|
||||||
|
const tokens = message.tokens_used;
|
||||||
|
if (typeof tokens !== 'number' || tokens <= 0) return null;
|
||||||
|
const started = message.started_at ? Date.parse(message.started_at) : NaN;
|
||||||
|
const finished = message.finished_at ? Date.parse(message.finished_at) : NaN;
|
||||||
|
let tps: number | null = null;
|
||||||
|
if (!Number.isNaN(started) && !Number.isNaN(finished) && finished > started) {
|
||||||
|
const seconds = (finished - started) / 1000;
|
||||||
|
if (seconds > 0) tps = Math.round((tokens / seconds) * 10) / 10;
|
||||||
|
}
|
||||||
|
const ctxUsed = message.ctx_used;
|
||||||
|
const ctxMax = message.ctx_max;
|
||||||
|
const ctxPart =
|
||||||
|
typeof ctxUsed === 'number'
|
||||||
|
? typeof ctxMax === 'number' && ctxMax > 0
|
||||||
|
? `${ctxUsed} / ${ctxMax} ctx`
|
||||||
|
: `${ctxUsed} ctx`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const cacheHit = message.cache_tokens;
|
||||||
|
const reasoning = message.reasoning_tokens;
|
||||||
|
const cachePart = typeof cacheHit === 'number' && cacheHit > 0 ? `cache ${cacheHit}` : null;
|
||||||
|
const reasoningPart = typeof reasoning === 'number' && reasoning > 0 ? `think ${reasoning}` : null;
|
||||||
|
|
||||||
|
const parts: string[] = [`${tokens} tokens`];
|
||||||
|
if (tps !== null) parts.push(`${tps.toFixed(1)} tok/s`);
|
||||||
|
if (ctxPart) parts.push(ctxPart);
|
||||||
|
if (cachePart) parts.push(cachePart);
|
||||||
|
if (reasoningPart) parts.push(reasoningPart);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-[10px] font-mono text-muted-foreground">
|
||||||
|
{parts.join(' · ')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
apps/web/src/components/message-parts/SummaryCard.tsx
Normal file
62
apps/web/src/components/message-parts/SummaryCard.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { ChevronDown, ChevronRight, Copy, Check } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import type { Message } from '@/api/types';
|
||||||
|
import { MarkdownRenderer } from '@/components/MarkdownRenderer';
|
||||||
|
|
||||||
|
// v1.11 anchored rolling summary. Inserted by services/compaction.ts as a
|
||||||
|
// role='assistant', summary=true row. Distinct from legacy CompactCard
|
||||||
|
// (which renders the kind='compact' system rows produced by v1.10 /compact).
|
||||||
|
// Collapsed by default; header shows the timestamp; body renders the
|
||||||
|
// summary markdown when expanded. Copy button matches CompactCard's affordance.
|
||||||
|
export function SummaryCard({ message }: { message: Message }) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
// Use finished_at when available (that's when the summary actually landed);
|
||||||
|
// fall back to created_at for any row missing it. Both are ISO strings.
|
||||||
|
const ts = message.finished_at ?? message.created_at;
|
||||||
|
const headerTs = ts ? new Date(ts).toLocaleString() : '';
|
||||||
|
|
||||||
|
async function handleCopy() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(message.content);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1200);
|
||||||
|
toast.success('Summary copied to clipboard');
|
||||||
|
} catch {
|
||||||
|
toast.error('Copy failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-primary/30 bg-primary/5 text-sm">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
className="flex items-center gap-1.5 flex-1 min-w-0 text-left text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||||
|
<span className="text-xs font-medium truncate">
|
||||||
|
Compacted summary — {headerTs}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleCopy()}
|
||||||
|
className="p-1 rounded hover:bg-muted text-muted-foreground"
|
||||||
|
aria-label="Copy summary"
|
||||||
|
title="Copy summary"
|
||||||
|
>
|
||||||
|
{copied ? <Check size={12} /> : <Copy size={12} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{expanded && (
|
||||||
|
<div className="px-3 pb-3 text-xs leading-relaxed border-t pt-2">
|
||||||
|
<MarkdownRenderer content={message.content} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
apps/web/src/components/message-parts/index.ts
Normal file
7
apps/web/src/components/message-parts/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export { StatsLine } from './StatsLine';
|
||||||
|
export { ActionRow } from './ActionRow';
|
||||||
|
export { CompactCard } from './CompactCard';
|
||||||
|
export { SummaryCard } from './SummaryCard';
|
||||||
|
export { ReasoningBlock } from './ReasoningBlock';
|
||||||
|
export { MistakeRecoverySentinel } from './MistakeRecoverySentinel';
|
||||||
|
export { SendToTerminalMenu } from './SendToTerminalMenu';
|
||||||
@@ -1,14 +1,31 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { History, Pencil, Send, X } from 'lucide-react';
|
import { Columns, Download, History, Pencil, Send, X } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { useSessionStream } from '@/hooks/useSessionStream';
|
import { useSessionStream } from '@/hooks/useSessionStream';
|
||||||
import { MessageList } from '@/components/MessageList';
|
import { MessageList } from '@/components/MessageList';
|
||||||
import { ChatInput } from '@/components/ChatInput';
|
import { ChatInput } from '@/components/ChatInput';
|
||||||
|
import { ModelPicker } from '@/components/ModelPicker';
|
||||||
import { StaleStreamBanner } from '@/components/StaleStreamBanner';
|
import { StaleStreamBanner } from '@/components/StaleStreamBanner';
|
||||||
import { SessionTimeline } from '@/components/SessionTimeline';
|
import { SessionTimeline } from '@/components/SessionTimeline';
|
||||||
import { TraceViewer } from '@/components/TraceViewer';
|
import { TraceViewer } from '@/components/TraceViewer';
|
||||||
import { sendToChat } from '@/lib/events';
|
import { sendToChat } from '@/lib/events';
|
||||||
|
import { ComparePane, type CompareResponse } from '@/components/ComparePane';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -31,6 +48,107 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
|
|||||||
const [queue, setQueue] = useState<{ id: string; text: string }[]>([]);
|
const [queue, setQueue] = useState<{ id: string; text: string }[]>([]);
|
||||||
const queueIdRef = useRef(0);
|
const queueIdRef = useRef(0);
|
||||||
const processingRef = useRef(false);
|
const processingRef = useRef(false);
|
||||||
|
const chatMessages = stream.messages.filter((m) => m.chat_id === chatId);
|
||||||
|
const streaming = chatMessages.some((m) => m.status === 'streaming');
|
||||||
|
|
||||||
|
// v2.8-compare: compare mode state
|
||||||
|
const [showCompareSelector, setShowCompareSelector] = useState(false);
|
||||||
|
const [compareModels, setCompareModels] = useState<string[]>([]);
|
||||||
|
const [compareResponses, setCompareResponses] = useState<CompareResponse[]>([]);
|
||||||
|
const [compareGroupId, setCompareGroupId] = useState<string | null>(null);
|
||||||
|
const compareMsgIdToModelRef = useRef<Map<string, string>>(new Map());
|
||||||
|
|
||||||
|
// v2.8-compare: derive compare responses from streaming messages.
|
||||||
|
// Watches stream.messages for message IDs tracked in compareMsgIdToModelRef.
|
||||||
|
const compareActive = compareGroupId !== null;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!compareActive) return;
|
||||||
|
const idToModel = compareMsgIdToModelRef.current;
|
||||||
|
if (idToModel.size === 0) return;
|
||||||
|
|
||||||
|
setCompareResponses((prev) => {
|
||||||
|
let changed = false;
|
||||||
|
const next = prev.map((r) => r);
|
||||||
|
for (const msg of chatMessages) {
|
||||||
|
const model = idToModel.get(msg.id);
|
||||||
|
if (!model) continue;
|
||||||
|
const idx = next.findIndex((r) => r.model === model);
|
||||||
|
if (idx === -1) continue;
|
||||||
|
const entry = next[idx]!;
|
||||||
|
if (entry.content !== msg.content || entry.status !== (msg.status === 'streaming' ? 'streaming' : msg.status === 'failed' ? 'error' : 'complete')) {
|
||||||
|
changed = true;
|
||||||
|
next[idx] = {
|
||||||
|
...entry,
|
||||||
|
content: msg.content,
|
||||||
|
status: msg.status === 'streaming' ? 'streaming' : msg.status === 'failed' ? 'error' : 'complete',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed ? next : prev;
|
||||||
|
});
|
||||||
|
}, [chatMessages, compareActive]);
|
||||||
|
|
||||||
|
const handleExitCompare = useCallback(() => {
|
||||||
|
setCompareModels([]);
|
||||||
|
setCompareResponses([]);
|
||||||
|
setCompareGroupId(null);
|
||||||
|
compareMsgIdToModelRef.current.clear();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [compareInput, setCompareInput] = useState('');
|
||||||
|
const [selectedCompareModels, setSelectedCompareModels] = useState<string[]>([]);
|
||||||
|
const [sendingCompare, setSendingCompare] = useState(false);
|
||||||
|
const [availableModels, setAvailableModels] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Fetch available models when the compare selector opens.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showCompareSelector) return;
|
||||||
|
api.models()
|
||||||
|
.then((mods) => setAvailableModels(mods.map((m) => m.id).sort()))
|
||||||
|
.catch(() => {
|
||||||
|
// Fallback: use session model if API fails
|
||||||
|
const sessionModel = sessionChats?.find((c) => c.id === chatId)?.model;
|
||||||
|
setAvailableModels(sessionModel ? [sessionModel] : []);
|
||||||
|
});
|
||||||
|
}, [showCompareSelector, sessionChats, chatId]);
|
||||||
|
|
||||||
|
// v2.8-compare: when user types in ChatInput during compare mode, open the
|
||||||
|
// model selector dialog with the typed message.
|
||||||
|
const handleCompareFromInput = useCallback((content: string) => {
|
||||||
|
setCompareInput(content);
|
||||||
|
setShowCompareSelector(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleCompareSend() {
|
||||||
|
const trimmed = compareInput.trim();
|
||||||
|
if (!trimmed || selectedCompareModels.length < 2 || sendingCompare) return;
|
||||||
|
setSendingCompare(true);
|
||||||
|
try {
|
||||||
|
const result = await api.chats.compare(chatId, trimmed, selectedCompareModels);
|
||||||
|
const idToModel = compareMsgIdToModelRef.current;
|
||||||
|
idToModel.clear();
|
||||||
|
for (const r of result.responses) {
|
||||||
|
idToModel.set(r.assistant_message_id, r.model);
|
||||||
|
}
|
||||||
|
setCompareResponses(
|
||||||
|
selectedCompareModels.map((model) => ({
|
||||||
|
model,
|
||||||
|
assistantMessageId: result.responses.find((r) => r.model === model)?.assistant_message_id ?? '',
|
||||||
|
content: '',
|
||||||
|
status: 'streaming' as const,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
setCompareGroupId(result.compare_group_id);
|
||||||
|
setCompareModels([...selectedCompareModels]);
|
||||||
|
setShowCompareSelector(false);
|
||||||
|
setCompareInput('');
|
||||||
|
setSelectedCompareModels([]);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Compare failed');
|
||||||
|
} finally {
|
||||||
|
setSendingCompare(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (stream.error && stream.error !== lastErrorRef.current) {
|
if (stream.error && stream.error !== lastErrorRef.current) {
|
||||||
@@ -42,9 +160,6 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
|
|||||||
}
|
}
|
||||||
}, [stream.error]);
|
}, [stream.error]);
|
||||||
|
|
||||||
const chatMessages = stream.messages.filter((m) => m.chat_id === chatId);
|
|
||||||
const streaming = chatMessages.some((m) => m.status === 'streaming');
|
|
||||||
|
|
||||||
// v1.12.3: stale-stream detection. Watches the (at most one) streaming
|
// v1.12.3: stale-stream detection. Watches the (at most one) streaming
|
||||||
// assistant row. If its content length doesn't grow for STALE_THRESHOLD_MS,
|
// assistant row. If its content length doesn't grow for STALE_THRESHOLD_MS,
|
||||||
// assume the upstream call is dead and surface the recovery banner. We use
|
// assume the upstream call is dead and surface the recovery banner. We use
|
||||||
@@ -213,10 +328,52 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
async function handleExport(format: 'json' | 'markdown') {
|
||||||
|
try {
|
||||||
|
const content = await api.chats.exportChat(chatId, format);
|
||||||
|
const blob = new Blob([content], { type: format === 'json' ? 'application/json' : 'text/markdown' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `chat-${chatId}.${format === 'json' ? 'json' : 'md'}`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Export failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full min-h-0 relative">
|
<div className="flex flex-col h-full min-h-0 relative">
|
||||||
{chatMessages.length > 0 && (
|
{chatMessages.length > 0 && (
|
||||||
<div className="absolute top-2 right-2 z-10">
|
<div className="absolute top-2 right-2 z-10 flex items-center gap-2">
|
||||||
|
<ModelPicker
|
||||||
|
value={sessionChats?.find((c) => c.id === chatId)?.model ?? null}
|
||||||
|
onChange={async (model) => {
|
||||||
|
try {
|
||||||
|
await api.chats.update(chatId, { model });
|
||||||
|
toast.success(`Model set to ${model}`);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to update model');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCompareSelector(true)}
|
||||||
|
disabled={streaming}
|
||||||
|
className={`
|
||||||
|
inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium
|
||||||
|
transition-colors border
|
||||||
|
bg-background text-muted-foreground border-border hover:bg-muted hover:text-foreground
|
||||||
|
disabled:opacity-40 disabled:cursor-not-allowed
|
||||||
|
`}
|
||||||
|
aria-label="Compare models"
|
||||||
|
title="Compare models"
|
||||||
|
>
|
||||||
|
<Columns size={12} />
|
||||||
|
Compare
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowTimeline((v) => !v)}
|
onClick={() => setShowTimeline((v) => !v)}
|
||||||
@@ -233,16 +390,44 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
|
|||||||
<History size={12} />
|
<History size={12} />
|
||||||
Timeline
|
Timeline
|
||||||
</button>
|
</button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="p-1 rounded hover:bg-muted text-muted-foreground"
|
||||||
|
aria-label="Export chat"
|
||||||
|
title="Export chat"
|
||||||
|
>
|
||||||
|
<Download className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onSelect={() => handleExport('json')}>
|
||||||
|
Export as JSON
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onSelect={() => handleExport('markdown')}>
|
||||||
|
Export as Markdown
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* v1.11.5: ContextBar moved into ChatInput (above the agent picker). */}
|
{/* v1.11.5: ContextBar moved into ChatInput (above the agent picker). */}
|
||||||
|
{compareActive ? (
|
||||||
|
<ComparePane
|
||||||
|
models={compareModels}
|
||||||
|
responses={compareResponses}
|
||||||
|
onClose={handleExitCompare}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<MessageList messages={chatMessages} sessionChats={sessionChats} />
|
<MessageList messages={chatMessages} sessionChats={sessionChats} />
|
||||||
|
)}
|
||||||
|
|
||||||
<TraceViewer chatId={chatId} />
|
<TraceViewer chatId={chatId} />
|
||||||
|
|
||||||
{/* Queued messages */}
|
{/* Queued messages */}
|
||||||
{queue.length > 0 && (
|
{!compareActive && queue.length > 0 && (
|
||||||
<div className="border-t">
|
<div className="border-t">
|
||||||
<div className="max-w-[1000px] mx-auto w-full px-4 py-1 space-y-1">
|
<div className="max-w-[1000px] mx-auto w-full px-4 py-1 space-y-1">
|
||||||
{queue.map((item, i) => (
|
{queue.map((item, i) => (
|
||||||
@@ -282,7 +467,7 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{stale && streamingId && (
|
{!compareActive && stale && streamingId && (
|
||||||
<StaleStreamBanner
|
<StaleStreamBanner
|
||||||
onRetry={() => void handleRetryStale()}
|
onRetry={() => void handleRetryStale()}
|
||||||
onDiscard={() => void handleDiscardStale()}
|
onDiscard={() => void handleDiscardStale()}
|
||||||
@@ -296,7 +481,7 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
|
|||||||
agentId={agentId}
|
agentId={agentId}
|
||||||
onAgentChange={onAgentChange}
|
onAgentChange={onAgentChange}
|
||||||
webSearchEnabled={webSearchEnabled}
|
webSearchEnabled={webSearchEnabled}
|
||||||
onSend={handleSend}
|
onSend={compareActive ? handleCompareFromInput : handleSend}
|
||||||
onForceSend={streaming ? handleForceSend : undefined}
|
onForceSend={streaming ? handleForceSend : undefined}
|
||||||
generating={streaming}
|
generating={streaming}
|
||||||
onStop={handleStop}
|
onStop={handleStop}
|
||||||
@@ -318,6 +503,79 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
|
|||||||
onScrollToMessage={handleScrollToMessage}
|
onScrollToMessage={handleScrollToMessage}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Compare model selector dialog */}
|
||||||
|
{showCompareSelector && (
|
||||||
|
<Dialog open={showCompareSelector} onOpenChange={(open) => { if (!open) setShowCompareSelector(false); }}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Compare Models</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Select 2-3 models to compare. Each model receives the same message and you see responses side by side.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col gap-3 py-4">
|
||||||
|
<textarea
|
||||||
|
value={compareInput}
|
||||||
|
onChange={(e) => setCompareInput(e.target.value)}
|
||||||
|
placeholder="Type your message to compare across models…"
|
||||||
|
rows={3}
|
||||||
|
className="w-full resize-none rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">Select 2-3 models:</div>
|
||||||
|
{availableModels.length === 0 && (
|
||||||
|
<div className="text-xs text-muted-foreground px-1">Loading models…</div>
|
||||||
|
)}
|
||||||
|
{availableModels.map((model) => {
|
||||||
|
const isSelected = selectedCompareModels.includes(model);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={model}
|
||||||
|
className={`
|
||||||
|
flex items-center gap-3 px-3 py-2 rounded-md border text-sm cursor-pointer transition-colors
|
||||||
|
${isSelected
|
||||||
|
? 'border-primary bg-primary/5 text-foreground'
|
||||||
|
: 'border-border hover:bg-muted/50 text-muted-foreground'
|
||||||
|
}
|
||||||
|
${selectedCompareModels.length >= 3 && !isSelected ? 'opacity-40 pointer-events-none' : ''}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => {
|
||||||
|
setSelectedCompareModels((prev) =>
|
||||||
|
isSelected
|
||||||
|
? prev.filter((m) => m !== model)
|
||||||
|
: prev.length < 3
|
||||||
|
? [...prev, model]
|
||||||
|
: prev,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="size-4 accent-primary"
|
||||||
|
/>
|
||||||
|
<span className="flex-1">{model}</span>
|
||||||
|
{isSelected && (
|
||||||
|
<span className="text-[10px] text-muted-foreground shrink-0">
|
||||||
|
{selectedCompareModels.indexOf(model) + 1}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
disabled={selectedCompareModels.length < 2 || sendingCompare || !compareInput.trim()}
|
||||||
|
onClick={() => void handleCompareSend()}
|
||||||
|
>
|
||||||
|
{sendingCompare ? 'Starting…' : `Compare (${selectedCompareModels.length})`}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,10 +48,12 @@ interface Props {
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
paneId: string;
|
paneId: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
parentAgent?: string;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TerminalPane({ sessionId, paneId, label, active = false }: Props) {
|
export function TerminalPane({ sessionId, paneId, label, description, parentAgent, active = false }: Props) {
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const termRef = useRef<Terminal | null>(null);
|
const termRef = useRef<Terminal | null>(null);
|
||||||
const searchRef = useRef<SearchAddon | null>(null);
|
const searchRef = useRef<SearchAddon | null>(null);
|
||||||
@@ -112,6 +114,8 @@ export function TerminalPane({ sessionId, paneId, label, active = false }: Props
|
|||||||
termRef,
|
termRef,
|
||||||
sessionId,
|
sessionId,
|
||||||
paneId,
|
paneId,
|
||||||
|
description,
|
||||||
|
parentAgent,
|
||||||
fit: fit.fit,
|
fit: fit.fit,
|
||||||
getSize: fit.getSize,
|
getSize: fit.getSize,
|
||||||
setSize: fit.setSize,
|
setSize: fit.setSize,
|
||||||
@@ -148,6 +152,18 @@ export function TerminalPane({ sessionId, paneId, label, active = false }: Props
|
|||||||
onArmCtrl={socket.armCtrl}
|
onArmCtrl={socket.armCtrl}
|
||||||
onFit={fit.fit}
|
onFit={fit.fit}
|
||||||
/>
|
/>
|
||||||
|
{(description || parentAgent) && (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1 text-xs border-b border-border/30 bg-[#0b0f14] shrink-0">
|
||||||
|
{parentAgent && (
|
||||||
|
<span className="inline-flex items-center rounded-full bg-primary/10 text-primary px-2 py-0.5 text-[10px] font-medium leading-none">
|
||||||
|
{parentAgent}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{description && (
|
||||||
|
<span className="text-muted-foreground truncate">{description}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="flex-1 min-h-0 w-full overflow-hidden"
|
className="flex-1 min-h-0 w-full overflow-hidden"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Slot } from "radix-ui"
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-colors motion-reduce:transition-none outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:scale-[0.97] disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ interface SocketDeps {
|
|||||||
termRef: React.MutableRefObject<Terminal | null>;
|
termRef: React.MutableRefObject<Terminal | null>;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
paneId: string;
|
paneId: string;
|
||||||
|
description?: string;
|
||||||
|
parentAgent?: string;
|
||||||
fit: TerminalFit['fit'];
|
fit: TerminalFit['fit'];
|
||||||
getSize: TerminalFit['getSize'];
|
getSize: TerminalFit['getSize'];
|
||||||
setSize: TerminalFit['setSize'];
|
setSize: TerminalFit['setSize'];
|
||||||
@@ -40,6 +42,8 @@ export function useTerminalSocket({
|
|||||||
termRef,
|
termRef,
|
||||||
sessionId,
|
sessionId,
|
||||||
paneId,
|
paneId,
|
||||||
|
description,
|
||||||
|
parentAgent,
|
||||||
fit,
|
fit,
|
||||||
getSize,
|
getSize,
|
||||||
setSize,
|
setSize,
|
||||||
@@ -276,7 +280,7 @@ export function useTerminalSocket({
|
|||||||
fit();
|
fit();
|
||||||
const { cols, rows } = getSize();
|
const { cols, rows } = getSize();
|
||||||
api.terminals
|
api.terminals
|
||||||
.start(sessionId, paneId, cols, rows)
|
.start(sessionId, paneId, cols, rows, description, parentAgent)
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
/* WS handler will ensureSession itself — non-fatal */
|
/* WS handler will ensureSession itself — non-fatal */
|
||||||
})
|
})
|
||||||
|
|||||||
98
apps/web/src/hooks/useDraftPersistence.ts
Normal file
98
apps/web/src/hooks/useDraftPersistence.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
const STORAGE_PREFIX = 'boocode_draft_';
|
||||||
|
const SAVE_DEBOUNCE_MS = 500;
|
||||||
|
|
||||||
|
function getKey(chatId: string): string {
|
||||||
|
return `${STORAGE_PREFIX}${chatId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readDraft(key: string): string {
|
||||||
|
if (typeof window === 'undefined') return '';
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(key) ?? '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeDraft(key: string, text: string): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
try {
|
||||||
|
if (text) {
|
||||||
|
localStorage.setItem(key, text);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// storage full or unavailable — silently ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeDraft(key: string): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
} catch {
|
||||||
|
// silently ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DraftPersistenceResult {
|
||||||
|
/** Current draft state, initialized from localStorage on mount. */
|
||||||
|
draft: string;
|
||||||
|
/** Update draft with 500ms debounced persistence to localStorage. */
|
||||||
|
setDraft: (text: string) => void;
|
||||||
|
/** Clear draft state and remove localStorage entry immediately. */
|
||||||
|
clearDraft: () => void;
|
||||||
|
/** Re-read from localStorage, update state, and return saved value. */
|
||||||
|
restoreDraft: () => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDraftPersistence(chatId: string | undefined): DraftPersistenceResult {
|
||||||
|
const key = chatId ? getKey(chatId) : null;
|
||||||
|
const [draft, setDraftState] = useState(() => (key ? readDraft(key) : ''));
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const keyRef = useRef(key);
|
||||||
|
keyRef.current = key;
|
||||||
|
|
||||||
|
// Cleanup timer on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current !== null) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setDraft = useCallback((text: string) => {
|
||||||
|
setDraftState(text);
|
||||||
|
if (timerRef.current !== null) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
}
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
const k = keyRef.current;
|
||||||
|
if (k) writeDraft(k, text);
|
||||||
|
}, SAVE_DEBOUNCE_MS);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearDraft = useCallback(() => {
|
||||||
|
setDraftState('');
|
||||||
|
if (timerRef.current !== null) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
const k = keyRef.current;
|
||||||
|
if (k) removeDraft(k);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const restoreDraft = useCallback((): string => {
|
||||||
|
const k = keyRef.current;
|
||||||
|
if (!k) return '';
|
||||||
|
const saved = readDraft(k);
|
||||||
|
setDraftState(saved);
|
||||||
|
return saved;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { draft, setDraft, clearDraft, restoreDraft };
|
||||||
|
}
|
||||||
616
apps/web/src/hooks/useSessionStream.test.ts
Normal file
616
apps/web/src/hooks/useSessionStream.test.ts
Normal file
@@ -0,0 +1,616 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import React, { act } from 'react';
|
||||||
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
|
import type { Message, WsFrame } from '@/api/types';
|
||||||
|
import { useSessionStream } from './useSessionStream';
|
||||||
|
|
||||||
|
// ── Hoisted mock values ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const { mockMessagesList, mockEmit, mockSubscribe, mockRecordUsage } = vi.hoisted(
|
||||||
|
() => ({
|
||||||
|
mockMessagesList: vi.fn(),
|
||||||
|
mockEmit: vi.fn(),
|
||||||
|
mockSubscribe: vi.fn().mockReturnValue(vi.fn()),
|
||||||
|
mockRecordUsage: vi.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Module mocks ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({
|
||||||
|
toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/api/client', () => ({
|
||||||
|
api: { messages: { list: mockMessagesList } },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./sessionEvents', () => ({
|
||||||
|
sessionEvents: { emit: mockEmit, subscribe: mockSubscribe },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./useChatThroughput', () => ({
|
||||||
|
recordUsage: mockRecordUsage,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── Test constants ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const SESSION_ID = '00000000-0000-0000-0000-000000000001';
|
||||||
|
const CHAT_ID = '00000000-0000-0000-0000-000000000002';
|
||||||
|
const MSG_ID = '00000000-0000-0000-0000-000000000003';
|
||||||
|
const TOOL_MSG_ID = '00000000-0000-0000-0000-000000000004';
|
||||||
|
|
||||||
|
// ── Frame builder helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function textDelta(seq: number, content: string): WsFrame {
|
||||||
|
return {
|
||||||
|
type: 'channel_delta', seq, channel: 'text', message_id: MSG_ID, chat_id: CHAT_ID, content,
|
||||||
|
} as unknown as WsFrame;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolCallDelta(
|
||||||
|
seq: number,
|
||||||
|
tc: { id: string; name: string; args: Record<string, unknown> },
|
||||||
|
): WsFrame {
|
||||||
|
return {
|
||||||
|
type: 'channel_delta', seq, channel: 'tool_call', message_id: MSG_ID, chat_id: CHAT_ID, tool_call: tc,
|
||||||
|
} as unknown as WsFrame;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolResultDelta(seq: number, callId: string, output: unknown): WsFrame {
|
||||||
|
return {
|
||||||
|
type: 'channel_delta', seq, channel: 'tool_result',
|
||||||
|
tool_message_id: TOOL_MSG_ID, chat_id: CHAT_ID, tool_call_id: callId,
|
||||||
|
output, truncated: false,
|
||||||
|
} as unknown as WsFrame;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusDelta(
|
||||||
|
seq: number, status: 'running' | 'complete' | 'cancelled' | 'failed',
|
||||||
|
overrides?: Record<string, unknown>,
|
||||||
|
): WsFrame {
|
||||||
|
return {
|
||||||
|
type: 'channel_delta', seq, channel: 'status', message_id: MSG_ID, chat_id: CHAT_ID, status,
|
||||||
|
...(overrides ?? {}),
|
||||||
|
} as unknown as WsFrame;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorDelta(seq: number, error: string): WsFrame {
|
||||||
|
return {
|
||||||
|
type: 'channel_delta', seq, channel: 'error', message_id: MSG_ID, chat_id: CHAT_ID, error,
|
||||||
|
} as unknown as WsFrame;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── WebSocket mock globals ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface MockWs {
|
||||||
|
onopen: (() => void) | null;
|
||||||
|
onmessage: ((ev: { data: string }) => void) | null;
|
||||||
|
onclose: ((ev: { code?: number; reason?: string }) => void) | null;
|
||||||
|
onerror: (() => void) | null;
|
||||||
|
send: ReturnType<typeof vi.fn>;
|
||||||
|
close: ReturnType<typeof vi.fn>;
|
||||||
|
readyState: number;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentMockWs: MockWs | null = null;
|
||||||
|
let wsConstructCount = 0;
|
||||||
|
|
||||||
|
function createWsMock(): MockWs {
|
||||||
|
return {
|
||||||
|
onopen: null, onmessage: null, onclose: null, onerror: null,
|
||||||
|
send: vi.fn(), close: vi.fn(), readyState: 1, url: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerWsOpen(): void { currentMockWs?.onopen?.(); }
|
||||||
|
function triggerWsMessage(frame: WsFrame): void {
|
||||||
|
currentMockWs?.onmessage?.({ data: JSON.stringify(frame) });
|
||||||
|
}
|
||||||
|
function triggerWsClose(): void { currentMockWs?.onclose?.({}); }
|
||||||
|
function getWsSendCalls(): string[] {
|
||||||
|
return (currentMockWs?.send.mock.calls ?? []).map((c: unknown[]) => String(c[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── React test harness ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function createHarness() {
|
||||||
|
const states: Array<{ messages: Message[]; connected: boolean; error: string | null }> = [];
|
||||||
|
|
||||||
|
function Wrapper({ sessionId }: { sessionId: string | undefined }) {
|
||||||
|
const state = useSessionStream(sessionId);
|
||||||
|
states.push(state);
|
||||||
|
return React.createElement('div');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
Wrapper,
|
||||||
|
lastState: () => states[states.length - 1] ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let root: Root;
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
currentMockWs = null;
|
||||||
|
wsConstructCount = 0;
|
||||||
|
mockMessagesList.mockReset().mockResolvedValue([]);
|
||||||
|
mockEmit.mockReset();
|
||||||
|
mockSubscribe.mockReset().mockReturnValue(vi.fn());
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
vi.spyOn(window, 'WebSocket').mockImplementation(function (this: WebSocket, url: string | URL) {
|
||||||
|
const ws = createWsMock();
|
||||||
|
ws.url = String(url);
|
||||||
|
currentMockWs = ws;
|
||||||
|
wsConstructCount++;
|
||||||
|
const proto = {
|
||||||
|
get onopen() { return ws.onopen; },
|
||||||
|
set onopen(fn) { ws.onopen = fn as () => void; },
|
||||||
|
get onmessage() { return ws.onmessage; },
|
||||||
|
set onmessage(fn) { ws.onmessage = fn as (ev: { data: string }) => void; },
|
||||||
|
get onclose() { return ws.onclose; },
|
||||||
|
set onclose(fn) { ws.onclose = fn as (ev: { code?: number; reason?: string }) => void; },
|
||||||
|
get onerror() { return ws.onerror; },
|
||||||
|
set onerror(fn) { ws.onerror = fn as () => void; },
|
||||||
|
send: vi.fn((d: string) => ws.send(d)),
|
||||||
|
close: vi.fn(() => { ws.close(); ws.onclose?.({}); }),
|
||||||
|
readyState: 1, url: ws.url,
|
||||||
|
CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3,
|
||||||
|
};
|
||||||
|
return proto as unknown as WebSocket;
|
||||||
|
});
|
||||||
|
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (root) {
|
||||||
|
act(() => { root.unmount(); });
|
||||||
|
}
|
||||||
|
if (container.parentNode) {
|
||||||
|
container.parentNode.removeChild(container);
|
||||||
|
}
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
currentMockWs = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function renderHook(sessionId: string | undefined) {
|
||||||
|
const harness = createHarness();
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(harness.Wrapper, {
|
||||||
|
sessionId: sessionId ?? (null as unknown as undefined),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await act(async () => {});
|
||||||
|
return harness;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flush() {
|
||||||
|
await act(async () => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('useSessionStream', () => {
|
||||||
|
describe('connection lifecycle', () => {
|
||||||
|
it('does not connect when sessionId is undefined', async () => {
|
||||||
|
const h = await renderHook(undefined);
|
||||||
|
const s = h.lastState();
|
||||||
|
expect(s).not.toBeNull();
|
||||||
|
expect(s!.connected).toBe(false);
|
||||||
|
expect(s!.messages).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('connects when sessionId is provided', async () => {
|
||||||
|
await renderHook(SESSION_ID);
|
||||||
|
expect(wsConstructCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets connected=true on WebSocket open', async () => {
|
||||||
|
const h = await renderHook(SESSION_ID);
|
||||||
|
act(() => { triggerWsOpen(); });
|
||||||
|
await flush();
|
||||||
|
expect(h.lastState()!.connected).toBe(true);
|
||||||
|
expect(h.lastState()!.error).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends reconnect frame with lastSeqPerChannel on open', async () => {
|
||||||
|
await renderHook(SESSION_ID);
|
||||||
|
act(() => { triggerWsOpen(); });
|
||||||
|
await flush();
|
||||||
|
const sends = getWsSendCalls();
|
||||||
|
const reconnectMsg = sends.find((s) => s.includes('reconnect'));
|
||||||
|
expect(reconnectMsg).toBeDefined();
|
||||||
|
if (reconnectMsg) {
|
||||||
|
const parsed = JSON.parse(reconnectMsg);
|
||||||
|
expect(parsed.type).toBe('reconnect');
|
||||||
|
expect(parsed.lastSeqPerChannel).toEqual({});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('non-channel frames', () => {
|
||||||
|
it('processes snapshot frame', async () => {
|
||||||
|
await renderHook(SESSION_ID);
|
||||||
|
act(() => { triggerWsOpen(); });
|
||||||
|
await flush();
|
||||||
|
const msg: Message = {
|
||||||
|
id: MSG_ID, session_id: SESSION_ID, chat_id: CHAT_ID, role: 'user', content: 'hi',
|
||||||
|
kind: 'message', tool_calls: null, tool_results: null, status: 'complete',
|
||||||
|
last_seq: 0, tokens_used: null, ctx_used: null, ctx_max: null,
|
||||||
|
cache_tokens: null, reasoning_tokens: null, model: null,
|
||||||
|
started_at: null, finished_at: null, created_at: '2026-01-01T00:00:00Z', metadata: null,
|
||||||
|
};
|
||||||
|
act(() => { triggerWsMessage({ type: 'snapshot', messages: [msg] } as WsFrame); });
|
||||||
|
await flush();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('processes delta frame for existing message', async () => {
|
||||||
|
await renderHook(SESSION_ID);
|
||||||
|
act(() => { triggerWsOpen(); });
|
||||||
|
await flush();
|
||||||
|
const streamingMsg: Message = {
|
||||||
|
id: MSG_ID, session_id: SESSION_ID, chat_id: CHAT_ID, role: 'assistant', content: '',
|
||||||
|
kind: 'message', tool_calls: null, tool_results: null, status: 'streaming',
|
||||||
|
last_seq: 0, tokens_used: null, ctx_used: null, ctx_max: null,
|
||||||
|
cache_tokens: null, reasoning_tokens: null, model: null,
|
||||||
|
started_at: null, finished_at: null, created_at: '2026-01-01T00:00:00Z', metadata: null,
|
||||||
|
};
|
||||||
|
act(() => { triggerWsMessage({ type: 'snapshot', messages: [streamingMsg] } as WsFrame); });
|
||||||
|
await flush();
|
||||||
|
act(() => { triggerWsMessage({ type: 'delta', message_id: MSG_ID, content: 'Hello!' } as WsFrame); });
|
||||||
|
await flush();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits git_diff_refresh on message_complete', async () => {
|
||||||
|
await renderHook(SESSION_ID);
|
||||||
|
act(() => { triggerWsOpen(); });
|
||||||
|
await flush();
|
||||||
|
act(() => {
|
||||||
|
triggerWsMessage({
|
||||||
|
type: 'message_started', message_id: MSG_ID, chat_id: CHAT_ID, role: 'assistant',
|
||||||
|
} as WsFrame);
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
act(() => {
|
||||||
|
triggerWsMessage({
|
||||||
|
type: 'message_complete', message_id: MSG_ID, chat_id: CHAT_ID,
|
||||||
|
} as WsFrame);
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
expect(mockEmit).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ type: 'git_diff_refresh' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('processes messages_deleted frame', async () => {
|
||||||
|
await renderHook(SESSION_ID);
|
||||||
|
act(() => { triggerWsOpen(); });
|
||||||
|
await flush();
|
||||||
|
act(() => {
|
||||||
|
triggerWsMessage({
|
||||||
|
type: 'messages_deleted', message_ids: [MSG_ID], chat_id: CHAT_ID,
|
||||||
|
} as WsFrame);
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('processes compacted frame and refetches messages', async () => {
|
||||||
|
await renderHook(SESSION_ID);
|
||||||
|
act(() => { triggerWsOpen(); });
|
||||||
|
await flush();
|
||||||
|
act(() => {
|
||||||
|
triggerWsMessage({
|
||||||
|
type: 'compacted', session_id: SESSION_ID, chat_id: CHAT_ID, summary_message_id: MSG_ID,
|
||||||
|
} as WsFrame);
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
expect(mockMessagesList).toHaveBeenCalledWith(SESSION_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('processes usage frame and calls recordUsage', async () => {
|
||||||
|
await renderHook(SESSION_ID);
|
||||||
|
act(() => { triggerWsOpen(); });
|
||||||
|
await flush();
|
||||||
|
act(() => {
|
||||||
|
triggerWsMessage({
|
||||||
|
type: 'usage', message_id: MSG_ID, chat_id: CHAT_ID,
|
||||||
|
completion_tokens: 100, ctx_used: 5000, ctx_max: 32768,
|
||||||
|
} as WsFrame);
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
expect(mockRecordUsage).toHaveBeenCalledWith(CHAT_ID, {
|
||||||
|
completion_tokens: 100, ctx_used: 5000, ctx_max: 32768,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits chat_updated on chat_renamed frame', async () => {
|
||||||
|
await renderHook(SESSION_ID);
|
||||||
|
act(() => { triggerWsOpen(); });
|
||||||
|
await flush();
|
||||||
|
act(() => {
|
||||||
|
triggerWsMessage({
|
||||||
|
type: 'chat_renamed', chat_id: CHAT_ID, name: 'New Chat Name',
|
||||||
|
} as WsFrame);
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
expect(mockEmit).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ type: 'chat_updated', chat_id: CHAT_ID, name: 'New Chat Name' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles pass-through frames: agent_snapshot, agent_status_updated, flow_run*, battle*', async () => {
|
||||||
|
await renderHook(SESSION_ID);
|
||||||
|
act(() => { triggerWsOpen(); });
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
triggerWsMessage({ type: 'agent_snapshot', chat_id: CHAT_ID, model: 'qwen', turn_number: 3 } as WsFrame);
|
||||||
|
triggerWsMessage({
|
||||||
|
type: 'agent_status_updated', chat_id: CHAT_ID, agent: 'coder', status: 'working',
|
||||||
|
at: '2026-01-01T00:00:00Z',
|
||||||
|
} as WsFrame);
|
||||||
|
triggerWsMessage({
|
||||||
|
type: 'flow_run_started', run_id: MSG_ID, flow_name: 'test', band: 'small', steps: [],
|
||||||
|
} as unknown as WsFrame);
|
||||||
|
triggerWsMessage({
|
||||||
|
type: 'flow_run_step_updated', run_id: MSG_ID, step_id: 's1', status: 'completed',
|
||||||
|
} as unknown as WsFrame);
|
||||||
|
triggerWsMessage({
|
||||||
|
type: 'battle_started', battle_id: MSG_ID, battle_type: 'coding', prompt: 'test', contestants: [],
|
||||||
|
} as unknown as WsFrame);
|
||||||
|
triggerWsMessage({
|
||||||
|
type: 'contestant_updated', battle_id: MSG_ID, contestant_id: CHAT_ID,
|
||||||
|
} as unknown as WsFrame);
|
||||||
|
triggerWsMessage({ type: 'battle_updated', battle_id: MSG_ID } as unknown as WsFrame);
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('channel_delta frames', () => {
|
||||||
|
async function setup() {
|
||||||
|
const h = await renderHook(SESSION_ID);
|
||||||
|
act(() => { triggerWsOpen(); });
|
||||||
|
await flush();
|
||||||
|
act(() => {
|
||||||
|
triggerWsMessage({
|
||||||
|
type: 'message_started', message_id: MSG_ID, chat_id: CHAT_ID, role: 'assistant',
|
||||||
|
} as WsFrame);
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('processes text channel delta', async () => {
|
||||||
|
await setup();
|
||||||
|
act(() => { triggerWsMessage(textDelta(0, 'Hello ')); });
|
||||||
|
await flush();
|
||||||
|
act(() => { triggerWsMessage(textDelta(1, 'World!')); });
|
||||||
|
await flush();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('processes out-of-order deltas (seq=1 before seq=0)', async () => {
|
||||||
|
await setup();
|
||||||
|
act(() => { triggerWsMessage(textDelta(1, 'World!')); });
|
||||||
|
await flush();
|
||||||
|
act(() => { triggerWsMessage(textDelta(0, 'Hello ')); });
|
||||||
|
await flush();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores duplicate seq', async () => {
|
||||||
|
await setup();
|
||||||
|
act(() => { triggerWsMessage(textDelta(0, 'Hello ')); });
|
||||||
|
await flush();
|
||||||
|
act(() => { triggerWsMessage(textDelta(0, 'Hello ')); });
|
||||||
|
await flush();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fills gap and flushes in order (seq=0, seq=2, then seq=1)', async () => {
|
||||||
|
await setup();
|
||||||
|
act(() => { triggerWsMessage(textDelta(0, 'First ')); });
|
||||||
|
await flush();
|
||||||
|
act(() => { triggerWsMessage(textDelta(2, 'Third ')); });
|
||||||
|
await flush();
|
||||||
|
act(() => { triggerWsMessage(textDelta(1, 'Second ')); });
|
||||||
|
await flush();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('processes tool_call channel delta', async () => {
|
||||||
|
await setup();
|
||||||
|
act(() => {
|
||||||
|
triggerWsMessage(toolCallDelta(0, { id: 'call_1', name: 'read', args: { path: '/' } }));
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('processes tool_result channel delta', async () => {
|
||||||
|
await renderHook(SESSION_ID);
|
||||||
|
act(() => { triggerWsOpen(); });
|
||||||
|
await flush();
|
||||||
|
act(() => { triggerWsMessage(toolResultDelta(0, 'call_1', { data: 'file content' })); });
|
||||||
|
await flush();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('processes error channel delta', async () => {
|
||||||
|
await renderHook(SESSION_ID);
|
||||||
|
act(() => { triggerWsOpen(); });
|
||||||
|
await flush();
|
||||||
|
act(() => { triggerWsMessage(errorDelta(0, 'Something went wrong')); });
|
||||||
|
await flush();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('processes status delta: running creates message, complete terminates it', async () => {
|
||||||
|
await renderHook(SESSION_ID);
|
||||||
|
act(() => { triggerWsOpen(); });
|
||||||
|
await flush();
|
||||||
|
act(() => { triggerWsMessage(statusDelta(0, 'running')); });
|
||||||
|
await flush();
|
||||||
|
act(() => {
|
||||||
|
triggerWsMessage(statusDelta(1, 'complete', { tokens_used: 42, ctx_used: 1000 }));
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles multi-channel interleaved deltas with independent seq', async () => {
|
||||||
|
await renderHook(SESSION_ID);
|
||||||
|
act(() => { triggerWsOpen(); });
|
||||||
|
await flush();
|
||||||
|
const tId1 = '00000000-0000-0000-0000-000000000010';
|
||||||
|
const tId2 = '00000000-0000-0000-0000-000000000011';
|
||||||
|
act(() => {
|
||||||
|
triggerWsMessage({ type: 'message_started', message_id: tId1, chat_id: CHAT_ID, role: 'assistant' } as WsFrame);
|
||||||
|
triggerWsMessage({ type: 'message_started', message_id: tId2, chat_id: CHAT_ID, role: 'assistant' } as WsFrame);
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
act(() => {
|
||||||
|
triggerWsMessage({ type: 'channel_delta', seq: 0, channel: 'text', message_id: tId1, chat_id: CHAT_ID, content: 'A' } as unknown as WsFrame);
|
||||||
|
triggerWsMessage({ type: 'channel_delta', seq: 1, channel: 'text', message_id: tId1, chat_id: CHAT_ID, content: 'B' } as unknown as WsFrame);
|
||||||
|
triggerWsMessage({ type: 'channel_delta', seq: 0, channel: 'tool_call', message_id: tId2, chat_id: CHAT_ID, tool_call: { id: 'c1', name: 'ls', args: {} } } as unknown as WsFrame);
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('status delta with metadata', () => {
|
||||||
|
it('applies tokens_used, ctx_used, model to message via status delta', async () => {
|
||||||
|
await renderHook(SESSION_ID);
|
||||||
|
act(() => { triggerWsOpen(); });
|
||||||
|
await flush();
|
||||||
|
act(() => { triggerWsMessage(statusDelta(0, 'running')); });
|
||||||
|
await flush();
|
||||||
|
act(() => {
|
||||||
|
triggerWsMessage(
|
||||||
|
statusDelta(1, 'running', {
|
||||||
|
tokens_used: 150, ctx_used: 5000, ctx_max: 32768, model: 'qwen-2.5-32b',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reconnection behavior', () => {
|
||||||
|
it('reconnects on WebSocket close with backoff', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
await renderHook(SESSION_ID);
|
||||||
|
act(() => { triggerWsOpen(); });
|
||||||
|
await flush();
|
||||||
|
const initialCount = wsConstructCount;
|
||||||
|
act(() => { triggerWsClose(); });
|
||||||
|
// Before 1000ms, no reconnect yet
|
||||||
|
await vi.advanceTimersByTimeAsync(500);
|
||||||
|
expect(wsConstructCount).toBe(initialCount);
|
||||||
|
// After 1000ms from close, reconnect fires
|
||||||
|
await vi.advanceTimersByTimeAsync(600);
|
||||||
|
expect(wsConstructCount).toBe(initialCount + 1);
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends lastSeqPerChannel on reconnect', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
await renderHook(SESSION_ID);
|
||||||
|
act(() => { triggerWsOpen(); });
|
||||||
|
await flush();
|
||||||
|
act(() => { triggerWsMessage(statusDelta(0, 'running')); });
|
||||||
|
await flush();
|
||||||
|
act(() => { triggerWsMessage(statusDelta(1, 'complete')); });
|
||||||
|
await flush();
|
||||||
|
act(() => { triggerWsClose(); });
|
||||||
|
await vi.advanceTimersByTimeAsync(1100);
|
||||||
|
vi.clearAllMocks();
|
||||||
|
act(() => { triggerWsOpen(); });
|
||||||
|
await flush();
|
||||||
|
const sends = getWsSendCalls();
|
||||||
|
const reconnectMsg = sends.find((s) => s.includes('reconnect'));
|
||||||
|
expect(reconnectMsg).toBeDefined();
|
||||||
|
if (reconnectMsg) {
|
||||||
|
const parsed = JSON.parse(reconnectMsg);
|
||||||
|
expect(parsed.type).toBe('reconnect');
|
||||||
|
expect(parsed.lastSeqPerChannel).toBeDefined();
|
||||||
|
expect(parsed.lastSeqPerChannel.status).toBe(2);
|
||||||
|
}
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('channel stall detection', () => {
|
||||||
|
it('emits refetch_messages when channel stalls for 5s', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
await renderHook(SESSION_ID);
|
||||||
|
act(() => { triggerWsOpen(); });
|
||||||
|
await flush();
|
||||||
|
act(() => { triggerWsMessage(statusDelta(1, 'complete')); });
|
||||||
|
await flush();
|
||||||
|
await vi.advanceTimersByTimeAsync(6000);
|
||||||
|
const refetchCalls = mockEmit.mock.calls.filter(
|
||||||
|
(c: unknown[]) => (c[0] as { type: string }).type === 'refetch_messages',
|
||||||
|
);
|
||||||
|
expect(refetchCalls.length).toBeGreaterThanOrEqual(1);
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not stall when buffer is empty', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
await renderHook(SESSION_ID);
|
||||||
|
act(() => { triggerWsOpen(); });
|
||||||
|
await flush();
|
||||||
|
await vi.advanceTimersByTimeAsync(10000);
|
||||||
|
const refetchCalls = mockEmit.mock.calls.filter(
|
||||||
|
(c: unknown[]) => (c[0] as { type: string }).type === 'refetch_messages',
|
||||||
|
);
|
||||||
|
expect(refetchCalls.length).toBe(0);
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sessionEvents subscription', () => {
|
||||||
|
it('calls api.messages.list when refetch_messages event fires', async () => {
|
||||||
|
await renderHook(SESSION_ID);
|
||||||
|
act(() => { triggerWsOpen(); });
|
||||||
|
await flush();
|
||||||
|
expect(mockSubscribe).toHaveBeenCalled();
|
||||||
|
const fn = mockSubscribe.mock.calls[0]?.[0] as (e: { type: string }) => void;
|
||||||
|
fn({ type: 'refetch_messages' });
|
||||||
|
await flush();
|
||||||
|
expect(mockMessagesList).toHaveBeenCalledWith(SESSION_ID);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('invalid frames', () => {
|
||||||
|
it('ignores bad JSON', async () => {
|
||||||
|
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
await renderHook(SESSION_ID);
|
||||||
|
act(() => { triggerWsOpen(); });
|
||||||
|
await flush();
|
||||||
|
currentMockWs?.onmessage?.({ data: 'not-json-at-all' });
|
||||||
|
await flush();
|
||||||
|
expect(spy).toHaveBeenCalled();
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores schema-invalid frames', async () => {
|
||||||
|
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
await renderHook(SESSION_ID);
|
||||||
|
act(() => { triggerWsOpen(); });
|
||||||
|
await flush();
|
||||||
|
currentMockWs?.onmessage?.({ data: JSON.stringify({ type: 'unknown_type' }) });
|
||||||
|
await flush();
|
||||||
|
expect(spy).toHaveBeenCalled();
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Note: git_diff_refresh on status delta 'complete' is not testable through
|
||||||
|
// the hook because the ChannelDeltaFrame Zod schema strips the `status` field
|
||||||
|
// (it's not in the schema's field list — only StatusChannelPayload has it).
|
||||||
|
// The message_complete → git_diff_refresh path is tested above and passes.
|
||||||
|
// The status delta path requires a schema fix in @boocode/contracts/ws-frames.
|
||||||
|
});
|
||||||
11
apps/web/src/hooks/useTerminals.ts
Normal file
11
apps/web/src/hooks/useTerminals.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { terminalsRegistry, type TerminalRegistration } from '@/lib/events';
|
||||||
|
|
||||||
|
// v1.10 booterm: tiny subscription hook for the mounted-terminals registry.
|
||||||
|
// Used by the right-click "Send to terminal" submenu so it always reflects
|
||||||
|
// currently-open terminal panes without prop drilling from Workspace.
|
||||||
|
export function useTerminals(): TerminalRegistration[] {
|
||||||
|
const [list, setList] = useState(() => terminalsRegistry.list());
|
||||||
|
useEffect(() => terminalsRegistry.subscribe(() => setList(terminalsRegistry.list())), []);
|
||||||
|
return list;
|
||||||
|
}
|
||||||
43
apps/web/src/lib/keyboard-shortcuts.ts
Normal file
43
apps/web/src/lib/keyboard-shortcuts.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
export interface ShortcutGroup {
|
||||||
|
category: string;
|
||||||
|
shortcuts: { keys: string[]; description: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const KEYBOARD_SHORTCUTS: ShortcutGroup[] = [
|
||||||
|
{
|
||||||
|
category: 'Navigation',
|
||||||
|
shortcuts: [
|
||||||
|
{ keys: ['Ctrl', '`'], description: 'Jump to terminal pane' },
|
||||||
|
{ keys: ['Ctrl', 'T'], description: 'New terminal pane' },
|
||||||
|
{ keys: ['Ctrl', 'C'], description: 'New chat pane' },
|
||||||
|
{ keys: ['Ctrl', 'W'], description: 'Close active pane' },
|
||||||
|
{ keys: ['Tab'], description: 'Next pane' },
|
||||||
|
{ keys: ['Shift', 'Tab'], description: 'Previous pane' },
|
||||||
|
{ keys: ['Ctrl', '1-9'], description: 'Jump to pane by number' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'Chat',
|
||||||
|
shortcuts: [
|
||||||
|
{ keys: ['Enter'], description: 'Send message' },
|
||||||
|
{ keys: ['Shift', 'Enter'], description: 'New line' },
|
||||||
|
{ keys: ['@'], description: 'Mention file' },
|
||||||
|
{ keys: ['/'], description: 'Slash commands' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'Terminal',
|
||||||
|
shortcuts: [
|
||||||
|
{ keys: ['Ctrl', 'Shift', 'C'], description: 'Copy from terminal' },
|
||||||
|
{ keys: ['Ctrl', 'F'], description: 'Search terminal' },
|
||||||
|
{ keys: ['Esc'], description: 'Close terminal search' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'General',
|
||||||
|
shortcuts: [
|
||||||
|
{ keys: ['Ctrl', '/'], description: 'Toggle this shortcuts panel' },
|
||||||
|
{ keys: ['?'], description: 'Toggle this shortcuts panel' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
49
apps/web/src/lib/tool-utils.ts
Normal file
49
apps/web/src/lib/tool-utils.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
// Set of built-in tool names (from formatToolArgs in ToolCallLine.tsx).
|
||||||
|
// Any tool not in this set that has a `server_tool` name pattern is an MCP tool.
|
||||||
|
export const BUILT_IN_TOOLS = new Set([
|
||||||
|
'view_file',
|
||||||
|
'list_dir',
|
||||||
|
'grep',
|
||||||
|
'find_files',
|
||||||
|
'git_status',
|
||||||
|
'skill_use',
|
||||||
|
'get_codebase_overview',
|
||||||
|
'get_file_analysis',
|
||||||
|
'get_symbol_info',
|
||||||
|
'search_symbols',
|
||||||
|
'get_dependencies',
|
||||||
|
'watch_changes',
|
||||||
|
'get_semantic_neighborhoods',
|
||||||
|
'get_framework_analysis',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the tool name follows the `<server>_<tool>` MCP pattern.
|
||||||
|
* Built-in tools (view_file, grep, skill_use, etc.) are excluded even if
|
||||||
|
* they happen to contain an underscore (e.g. 'git_status', 'skill_use').
|
||||||
|
*/
|
||||||
|
export function isMcpTool(name: string): boolean {
|
||||||
|
return name.includes('_') && !BUILT_IN_TOOLS.has(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the MCP server name from a tool call name.
|
||||||
|
* For 'context7_searchWeb' returns 'context7'.
|
||||||
|
* Returns null for native tools.
|
||||||
|
*/
|
||||||
|
export function extractServerName(name: string): string | null {
|
||||||
|
const idx = name.indexOf('_');
|
||||||
|
if (idx === -1) return null;
|
||||||
|
return name.slice(0, idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the tool name (without server prefix) from an MCP tool call name.
|
||||||
|
* For 'context7_searchWeb' returns 'searchWeb'.
|
||||||
|
* Returns null for native tools.
|
||||||
|
*/
|
||||||
|
export function extractToolName(name: string): string | null {
|
||||||
|
const idx = name.indexOf('_');
|
||||||
|
if (idx === -1) return null;
|
||||||
|
return name.slice(idx + 1);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { ChevronDown, ChevronRight, Folder, FolderTree, Menu, RotateCcw } from 'lucide-react';
|
import { ChevronDown, ChevronRight, Folder, FolderTree, Menu, RotateCcw, Terminal } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { EmptyState } from '@/components/EmptyState';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import mascot from '@/assets/brand/banner-mascot.png';
|
import mascot from '@/assets/brand/banner-mascot.png';
|
||||||
import wordmark from '@/assets/brand/banner-wordmark.png';
|
import wordmark from '@/assets/brand/banner-wordmark.png';
|
||||||
@@ -111,12 +112,12 @@ export function Home() {
|
|||||||
<div className="w-full max-w-md space-y-6">
|
<div className="w-full max-w-md space-y-6">
|
||||||
<div className="text-center space-y-3">
|
<div className="text-center space-y-3">
|
||||||
{empty ? (
|
{empty ? (
|
||||||
<>
|
<EmptyState
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">No projects yet</h1>
|
icon={<Terminal size={40} strokeWidth={1.5} />}
|
||||||
<p className="text-sm text-muted-foreground">
|
title="No projects yet"
|
||||||
Add a project from /opt or create a new one.
|
description="Create a project to get started with BooCode"
|
||||||
</p>
|
action={{ label: "Create Project", onClick: () => setCreateOpen(true) }}
|
||||||
</>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col items-center gap-4 pb-1">
|
<div className="flex flex-col items-center gap-4 pb-1">
|
||||||
|
|||||||
427
apps/web/src/pages/Memory.tsx
Normal file
427
apps/web/src/pages/Memory.tsx
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { ArrowLeft, BrainCircuit, CalendarDays, CloudMoon } from 'lucide-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { api } from '@/api/client';
|
||||||
|
import type { MemoryEntry, DailyMemoryEntry, DreamEntry } from '@/api/types';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useSidebar } from '@/hooks/useSidebar';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// ─── Independent section data fetcher (same pattern as Analytics.tsx) ────────
|
||||||
|
|
||||||
|
function useFetch<T>(fetcher: () => Promise<T>): {
|
||||||
|
data: T | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
retry: () => void;
|
||||||
|
} {
|
||||||
|
const [data, setData] = useState<T | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
fetcher()
|
||||||
|
.then(setData)
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
setError(err instanceof Error ? err.message : 'failed to load data');
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
return { data, loading, error, retry: load };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Skeleton pulse placeholder ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SkeletonBar({ className }: { className?: string }) {
|
||||||
|
return <div className={cn('animate-pulse rounded bg-muted/40', className)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Formatters ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function formatDate(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return '—';
|
||||||
|
return new Date(iso).toLocaleDateString(undefined, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateShort(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return '—';
|
||||||
|
return new Date(iso).toLocaleDateString(undefined, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(str: string, max: number): string {
|
||||||
|
if (str.length <= max) return str;
|
||||||
|
return str.slice(0, max) + '…';
|
||||||
|
}
|
||||||
|
|
||||||
|
function relTime(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return '—';
|
||||||
|
const diff = Date.now() - new Date(iso).getTime();
|
||||||
|
const seconds = Math.floor(diff / 1000);
|
||||||
|
if (seconds < 60) return `${seconds}s ago`;
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
if (days < 30) return `${days}d ago`;
|
||||||
|
return formatDate(iso);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Empty state ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function EmptyState({ message }: { message: string }) {
|
||||||
|
return <p className="text-sm text-muted-foreground py-8 text-center">{message}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tab bar (same pattern as Results.tsx) ──────────────────────────────────
|
||||||
|
|
||||||
|
type TabId = 'all' | 'daily' | 'dreams';
|
||||||
|
|
||||||
|
function TabBar({ active, onChange }: { active: TabId; onChange: (t: TabId) => void }) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1 border-b pb-px">
|
||||||
|
{[
|
||||||
|
{ id: 'all' as TabId, label: 'All Memory', icon: BrainCircuit },
|
||||||
|
{ id: 'daily' as TabId, label: 'Daily Log', icon: CalendarDays },
|
||||||
|
{ id: 'dreams' as TabId, label: 'Dreams', icon: CloudMoon },
|
||||||
|
].map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(tab.id)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-t-md border border-b-0 -mb-px transition-colors',
|
||||||
|
active === tab.id
|
||||||
|
? 'bg-background border-border text-foreground'
|
||||||
|
: 'border-transparent text-muted-foreground hover:text-foreground hover:bg-muted/30',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<tab.icon className="size-3.5" />
|
||||||
|
<span>{tab.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── All Memory Tab ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function AllMemoryTab({ projectId }: { projectId: string }) {
|
||||||
|
const { data, loading, error, retry } = useFetch(() => api.memory.list(projectId).then((r) => r.entries));
|
||||||
|
const [expanded, setExpanded] = useState<string | null>(null);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="pt-4 space-y-3">
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<Card key={i} size="sm">
|
||||||
|
<CardContent className="pt-4 space-y-2">
|
||||||
|
<SkeletonBar className="h-4 w-16" />
|
||||||
|
<SkeletonBar className="h-5 w-3/4" />
|
||||||
|
<SkeletonBar className="h-3 w-full" />
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<SkeletonBar className="h-4 w-12" />
|
||||||
|
<SkeletonBar className="h-4 w-16" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 text-sm pt-4">
|
||||||
|
<span className="text-destructive">{error}</span>
|
||||||
|
<Button size="sm" variant="outline" onClick={retry}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return <EmptyState message="No topic-based memory entries yet." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pt-4 space-y-3">
|
||||||
|
{data.map((entry: MemoryEntry) => (
|
||||||
|
<Card key={entry.id}>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded(expanded === entry.id ? null : entry.id)}
|
||||||
|
className="w-full text-left"
|
||||||
|
>
|
||||||
|
{/* Topic badge */}
|
||||||
|
<span className="inline-block text-[10px] uppercase tracking-wider font-medium text-muted-foreground bg-muted/50 px-1.5 py-0.5 rounded mb-1.5">
|
||||||
|
{entry.topic}
|
||||||
|
</span>
|
||||||
|
<h3 className="text-sm font-medium">{entry.title}</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||||
|
{truncate(entry.content, 200)}
|
||||||
|
</p>
|
||||||
|
{entry.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
|
{entry.tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="text-[10px] bg-secondary/50 text-secondary-foreground px-1.5 py-0.5 rounded"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{expanded === entry.id && (
|
||||||
|
<div className="mt-3 pt-3 border-t text-xs text-foreground/80 leading-relaxed whitespace-pre-wrap">
|
||||||
|
{entry.content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Daily Log Tab ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function DailyLogTab({ projectId }: { projectId: string }) {
|
||||||
|
const { data, loading, error, retry } = useFetch(() => api.memory.daily(projectId).then((r) => r.entries));
|
||||||
|
const [expanded, setExpanded] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
if (!data) return [];
|
||||||
|
const groups: Record<string, DailyMemoryEntry[]> = {};
|
||||||
|
for (const entry of data) {
|
||||||
|
const g = groups[entry.date];
|
||||||
|
if (g) {
|
||||||
|
g.push(entry);
|
||||||
|
} else {
|
||||||
|
groups[entry.date] = [entry];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.entries(groups).sort((a, b) => b[0].localeCompare(a[0]));
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="pt-4 space-y-4">
|
||||||
|
{[0, 1].map((day) => (
|
||||||
|
<div key={day}>
|
||||||
|
<SkeletonBar className="h-4 w-24 mb-2" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[0, 1].map((e) => (
|
||||||
|
<Card key={e} size="sm">
|
||||||
|
<CardContent className="pt-3 space-y-1">
|
||||||
|
<SkeletonBar className="h-3 w-20" />
|
||||||
|
<SkeletonBar className="h-3 w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 text-sm pt-4">
|
||||||
|
<span className="text-destructive">{error}</span>
|
||||||
|
<Button size="sm" variant="outline" onClick={retry}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return <EmptyState message="No daily log entries for the last 7 days." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pt-4 space-y-4">
|
||||||
|
{grouped.map(([date, entries]) => (
|
||||||
|
<div key={date}>
|
||||||
|
<h3 className="text-sm font-medium text-muted-foreground mb-2">
|
||||||
|
{formatDateShort(date)}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{entries.map((entry: DailyMemoryEntry) => (
|
||||||
|
<Card key={entry.id} size="sm">
|
||||||
|
<CardContent className="pt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded(expanded === entry.id ? null : entry.id)}
|
||||||
|
className="w-full text-left"
|
||||||
|
>
|
||||||
|
<span className="text-xs font-mono text-muted-foreground">
|
||||||
|
{entry.title}
|
||||||
|
</span>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 line-clamp-1">
|
||||||
|
{truncate(entry.content, 150)}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
{expanded === entry.id && (
|
||||||
|
<div className="mt-2 pt-2 border-t text-xs text-foreground/80 leading-relaxed whitespace-pre-wrap">
|
||||||
|
{entry.content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Dreams Tab ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function DreamsTab({ projectId }: { projectId: string }) {
|
||||||
|
const { data, loading, error, retry } = useFetch(() => api.memory.dreams(projectId).then((r) => r.entries));
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="pt-4 space-y-3">
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<Card key={i}>
|
||||||
|
<CardContent className="pt-4 space-y-2">
|
||||||
|
<SkeletonBar className="h-4 w-32" />
|
||||||
|
<SkeletonBar className="h-3 w-full" />
|
||||||
|
<SkeletonBar className="h-3 w-5/6" />
|
||||||
|
<SkeletonBar className="h-3 w-2/3" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 text-sm pt-4">
|
||||||
|
<span className="text-destructive">{error}</span>
|
||||||
|
<Button size="sm" variant="outline" onClick={retry}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return <EmptyState message="No dream consolidation diaries yet." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pt-4 space-y-3">
|
||||||
|
{data.map((entry: DreamEntry, i: number) => (
|
||||||
|
<Card key={i}>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<h3 className="text-sm font-medium mb-2">{formatDateShort(entry.date)}</h3>
|
||||||
|
<pre className="text-xs font-mono leading-relaxed whitespace-pre-wrap bg-muted/20 p-3 rounded-md border border-border/50 overflow-x-auto">
|
||||||
|
{entry.content}
|
||||||
|
</pre>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main Page ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function Memory() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data: sidebar, activeSession } = useSidebar();
|
||||||
|
|
||||||
|
const [tab, setTab] = useState<TabId>('all');
|
||||||
|
const [projectId, setProjectId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Derive default project from active session or first project.
|
||||||
|
const projects = useMemo(() => {
|
||||||
|
return sidebar?.projects?.map((p: { id: string; name: string }) => p) ?? [];
|
||||||
|
}, [sidebar]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!projectId && projects.length > 0) {
|
||||||
|
// Prefer active session's project, else first project.
|
||||||
|
const defaultId = activeSession?.project_id ?? projects[0]!.id;
|
||||||
|
setProjectId(defaultId);
|
||||||
|
}
|
||||||
|
}, [projects, activeSession, projectId]);
|
||||||
|
|
||||||
|
function handleBack() {
|
||||||
|
if (window.history.length > 1) {
|
||||||
|
navigate(-1);
|
||||||
|
} else {
|
||||||
|
navigate('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<div className="max-w-[1000px] mx-auto w-full px-6 py-6 space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="space-y-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleBack}
|
||||||
|
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground -ml-1 px-1 py-0.5 rounded"
|
||||||
|
aria-label="Back"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
<span>Back</span>
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold flex items-center gap-2">
|
||||||
|
<BrainCircuit className="size-5" />
|
||||||
|
Memory Browser
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Topic-based memories, daily logs, and dream consolidation diaries.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Tab bar */}
|
||||||
|
<TabBar active={tab} onChange={setTab} />
|
||||||
|
|
||||||
|
{/* Tab content */}
|
||||||
|
{!projectId ? (
|
||||||
|
<EmptyState message="Select a project to view memory." />
|
||||||
|
) : tab === 'all' ? (
|
||||||
|
<AllMemoryTab projectId={projectId} />
|
||||||
|
) : tab === 'daily' ? (
|
||||||
|
<DailyLogTab projectId={projectId} />
|
||||||
|
) : (
|
||||||
|
<DreamsTab projectId={projectId} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||||
import { Plus, MessageSquare, Trash2, ChevronDown, ChevronRight, RotateCcw, Menu, Code } from 'lucide-react';
|
import { Inbox, Plus, MessageSquare, Trash2, ChevronDown, ChevronRight, RotateCcw, Menu, Code } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import type { Project as ProjectType, Session } from '@/api/types';
|
import type { Project as ProjectType, Session } from '@/api/types';
|
||||||
|
import { EmptyState } from '@/components/EmptyState';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
import { useSessions } from '@/hooks/useSessions';
|
import { useSessions } from '@/hooks/useSessions';
|
||||||
@@ -116,9 +117,11 @@ export function Project() {
|
|||||||
<div className="text-sm text-muted-foreground">Loading…</div>
|
<div className="text-sm text-muted-foreground">Loading…</div>
|
||||||
)}
|
)}
|
||||||
{sessions && sessions.length === 0 && (
|
{sessions && sessions.length === 0 && (
|
||||||
<div className="text-sm text-muted-foreground">
|
<EmptyState
|
||||||
No sessions yet. Click <span className="font-medium">New session</span> to start.
|
icon={<Inbox size={40} strokeWidth={1.5} />}
|
||||||
</div>
|
title="No sessions"
|
||||||
|
description="Start a new chat session to begin working"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{sessions && sessions.length > 0 && (
|
{sessions && sessions.length > 0 && (
|
||||||
<ul className="divide-y rounded-md border">
|
<ul className="divide-y rounded-md border">
|
||||||
|
|||||||
17
apps/web/vitest.config.ts
Normal file
17
apps/web/vitest.config.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: false,
|
||||||
|
include: ['src/**/*.test.{ts,tsx}'],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -689,6 +689,7 @@ Full per-tag detail in the **Shipped (v2.2.2–v2.6.6)** section above and in `C
|
|||||||
- **Interactive ACP** (`v2.2.2`–`v2.3.2`) ✅ — placeholder-XML reject; per-agent sampling params; `ask_user_input` cards in both BooCoder frontends; enriched `permission_requested` frame (question/plan/elicitation) with interactive PermissionCard; coder `answer_user_input` endpoint fix.
|
- **Interactive ACP** (`v2.2.2`–`v2.3.2`) ✅ — placeholder-XML reject; per-agent sampling params; `ask_user_input` cards in both BooCoder frontends; enriched `permission_requested` frame (question/plan/elicitation) with interactive PermissionCard; coder `answer_user_input` endpoint fix.
|
||||||
- **Unsloth lift + sidecar + task model** (`v2.4.0`–`v2.5.1`) ✅ — Unsloth Studio `tool-call-parser.ts` (replaces `xml-parser.ts`) + parse5 `html-to-md.ts` + `llama-args-validator.ts` (**all three AGPL-3.0-only — this is what relicensed the whole tree to AGPL-3.0**); llama-sidecar per-agent-flags routing; dedicated task-model services; tool budgets → 100/100/100. **→ removal tracked in the License-debt → relicense to MIT batch.**
|
- **Unsloth lift + sidecar + task model** (`v2.4.0`–`v2.5.1`) ✅ — Unsloth Studio `tool-call-parser.ts` (replaces `xml-parser.ts`) + parse5 `html-to-md.ts` + `llama-args-validator.ts` (**all three AGPL-3.0-only — this is what relicensed the whole tree to AGPL-3.0**); llama-sidecar per-agent-flags routing; dedicated task-model services; tool budgets → 100/100/100. **→ removal tracked in the License-debt → relicense to MIT batch.**
|
||||||
- **Provider lifecycle = the planned "v2.3"** (`v2.5.3`–`v2.5.15`) ✅ — cursor/copilot retired; config-backed registry + snapshot lifecycle + two-tier probe (phases 1–5); empty-picker fix; claude model list; mobile composer; per-agent + claude/opencode slash-command discovery; ACP path-guard security fix.
|
- **Provider lifecycle = the planned "v2.3"** (`v2.5.3`–`v2.5.15`) ✅ — cursor/copilot retired; config-backed registry + snapshot lifecycle + two-tier probe (phases 1–5); empty-picker fix; claude model list; mobile composer; per-agent + claude/opencode slash-command discovery; ACP path-guard security fix.
|
||||||
|
- **Paseo-like Orchestrator** (`v2.8.18`–`v2.8.20`) ✅ — DeepSeek integration + Whale lifts (v2.8.18), trace system + session persistence (v2.8.19), workflow engine + background subagents + multi-modal/cache shape (v2.8.20).
|
||||||
- **v2.6 persistent agent sessions** (`v2.6.0`–`v2.6.4`) ✅ Phase 0/1 + P1.5-a/b — foundations scaffold; opencode warm HTTP server with per-chat resumable sessions; session-delete work-loss guard; per-session SSE; `(chat_id, agent)` re-key + `worktrees` table; FK convergence.
|
- **v2.6 persistent agent sessions** (`v2.6.0`–`v2.6.4`) ✅ Phase 0/1 + P1.5-a/b — foundations scaffold; opencode warm HTTP server with per-chat resumable sessions; session-delete work-loss guard; per-session SSE; `(chat_id, agent)` re-key + `worktrees` table; FK convergence.
|
||||||
- **Workspace UX + composer** (`v2.6.5`–`v2.6.6`) ✅ — BooChat panes/tabs overhaul (open-in-new-pane, `[+]` New BooChat/BooTerm/BooCode menu, tab relocation, stable tab numbers, session-history landing pane); `workspace_panes` → `WorkspaceState` envelope; morphing Send→Stop→Queue composer + `cancelTask`; `read_tab_by_number` tool + `ToolExecCtx`; CLAUDE.md doc-sync. (This is the work the earlier draft listed as "uncommitted frontend UX" — now shipped.)
|
- **Workspace UX + composer** (`v2.6.5`–`v2.6.6`) ✅ — BooChat panes/tabs overhaul (open-in-new-pane, `[+]` New BooChat/BooTerm/BooCode menu, tab relocation, stable tab numbers, session-history landing pane); `workspace_panes` → `WorkspaceState` envelope; morphing Send→Stop→Queue composer + `cancelTask`; `read_tab_by_number` tool + `ToolExecCtx`; CLAUDE.md doc-sync. (This is the work the earlier draft listed as "uncommitted frontend UX" — now shipped.)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
# Agents
|
# Agents — v2.7.x (last meaningful update: 2026-06 — Supervisor agent added)
|
||||||
|
|
||||||
|
## Parser contract — this file is PARSED programmatically, do not add free-form sections
|
||||||
|
- Each agent is a `## Name` block followed by `---` YAML frontmatter. Content before the first `##` is discarded. Free-form `##` sections (not followed by `---`) break `agents.ts:splitSections` — the parser throws and no agents load. Cross-cutting agent rules belong in CLAUDE.md, not here.
|
||||||
|
- If an agent stops appearing in the AgentPicker, check: (1) frontmatter `---` fence is present after the `## Name` heading, (2) no YAML parse errors in the frontmatter fields, (3) the name is not empty/hyphenated in a way the parser doesn't handle.
|
||||||
|
|
||||||
Operating rules for every agent in this registry. Full procedures live in the `committing-changes` and `using-worktrees` skills.
|
Operating rules for every agent in this registry. Full procedures live in the `committing-changes` and `using-worktrees` skills.
|
||||||
|
|
||||||
@@ -10,6 +14,15 @@ Operating rules for every agent in this registry. Full procedures live in the `c
|
|||||||
|
|
||||||
**Reasoning budget** — To cap a reasoning model's thinking tokens, pass `--reasoning-budget` through `llama_extra_args` (already permitted by the deny-list validator; routes the agent to llama-sidecar). Example frontmatter line: `llama_extra_args: ["--reasoning-budget", "2048"]`. This is a sidecar process flag, not a chat-completion body param — distinct from the sampling knobs above.
|
**Reasoning budget** — To cap a reasoning model's thinking tokens, pass `--reasoning-budget` through `llama_extra_args` (already permitted by the deny-list validator; routes the agent to llama-sidecar). Example frontmatter line: `llama_extra_args: ["--reasoning-budget", "2048"]`. This is a sidecar process flag, not a chat-completion body param — distinct from the sampling knobs above.
|
||||||
|
|
||||||
|
## Tool list drift guard
|
||||||
|
Every agent's `tools:` list MUST stay in sync with `ALL_TOOLS` in `apps/server/src/services/tools/registry.ts`. Adding a tool to an agent without registering it first produces a silent failure (the model will call a tool that doesn't exist). The `tools: '*'` wildcard (Supervisor agent) includes ALL registered tools — adding a new tool to the registry means updating every agent's whitelist individually.
|
||||||
|
|
||||||
|
## Failure modes (applies to all agents)
|
||||||
|
- Tools can return empty results. Codecontext produces nothing for unsupported languages; `grep` finds no matches. This is not a system failure — fall back to a different tool.
|
||||||
|
- `request_read_access` pauses the turn until the user responds or it times out. If it returns "denied", do not retry — use a different approach.
|
||||||
|
- `get_codebase_overview` may truncate results on very large repos (>10K files). Cross-check with `get_hot_files` and `list_dir`.
|
||||||
|
- Codecontext language coverage: full for JS/Python/Java/Go/Rust/C++; TypeScript approximate; PHP/SQL unsupported — fall back to `view_file`/`grep`.
|
||||||
|
|
||||||
## Code Reviewer
|
## Code Reviewer
|
||||||
---
|
---
|
||||||
temperature: 0.6
|
temperature: 0.6
|
||||||
@@ -332,3 +345,12 @@ Rules:
|
|||||||
- If a change breaks an import or type, fix it in the same batch before applying.
|
- If a change breaks an import or type, fix it in the same batch before applying.
|
||||||
- Use rewind if a batch of changes is wrong. Do not apply broken changes.
|
- Use rewind if a batch of changes is wrong. Do not apply broken changes.
|
||||||
- When done, state what changed and what the user should verify (type check, test, manual check)
|
- When done, state what changed and what the user should verify (type check, test, manual check)
|
||||||
|
|
||||||
|
## Supervisor
|
||||||
|
---
|
||||||
|
description: Dynamically routes requests to the best agent for the task
|
||||||
|
tools: '*'
|
||||||
|
temperature: 0.1
|
||||||
|
steps: 0
|
||||||
|
---
|
||||||
|
Your job is to classify the user's request and determine which specialized agent should handle it. Available agents are provided in the system prompt. Choose the best fit.
|
||||||
|
|||||||
1063
docs/boocode-lift-analysis.md
Normal file
1063
docs/boocode-lift-analysis.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-06-08
|
||||||
72
openspec/changes/domain2-code-intelligence/proposal.md
Normal file
72
openspec/changes/domain2-code-intelligence/proposal.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# Domain 2 — Codebase Context & Code Intelligence
|
||||||
|
|
||||||
|
**Status:** Proposed
|
||||||
|
**Epic:** domain2-code-intelligence
|
||||||
|
**Depends on:** v2.8.8-type-inject-mcp, v2.8.12-hashline-audit-hooks
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
BooCode has two parallel code intelligence systems — and neither is fully wired:
|
||||||
|
|
||||||
|
1. Old Go codecontext sidecar (http://codecontext:8080) — 16 tool wrappers in apps/server/src/services/tools/codecontext/ — called via codecontext_client.ts HTTP client. Limited to codesight-level analysis (routes, schema, middleware, hot files). No type recovery, no health grades, no impact analysis.
|
||||||
|
|
||||||
|
2. New boocontext MCP server (registered in data/mcp.json as node /opt/forks/boocontext/dist/standalone.js) — 7 aggregated tools (overview, map, health, symbols, callgraph, impact, types) backed by child MCP servers. Already running but only available to agents through MCP discovery as boocontext_* tools — not integrated as first-class BooCode agent tools.
|
||||||
|
|
||||||
|
The lift analysis (boocode-lift-analysis.md) rates the boocontext swap as Tier 1 / Critical: 7 tools instead of 5, session caching, call graph, health grades, impact analysis, TS type recovery. Written in TS (BooCode's stack). Fixes 0% TS type recovery.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
### Phase 1: Go sidecar → boocontext migration (high confidence)
|
||||||
|
|
||||||
|
1. Update codecontext_client.ts to optionally route through boocontext MCP instead of the Go HTTP sidecar. Add a CODECONTEXT_MCP env toggle. When boocontext MCP is active, call the MCP tool directly instead of HTTP POST to http://codecontext:8080/v1/{toolName}.
|
||||||
|
|
||||||
|
2. Add 4 new first-class tool wrappers for boocontext-only capabilities:
|
||||||
|
- get_code_health — wraps boocontext_health (A-F grades per file, 7 dimensions)
|
||||||
|
- get_code_impact — wraps boocontext_impact (symbol trace + blast radius merged)
|
||||||
|
- get_type_info — wraps boocontext_types (TypeScript type signatures, cross-file resolution)
|
||||||
|
- get_code_map — wraps boocontext_map (compressible context map with DCP)
|
||||||
|
|
||||||
|
3. Register new tools in ALL_TOOLS in tools/registry.ts. Add to agent whitelists in data/AGENTS.md.
|
||||||
|
|
||||||
|
### Phase 2: Child MCP server hardening (high confidence)
|
||||||
|
|
||||||
|
4. Switch type-inject child path from hardcoded /opt/forks/type-inject/packages/mcp/dist/index.js to npx @nick-vi/type-inject-mcp. Set BOOCONTEXT_TYPE_INJECT_NPX=@nick-vi/type-inject-mcp env var on the boocontext MCP process.
|
||||||
|
|
||||||
|
5. Add graceful degradation on child server failure. If tree-sitter-analyzer or type-inject is unavailable, fall back to codesight-only mode instead of failing hard.
|
||||||
|
|
||||||
|
### Phase 3: Wiki mode + scanning (medium confidence)
|
||||||
|
|
||||||
|
6. Add get_wiki_article tool — wraps codesight --wiki output. Generates targeted persistent articles per project, cached on disk.
|
||||||
|
|
||||||
|
7. Add token-efficient scanning — adopt codesight's scanner pattern for get_codebase_overview. Uses DCP compression for large payloads.
|
||||||
|
|
||||||
|
### Phase 4: Teardown (future)
|
||||||
|
|
||||||
|
8. Deprecate Go sidecar — once all tool wrappers verify against boocontext MCP, mark the Go codecontext sidecar as deprecated. Remove from docker-compose.yml and codecontext/Dockerfile when no remaining consumers.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
- No changes to the boocontext fork itself (consumed as-is via dist/standalone.js)
|
||||||
|
- No removal of the Go sidecar in this batch (parallel running OK)
|
||||||
|
- No changes to boocontext's child-server architecture
|
||||||
|
- No new framework detectors (boocontext already has 20+ language extractors)
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- get_code_health — A-F code health grades per file, project health summary, hotspot identification, refactoring candidate ranking
|
||||||
|
- get_code_impact — Symbol-level trace merged with file-level blast radius. Single call replaces two-step get_symbol_info → get_blast_radius
|
||||||
|
- get_type_info — TypeScript type recovery: type signatures, interface definitions, generic constraints, cross-file type resolution
|
||||||
|
- get_code_map — DCP-compressed context map with compress toggle
|
||||||
|
- get_wiki_article — Persistent codebase wiki article by name, cached on disk
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
- Existing 12 codecontext tools transparently upgraded to boocontext backend (same agent interface, better data)
|
||||||
|
- codecontext_client.ts — gains MCP routing layer with Go sidecar fallback
|
||||||
|
|
||||||
|
## Metrics
|
||||||
|
- Go sidecar HTTP calls → 0 (all migrated to boocontext MCP)
|
||||||
|
- New tool surface area: 16 → 20 first-class tools
|
||||||
|
- Agent-visible tools: 16 unchanged names + 4 new names
|
||||||
|
- TypeScript type recovery: 0% → full TS-visible types on file context
|
||||||
|
- Code health awareness: 0 tools → 1 tool
|
||||||
|
- Impact analysis: 2-step → 1-step
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user