Files
boocode/apps/web/src/api/client.ts
indifferentketchup 23a33e893a web+coder: segmented per-agent slash menu (agent commands + skills) + cross-agent skill execution
Coder / menu now shows two groups: the active agent's commands first (manifest + live ACP available_commands), BooCoder skills second. SlashCommandPicker gains an opt-in groups prop (flat items path unchanged -> BooChat byte-identical, parity verified); ChatInput takes slashGroups; CoderPane builds the groups. Skills run under the selected agent: coder skill_invoke accepts a provider and, when external, injects the server-side skill body into a dispatched task instead of native inference. Also folds in the initial-chat skill fix (handleLandingSkill: create chat -> assign to pane -> invoke, same transition as a text send) that resolves the landing-page blank screen. BooChat slash menu + skill invocation unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 14:38:39 +00:00

439 lines
17 KiB
TypeScript

import type {
Project,
AvailableProject,
Session,
Chat,
Message,
ModelInfo,
SidebarResponse,
ListDirResult,
ViewFileResult,
AgentsResponse,
GitMeta,
Skill,
AskUserAnswer,
ToolCostStat,
ProviderSnapshotEntry,
CoderSendMessageBody,
CoderSendMessageResponse,
CoderMessageWire,
CoderTaskDetail,
PermissionPrompt,
AgentCommand,
} from './types';
export class ApiError extends Error {
constructor(
public status: number,
public body: unknown
) {
super(typeof body === 'object' && body && 'error' in body ? String((body as { error: unknown }).error) : `HTTP ${status}`);
}
}
async function request<T>(
path: string,
init: RequestInit = {}
): Promise<T> {
const res = await fetch(path, {
...init,
headers: {
'Content-Type': 'application/json',
...(init.headers ?? {}),
},
});
if (res.status === 204) return undefined as T;
const text = await res.text();
const data = text ? JSON.parse(text) : undefined;
if (!res.ok) throw new ApiError(res.status, data);
return data as T;
}
export const api = {
health: () => request<{ status: string; db: boolean }>('/api/health'),
projects: {
list: (params?: { status?: 'open' | 'archived' }) =>
request<Project[]>(`/api/projects${params?.status ? `?status=${params.status}` : ''}`),
available: () => request<AvailableProject[]>('/api/projects/available'),
add: (body: { path: string; name?: string }) =>
request<Project>('/api/projects', {
method: 'POST',
body: JSON.stringify(body),
}),
update: (
id: string,
body: Partial<Pick<Project, 'name' | 'default_system_prompt' | 'default_web_search_enabled'>>,
) =>
request<Project>(`/api/projects/${id}`, {
method: 'PATCH',
body: JSON.stringify(body),
}),
get: (id: string) => request<Project>(`/api/projects/${id}`),
archive: (id: string) =>
request<void>(`/api/projects/${id}/archive`, { method: 'POST' }),
unarchive: (id: string) =>
request<Project>(`/api/projects/${id}/unarchive`, { method: 'POST' }),
// v1.9: bulk-archive every open session in this project. Server publishes
// one session_archived frame per affected id, so the sidebar reducer
// updates incrementally rather than waiting for a refetch.
archiveAllSessions: (id: string) =>
request<{ archived: number; ids: string[] }>(
`/api/projects/${id}/sessions/archive-all`,
{ method: 'POST' },
),
openSessionsCount: (id: string) =>
request<{ count: number }>(`/api/projects/${id}/sessions/open-count`),
create: (body: {
name: string;
commit_message?: string;
visibility?: 'private' | 'public';
create_gitea_remote?: boolean;
}) =>
request<{
project: Project;
bootstrap: {
folder_created: boolean;
git_initialized: boolean;
first_commit: boolean;
gitea_remote_created: boolean;
gitea_pushed: boolean;
warnings: string[];
};
}>(`/api/projects/create`, {
method: 'POST',
body: JSON.stringify(body),
}),
remove: (id: string) =>
request<void>(`/api/projects/${id}`, { method: 'DELETE' }),
listDir: (id: string, path: string) =>
request<ListDirResult>(`/api/projects/${id}/list_dir?path=${encodeURIComponent(path)}`),
viewFile: (id: string, path: string) =>
request<ViewFileResult>(`/api/projects/${id}/view_file?path=${encodeURIComponent(path)}`),
files: (id: string) =>
request<{ files: string[] }>(`/api/projects/${id}/files`),
git: (id: string) =>
request<GitMeta>(`/api/projects/${id}/git`),
},
sessions: {
listForProject: (projectId: string, status?: 'open' | 'archived') =>
request<Session[]>(`/api/projects/${projectId}/sessions${status ? `?status=${status}` : ''}`),
create: (
projectId: string,
body: { name?: string; model?: string; system_prompt?: string; agent_id?: string | null }
) =>
request<Session>(`/api/projects/${projectId}/sessions`, {
method: 'POST',
body: JSON.stringify(body),
}),
get: (id: string) => request<Session>(`/api/sessions/${id}`),
update: (
id: string,
body: Partial<
Pick<
Session,
| 'name'
| 'model'
| 'system_prompt'
| 'agent_id'
| 'web_search_enabled'
// v1.13.17: revocation path — frontend sends the shortened list
// when the user removes a grant. Grants are appended only via the
// separate grantReadAccess endpoint below.
| 'allowed_read_paths'
>
>
) =>
request<Session>(`/api/sessions/${id}`, {
method: 'PATCH',
body: JSON.stringify(body),
}),
remove: (id: string) =>
request<void>(`/api/sessions/${id}`, { method: 'DELETE' }),
archive: (id: string) =>
request<void>(`/api/sessions/${id}/archive`, { method: 'POST' }),
unarchive: (id: string) =>
request<Session>(`/api/sessions/${id}/unarchive`, { method: 'POST' }),
// v1.9: bulk-archive every open chat in this session. Same pattern as
// archiveAllSessions — server publishes one chat_archived per id.
archiveAllChats: (id: string) =>
request<{ archived: number; ids: string[] }>(
`/api/sessions/${id}/chats/archive-all`,
{ method: 'POST' },
),
openChatsCount: (id: string) =>
request<{ count: number }>(`/api/sessions/${id}/chats/open-count`),
updateWorkspacePanes: (id: string, panes: Session['workspace_panes']) =>
request<Session>(`/api/sessions/${id}/workspace`, {
method: 'PATCH',
body: JSON.stringify({ workspace_panes: panes }),
}),
},
chats: {
listForSession: (sessionId: string, params?: { status?: 'open' | 'archived' }) =>
request<Chat[]>(
`/api/sessions/${sessionId}/chats${params?.status ? `?status=${params.status}` : ''}`
),
create: (sessionId: string, body?: { name?: string }) =>
request<Chat>(`/api/sessions/${sessionId}/chats`, {
method: 'POST',
body: JSON.stringify(body ?? {}),
}),
update: (chatId: string, body: { name: string }) =>
request<Chat>(`/api/chats/${chatId}`, {
method: 'PATCH',
body: JSON.stringify(body),
}),
archive: (chatId: string) =>
request<void>(`/api/chats/${chatId}/archive`, { method: 'POST' }),
unarchive: (chatId: string) =>
request<Chat>(`/api/chats/${chatId}/unarchive`, { method: 'POST' }),
remove: (chatId: string) =>
request<void>(`/api/chats/${chatId}`, { method: 'DELETE' }),
messages: (chatId: string) =>
request<Message[]>(`/api/chats/${chatId}/messages`),
// v1.11: anchored-rolling compaction. POST awaits the LLM call inside
// the route's lifecycle; the new summary row arrives via the 'compacted'
// WS frame (useSessionStream refetches + toasts).
compact: (chatId: string) =>
request<{ ok: true }>(`/api/chats/${chatId}/compact`, { method: 'POST' }),
stop: (chatId: string) =>
request<{ stopped: boolean }>(`/api/chats/${chatId}/stop`, { method: 'POST' }),
discardStale: (chatId: string, messageId: string) =>
request<Message>(`/api/chats/${chatId}/discard_stale`, {
method: 'POST',
body: JSON.stringify({ message_id: messageId }),
}),
forceSend: (chatId: string, content: string) =>
request<{ user_message_id: string; assistant_message_id: string }>(
`/api/chats/${chatId}/force_send`,
{ method: 'POST', body: JSON.stringify({ content }) }
),
// v1.8.2: extend an inference that hit the tool budget. `sentinelMessageId`
// is the cap-hit sentinel message the user clicked Continue on.
continue: (chatId: string, sentinelMessageId: string) =>
request<{ assistant_message_id: string }>(
`/api/chats/${chatId}/continue`,
{ method: 'POST', body: JSON.stringify({ sentinel_message_id: sentinelMessageId }) }
),
fork: (chatId: string, body: { messageId: string; name?: string }) =>
request<Chat>(`/api/chats/${chatId}/fork`, {
method: 'POST',
body: JSON.stringify({ message_id: body.messageId, name: body.name }),
}),
// Batch 9.6: slash-command invocation. Server loads the skill body
// authoritatively (client doesn't get to forge file contents), persists
// a synthetic skill_use tool_use + tool_result + user message + streaming
// assistant, and enqueues inference. Returns all 4 new message IDs.
skillInvoke: (chatId: string, skillName: string, userMessage: string | null) =>
request<{
synth_assistant_id: string;
tool_message_id: string;
user_message_id: string;
assistant_message_id: string;
}>(`/api/chats/${chatId}/skill_invoke`, {
method: 'POST',
body: JSON.stringify({ skill_name: skillName, user_message: userMessage }),
}),
// Batch 9.7: submit answers for a paused ask_user_input call. Server
// validates against the question shape, UPDATEs the pending tool row,
// publishes the deferred tool_result frame, and enqueues the next turn.
answerUserInput: (chatId: string, toolCallId: string, answers: AskUserAnswer[]) =>
request<{ tool_message_id: string; assistant_message_id: string }>(
`/api/chats/${chatId}/answer_user_input`,
{
method: 'POST',
body: JSON.stringify({ tool_call_id: toolCallId, answers }),
},
),
// 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
// sessions.allowed_read_paths; the returned list reflects the post-
// grant state. On 'deny' the array is unchanged.
grantReadAccess: (chatId: string, toolCallId: string, decision: 'allow' | 'deny') =>
request<{
tool_message_id: string;
assistant_message_id: string;
allowed_read_paths: string[];
}>(`/api/chats/${chatId}/grant_read_access`, {
method: 'POST',
body: JSON.stringify({ tool_call_id: toolCallId, decision }),
}),
},
messages: {
list: (sessionId: string) =>
request<Message[]>(`/api/sessions/${sessionId}/messages`),
send: (chatId: string, content: string) =>
request<{ user_message_id: string; assistant_message_id: string }>(
`/api/chats/${chatId}/messages`,
{
method: 'POST',
body: JSON.stringify({ content }),
}
),
regenerate: (chatId: string, messageId: string) =>
request<{ assistant_message_id: string }>(
`/api/chats/${chatId}/messages/${messageId}/regenerate`,
{ method: 'POST' }
),
remove: (chatId: string, messageId: string) =>
request<void>(`/api/chats/${chatId}/messages/${messageId}`, {
method: 'DELETE',
}),
// v1.14.x-html-artifact-panes: write the artifact to
// <projectRoot>/.boocode/artifacts/<slug>-<ts>.<ext> and return the
// path + a /api/projects/.../artifacts/<filename> URL the browser can
// GET to download. fmt=html requires the assistant message to carry an
// html_artifact part (404 otherwise).
downloadArtifact: (chatId: string, messageId: string, fmt: 'md' | 'html') =>
request<{ path: string; url: string }>(
`/api/chats/${chatId}/messages/${messageId}/artifacts/download?fmt=${fmt}`,
{ method: 'POST' },
),
// v1.14.x-html-artifact-panes: fetch the html_artifact part payload so
// HtmlArtifactPane can render the iframe srcdoc. 404 = no html_artifact
// part on this message; MessageBubble uses that as a signal to fall back
// to the markdown pane variant.
getHtmlArtifact: (chatId: string, messageId: string) =>
request<{ html_content: string; char_count: number; title: string }>(
`/api/chats/${chatId}/messages/${messageId}/html_artifact`,
),
},
models: () => request<ModelInfo[]>('/api/models'),
coder: {
snapshot: (cwd?: string) => {
const qs = cwd ? `?cwd=${encodeURIComponent(cwd)}` : '';
return request<ProviderSnapshotEntry[]>(`/api/coder/providers/snapshot${qs}`);
},
refreshProviders: () =>
request<{ refreshed: number }>('/api/coder/providers/refresh', { method: 'POST' }),
sendMessage: (sessionId: string, body: CoderSendMessageBody) =>
request<CoderSendMessageResponse>(`/api/coder/sessions/${sessionId}/messages`, {
method: 'POST',
body: JSON.stringify(body),
}),
getTaskPermission: (taskId: string) =>
request<PermissionPrompt>(`/api/coder/tasks/${taskId}/permission`),
respondTaskPermission: (taskId: string, optionId: string | null, updatedInput?: Record<string, unknown>) =>
request<{ ok: boolean }>(`/api/coder/tasks/${taskId}/permission`, {
method: 'POST',
body: JSON.stringify({ option_id: optionId, ...(updatedInput ? { updated_input: updatedInput } : {}) }),
}),
getTaskCommands: (taskId: string) =>
request<{ taskId: string; commands: AgentCommand[] }>(`/api/coder/tasks/${taskId}/commands`),
getTask: (taskId: string) =>
request<CoderTaskDetail>(`/api/coder/tasks/${taskId}`),
listMessages: (sessionId: string, chatId?: string) =>
request<CoderMessageWire[]>(
`/api/coder/sessions/${sessionId}/messages${chatId ? `?chat_id=${encodeURIComponent(chatId)}` : ''}`,
),
skillInvoke: (
sessionId: string,
paneId: string,
skillName: string,
userMessage: string | null,
// v2.5.9: when the active provider is external, the skill runs under that
// agent (body injected into a dispatched task) → response carries task_id.
config?: { provider?: string; model?: string; mode_id?: string; thinking_option_id?: string },
) =>
request<{
user_message_id: string;
assistant_message_id?: string;
synth_assistant_id?: string;
tool_message_id?: string;
task_id?: string;
dispatched?: boolean;
}>(`/api/coder/sessions/${sessionId}/skill_invoke`, {
method: 'POST',
body: JSON.stringify({
pane_id: paneId,
skill_name: skillName,
user_message: userMessage,
...(config?.provider ? { provider: config.provider } : {}),
...(config?.model ? { model: config.model } : {}),
...(config?.mode_id ? { mode_id: config.mode_id } : {}),
...(config?.thinking_option_id ? { thinking_option_id: config.thinking_option_id } : {}),
}),
}),
// Queue a new-file create from the RightRail browser → BooCoder
// pending_changes (operation='create'). Surfaces in the CoderPane DiffPanel
// for explicit apply. A WriteGuardError comes back as a 422 whose { error }
// body ApiError exposes as .message for inline display.
createPendingFile: (sessionId: string, file_path: string, content: string) =>
request<{
id: string;
session_id: string;
task_id: string | null;
file_path: string;
operation: string;
status: string;
created_at: string;
}>(`/api/coder/sessions/${sessionId}/pending/create`, {
method: 'POST',
body: JSON.stringify({ file_path, content }),
}),
},
agents: {
list: (projectId: string) =>
request<AgentsResponse>(`/api/projects/${projectId}/agents`),
},
skills: {
list: () => request<{ skills: Skill[] }>('/api/skills'),
},
// v1.13.10: per-tool cost rolling-window stats (last 100 calls per tool,
// equal-split attribution across multi-tool turns). Read endpoint backed by
// the tool_cost_stats view. AgentPicker consumes this for per-agent cost
// hints.
tools: {
costStats: () => request<{ stats: ToolCostStat[] }>('/api/tools/cost_stats'),
},
settings: {
get: () => request<Record<string, unknown>>('/api/settings'),
patch: (body: Record<string, unknown>) =>
request<Record<string, unknown>>('/api/settings', {
method: 'PATCH',
body: JSON.stringify(body),
}),
},
sidebar: {
get: () => request<SidebarResponse>('/api/sidebar'),
},
// v1.10 booterm: REST control plane for terminal panes. WebSocket attach
// lives at /ws/term/sessions/:sid/panes/:pid (handled directly by
// TerminalPane). v1.10.8c: resize moved in-band onto the WebSocket as a
// `{type:"resize",cols,rows}` text frame — the old /resize HTTP endpoint is
// gone, eliminating the race between WS attach and PTY-map registration.
terminals: {
// cols/rows are optional. When passed, booterm sizes the per-pane tmux
// session at creation time so the inner bash (and any TUI it spawns) is
// born with the correct PTY dimensions instead of tmux's 80x24 default.
start: (sessionId: string, paneId: string, cols?: number, rows?: number) =>
request<{ tmux_session: string }>(
`/api/term/sessions/${sessionId}/panes/${paneId}/start`,
{
method: 'POST',
body:
cols !== undefined && rows !== undefined
? JSON.stringify({ cols, rows })
: undefined,
},
),
kill: (sessionId: string, paneId: string) =>
request<{ ok: true }>(
`/api/term/sessions/${sessionId}/panes/${paneId}/kill`,
{ method: 'POST' },
),
},
};