Compare commits
11 Commits
32c1a2b5f6
...
v1.9.2-ask
| Author | SHA1 | Date | |
|---|---|---|---|
| d85b17081e | |||
| adb5d7b3bb | |||
| 80fd3d9fa9 | |||
| eaacd432e8 | |||
| 529a77c959 | |||
| 9a7b35b677 | |||
| 98b432ebce | |||
| 1ecccc112f | |||
| b6469055d8 | |||
| 4bf2cd40c3 | |||
| 09aecc4ee9 |
@@ -10,3 +10,13 @@ dist
|
||||
.vite
|
||||
coverage
|
||||
/tmp
|
||||
|
||||
# Secrets and runtime data
|
||||
secrets/
|
||||
data/
|
||||
*.pem
|
||||
*.key
|
||||
id_rsa*
|
||||
id_ed25519*
|
||||
known_hosts
|
||||
.ssh/
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
## Code Reviewer
|
||||
---
|
||||
temperature: 0.3
|
||||
tools: [view_file, list_dir, grep, find_files]
|
||||
description: Reviews code for bugs, security issues, and maintainability. Read-only.
|
||||
---
|
||||
You review code. Find real problems, not style nits.
|
||||
@@ -33,7 +32,6 @@ If nothing critical or major, say so in one line. Do not pad.
|
||||
## Debugger
|
||||
---
|
||||
temperature: 0.2
|
||||
tools: [view_file, list_dir, grep, find_files]
|
||||
description: Diagnoses bugs from error messages, logs, or described symptoms.
|
||||
---
|
||||
You diagnose bugs. Form a hypothesis, prove it with evidence from the code.
|
||||
@@ -62,7 +60,6 @@ Output:
|
||||
## Refactorer
|
||||
---
|
||||
temperature: 0.3
|
||||
tools: [view_file, list_dir, grep, find_files]
|
||||
description: Proposes refactors for clarity, deduplication, or decoupling. Read-only — outputs plans, not edits.
|
||||
---
|
||||
You propose refactors. You do not apply them. The user applies via OpenCode or Claude Code.
|
||||
@@ -95,7 +92,6 @@ Output:
|
||||
## Architect
|
||||
---
|
||||
temperature: 0.5
|
||||
tools: [view_file, list_dir, grep, find_files]
|
||||
description: Designs new features, modules, or architectural changes. Outputs a build plan.
|
||||
---
|
||||
You design. You produce build plans, not code.
|
||||
@@ -128,7 +124,6 @@ Output:
|
||||
## Security Auditor
|
||||
---
|
||||
temperature: 0.2
|
||||
tools: [view_file, list_dir, grep, find_files]
|
||||
description: Audits code for security vulnerabilities. Read-only.
|
||||
---
|
||||
You audit for security issues. Concrete findings only, no generic warnings.
|
||||
@@ -165,7 +160,6 @@ If the code is clean, say so. Do not invent findings.
|
||||
## Prompt Builder
|
||||
---
|
||||
temperature: 0.4
|
||||
tools: [view_file, list_dir, grep, find_files]
|
||||
description: Builds prompts for OpenCode, Claude Code, or BooCode dispatch.
|
||||
---
|
||||
You write prompts that another coding agent will execute. Your output is the prompt, not the work.
|
||||
|
||||
@@ -15,8 +15,10 @@ import { registerSidebarRoutes } from './routes/sidebar.js';
|
||||
import { registerWebSocket } from './routes/ws.js';
|
||||
import { registerModelRoutes } from './routes/models.js';
|
||||
import { registerAgentRoutes } from './routes/agents.js';
|
||||
import { registerSkillsRoutes } from './routes/skills.js';
|
||||
import { createInferenceRunner } from './services/inference.js';
|
||||
import { createBroker } from './services/broker.js';
|
||||
import { listSkills } from './services/skills.js';
|
||||
|
||||
async function main() {
|
||||
const config = loadConfig();
|
||||
@@ -62,6 +64,15 @@ async function main() {
|
||||
registerSidebarRoutes(app, sql);
|
||||
registerChatRoutes(app, sql, broker);
|
||||
|
||||
// Batch 9.6: warm the skills cache at boot and surface the count. Empty or
|
||||
// missing /data/skills is non-fatal — the skill tools just return empty.
|
||||
try {
|
||||
const skills = await listSkills();
|
||||
app.log.info(`skills loaded: ${skills.length}`);
|
||||
} catch (err) {
|
||||
app.log.warn({ err }, 'skills boot walk failed');
|
||||
}
|
||||
|
||||
const inference = createInferenceRunner(
|
||||
{
|
||||
sql,
|
||||
@@ -112,6 +123,36 @@ async function main() {
|
||||
chat_id: chatId,
|
||||
});
|
||||
},
|
||||
publishSessionFrame: (sessionId, frame) => {
|
||||
broker.publish(sessionId, frame);
|
||||
},
|
||||
});
|
||||
registerSkillsRoutes(app, sql, {
|
||||
enqueueInference: (sessionId, chatId, assistantId, user) => {
|
||||
inference.enqueue(sessionId, chatId, assistantId, user);
|
||||
},
|
||||
publishUserMessage: (sessionId, chatId, userMessageId, content) => {
|
||||
broker.publish(sessionId, {
|
||||
type: 'message_started',
|
||||
message_id: userMessageId,
|
||||
chat_id: chatId,
|
||||
role: 'user',
|
||||
});
|
||||
broker.publish(sessionId, {
|
||||
type: 'delta',
|
||||
message_id: userMessageId,
|
||||
chat_id: chatId,
|
||||
content,
|
||||
});
|
||||
broker.publish(sessionId, {
|
||||
type: 'message_complete',
|
||||
message_id: userMessageId,
|
||||
chat_id: chatId,
|
||||
});
|
||||
},
|
||||
publishSessionFrame: (sessionId, frame) => {
|
||||
broker.publish(sessionId, frame);
|
||||
},
|
||||
});
|
||||
registerWebSocket(app, sql, broker);
|
||||
|
||||
|
||||
@@ -123,6 +123,53 @@ export function registerChatRoutes(
|
||||
}
|
||||
);
|
||||
|
||||
// v1.9: bulk-archive every open chat in a session. Mirrors the single
|
||||
// /chats/:id/archive shape — N chat_archived frames published, useSidebar
|
||||
// reducer handles each via the existing case.
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/api/sessions/:id/chats/archive-all',
|
||||
async (req, reply) => {
|
||||
const session = await sql`SELECT id FROM sessions WHERE id = ${req.params.id}`;
|
||||
if (session.length === 0) {
|
||||
reply.code(404);
|
||||
return { error: 'session not found' };
|
||||
}
|
||||
const rows = await sql<{ id: string }[]>`
|
||||
UPDATE chats
|
||||
SET status = 'archived', updated_at = clock_timestamp()
|
||||
WHERE session_id = ${req.params.id} AND status = 'open'
|
||||
RETURNING id
|
||||
`;
|
||||
const ids = rows.map((r) => r.id);
|
||||
for (const id of ids) {
|
||||
broker.publishUser('default', {
|
||||
type: 'chat_archived',
|
||||
chat_id: id,
|
||||
session_id: req.params.id,
|
||||
});
|
||||
}
|
||||
return { archived: ids.length, ids };
|
||||
}
|
||||
);
|
||||
|
||||
// v1.9: count helper for the confirm dialog.
|
||||
app.get<{ Params: { id: string } }>(
|
||||
'/api/sessions/:id/chats/open-count',
|
||||
async (req, reply) => {
|
||||
const session = await sql`SELECT id FROM sessions WHERE id = ${req.params.id}`;
|
||||
if (session.length === 0) {
|
||||
reply.code(404);
|
||||
return { error: 'session not found' };
|
||||
}
|
||||
const rows = await sql<{ count: number }[]>`
|
||||
SELECT COUNT(*)::int AS count
|
||||
FROM chats
|
||||
WHERE session_id = ${req.params.id} AND status = 'open'
|
||||
`;
|
||||
return { count: rows[0]?.count ?? 0 };
|
||||
}
|
||||
);
|
||||
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/api/chats/:id/archive',
|
||||
async (req, reply) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { Sql } from '../db.js';
|
||||
import type { Chat, Message, Session } from '../types/api.js';
|
||||
import type { Chat, Message, Session, ToolCall } from '../types/api.js';
|
||||
|
||||
const SendBody = z.object({
|
||||
content: z.string().min(1).max(64_000),
|
||||
@@ -14,6 +14,39 @@ const ContinueBody = z.object({
|
||||
sentinel_message_id: z.string().uuid(),
|
||||
});
|
||||
|
||||
// Batch 9.7: ask_user_input answer submission. Defensive shape — the question
|
||||
// content is echoed back for traceability but the server does NOT trust it
|
||||
// (the source of truth is the assistant message's tool_calls.args.questions).
|
||||
const AnswerUserInputBody = z.object({
|
||||
tool_call_id: z.string().min(1),
|
||||
answers: z
|
||||
.array(
|
||||
z.object({
|
||||
question: z.string(),
|
||||
selected_options: z.array(z.string()),
|
||||
free_text: z.string().nullable(),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(3),
|
||||
});
|
||||
|
||||
// Same shape the model declared via the tool's zod input. Re-derived here so
|
||||
// the route can validate args without depending on services/tools.ts (which
|
||||
// would pull in fs/path_guard for nothing).
|
||||
const AskUserInputArgs = z.object({
|
||||
questions: z
|
||||
.array(
|
||||
z.object({
|
||||
question: z.string(),
|
||||
type: z.enum(['single_select', 'multi_select']),
|
||||
options: z.array(z.string()).min(1),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(3),
|
||||
});
|
||||
|
||||
interface MessageHandlers {
|
||||
enqueueInference: (sessionId: string, chatId: string, assistantMessageId: string, user: string) => void;
|
||||
enqueueCompact: (sessionId: string, chatId: string, compactMessageId: string, user: string) => void;
|
||||
@@ -24,6 +57,13 @@ interface MessageHandlers {
|
||||
content: string
|
||||
) => void;
|
||||
publishMessagesDeleted: (sessionId: string, chatId: string, messageIds: string[]) => void;
|
||||
// Batch 9.7: lets the answer endpoint emit the tool_result frame that the
|
||||
// pause path intentionally skipped. Matches SkillInvokeHandlers in
|
||||
// routes/skills.ts so index.ts can pass the same broker.publish adapter.
|
||||
publishSessionFrame: (
|
||||
sessionId: string,
|
||||
frame: Record<string, unknown> & { type: string }
|
||||
) => void;
|
||||
cancelInference: (sessionId: string, chatId: string) => Promise<boolean>;
|
||||
hasActiveInference: (chatId: string) => boolean;
|
||||
}
|
||||
@@ -389,4 +429,169 @@ export function registerMessageRoutes(
|
||||
return result;
|
||||
}
|
||||
);
|
||||
|
||||
// Batch 9.7: resume an ask_user_input pause. Validates the body matches the
|
||||
// question shape the model declared, UPDATEs the pending tool row's
|
||||
// tool_results to the AnswerSet, publishes the deferred tool_result frame,
|
||||
// and enqueues the next assistant turn. Error codes per spec:
|
||||
// 400 invalid_body / mismatched_answer_shape
|
||||
// 404 chat_not_found / unknown_tool_call_id
|
||||
// 409 tool_call_already_answered
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/api/chats/:id/answer_user_input',
|
||||
async (req, reply) => {
|
||||
const parsed = AnswerUserInputBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
reply.code(400);
|
||||
return { error: 'invalid_body', details: parsed.error.flatten() };
|
||||
}
|
||||
const { tool_call_id, answers } = parsed.data;
|
||||
|
||||
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;
|
||||
|
||||
// Find the assistant message that emitted this tool_call. Scoped by
|
||||
// chat_id + role to avoid cross-chat lookups; ordered by created_at DESC
|
||||
// because the most recent issuance wins when an LLM reuses call IDs
|
||||
// across turns (the older, already-answered one is a different row with
|
||||
// populated tool_results downstream).
|
||||
const callerRows = await sql<{ id: string; tool_calls: ToolCall[] | null }[]>`
|
||||
SELECT id, tool_calls FROM messages
|
||||
WHERE chat_id = ${chat.id}
|
||||
AND role = 'assistant'
|
||||
AND tool_calls IS NOT NULL
|
||||
ORDER BY created_at DESC
|
||||
`;
|
||||
let foundCall: ToolCall | null = null;
|
||||
for (const row of callerRows) {
|
||||
const match = row.tool_calls?.find((tc) => tc.id === tool_call_id);
|
||||
if (match) {
|
||||
foundCall = match;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!foundCall) {
|
||||
reply.code(404);
|
||||
return { error: 'unknown_tool_call_id' };
|
||||
}
|
||||
if (foundCall.name !== 'ask_user_input') {
|
||||
reply.code(400);
|
||||
return { error: 'tool_call_not_ask_user_input' };
|
||||
}
|
||||
|
||||
// Validate the args themselves — the LLM could have emitted bad JSON.
|
||||
const argsParsed = AskUserInputArgs.safeParse(foundCall.args);
|
||||
if (!argsParsed.success) {
|
||||
reply.code(400);
|
||||
return { error: 'mismatched_answer_shape', detail: 'tool_call args invalid' };
|
||||
}
|
||||
const questions = argsParsed.data.questions;
|
||||
if (answers.length !== questions.length) {
|
||||
reply.code(400);
|
||||
return {
|
||||
error: 'mismatched_answer_shape',
|
||||
detail: `expected ${questions.length} answer(s), got ${answers.length}`,
|
||||
};
|
||||
}
|
||||
for (let i = 0; i < questions.length; i++) {
|
||||
const q = questions[i]!;
|
||||
const a = answers[i]!;
|
||||
for (const sel of a.selected_options) {
|
||||
if (!q.options.includes(sel)) {
|
||||
reply.code(400);
|
||||
return {
|
||||
error: 'mismatched_answer_shape',
|
||||
detail: `answer ${i + 1} contains option not in question: ${sel}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (q.type === 'single_select' && a.selected_options.length > 1) {
|
||||
reply.code(400);
|
||||
return {
|
||||
error: 'mismatched_answer_shape',
|
||||
detail: `answer ${i + 1} has multiple selections on single_select`,
|
||||
};
|
||||
}
|
||||
const hasOpt = a.selected_options.length > 0;
|
||||
const hasText = a.free_text !== null && a.free_text.trim().length > 0;
|
||||
if (!hasOpt && !hasText) {
|
||||
reply.code(400);
|
||||
return { error: 'mismatched_answer_shape', detail: `answer ${i + 1} is empty` };
|
||||
}
|
||||
}
|
||||
|
||||
// Find the pending tool row. ORDER BY created_at DESC + LIMIT 1 picks
|
||||
// the most recent row with this tool_call_id; the already-answered
|
||||
// check below guards against UPDATE-ing a stale answer.
|
||||
const toolRows = await sql<{
|
||||
id: string;
|
||||
tool_results: { tool_call_id: string; output: unknown } | null;
|
||||
}[]>`
|
||||
SELECT id, tool_results FROM messages
|
||||
WHERE chat_id = ${chat.id}
|
||||
AND role = 'tool'
|
||||
AND tool_results->>'tool_call_id' = ${tool_call_id}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
const toolRow = toolRows[0];
|
||||
if (!toolRow) {
|
||||
reply.code(404);
|
||||
return { error: 'unknown_tool_call_id', detail: 'tool message not found' };
|
||||
}
|
||||
if (toolRow.tool_results && toolRow.tool_results.output !== null) {
|
||||
reply.code(409);
|
||||
return { error: 'tool_call_already_answered' };
|
||||
}
|
||||
|
||||
const answerSet = { answers };
|
||||
const newToolResults = {
|
||||
tool_call_id,
|
||||
output: answerSet,
|
||||
truncated: false,
|
||||
};
|
||||
|
||||
const result = await sql.begin(async (tx) => {
|
||||
await tx`
|
||||
UPDATE messages
|
||||
SET tool_results = ${tx.json(newToolResults as never)}
|
||||
WHERE id = ${toolRow.id}
|
||||
`;
|
||||
const [assistantMsg] = await tx<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'streaming', clock_timestamp())
|
||||
RETURNING 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 {
|
||||
tool_message_id: toolRow.id,
|
||||
assistant_message_id: assistantMsg!.id,
|
||||
};
|
||||
});
|
||||
|
||||
// Publish the deferred tool_result frame. useSessionStream's reducer
|
||||
// updates the matching tool_run.result so AskUserInputCard flips into
|
||||
// its read-only "answered" mode without a refetch.
|
||||
handlers.publishSessionFrame(sessionId, {
|
||||
type: 'tool_result',
|
||||
tool_message_id: result.tool_message_id,
|
||||
tool_call_id,
|
||||
chat_id: chat.id,
|
||||
output: answerSet,
|
||||
truncated: false,
|
||||
});
|
||||
handlers.enqueueInference(sessionId, chat.id, result.assistant_message_id, 'default');
|
||||
|
||||
reply.code(202);
|
||||
return result;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,8 +22,14 @@ const AddProjectBody = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
// v1.9: PATCH accepts the new per-project defaults. All fields optional so
|
||||
// the existing rename-only callers keep working. Empty string on
|
||||
// default_system_prompt is the "no override" sentinel — same convention as
|
||||
// sessions.system_prompt.
|
||||
const PatchProjectBody = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
default_system_prompt: z.string().max(8000).optional(),
|
||||
default_web_search_enabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const CreateProjectBody = z.object({
|
||||
@@ -70,7 +76,8 @@ export function registerProjectRoutes(
|
||||
app.get<{ Querystring: { status?: string } }>('/api/projects', async (req) => {
|
||||
const status = req.query.status === 'archived' ? 'archived' : 'open';
|
||||
const rows = await sql<Project[]>`
|
||||
SELECT id, name, path, added_at, last_session_id, status, gitea_remote
|
||||
SELECT id, name, path, added_at, last_session_id, status, gitea_remote,
|
||||
default_system_prompt, default_web_search_enabled
|
||||
FROM projects
|
||||
WHERE status = ${status}
|
||||
ORDER BY added_at DESC
|
||||
@@ -119,7 +126,8 @@ export function registerProjectRoutes(
|
||||
const [row] = await sql<Project[]>`
|
||||
INSERT INTO projects (name, path, gitea_remote)
|
||||
VALUES (${parsed.data.name}, ${bootstrap.folder_real_path}, ${bootstrap.gitea_remote_url})
|
||||
RETURNING id, name, path, added_at, last_session_id, status, gitea_remote
|
||||
RETURNING id, name, path, added_at, last_session_id, status, gitea_remote,
|
||||
default_system_prompt, default_web_search_enabled
|
||||
`;
|
||||
broker.publishUser('default', { type: 'project_created', project: row as unknown as Project });
|
||||
reply.code(201);
|
||||
@@ -173,7 +181,8 @@ export function registerProjectRoutes(
|
||||
INSERT INTO projects (name, path)
|
||||
VALUES (${name}, ${resolved.real})
|
||||
ON CONFLICT (path) DO UPDATE SET status = 'open'
|
||||
RETURNING id, name, path, added_at, last_session_id, status, gitea_remote
|
||||
RETURNING id, name, path, added_at, last_session_id, status, gitea_remote,
|
||||
default_system_prompt, default_web_search_enabled
|
||||
`;
|
||||
|
||||
if (existing.length === 0) {
|
||||
@@ -187,22 +196,53 @@ export function registerProjectRoutes(
|
||||
return row;
|
||||
});
|
||||
|
||||
// v1.9: single-project fetch so the settings pane can refetch on
|
||||
// project_updated without pulling the whole project list.
|
||||
app.get<{ Params: { id: string } }>('/api/projects/:id', async (req, reply) => {
|
||||
const rows = await sql<Project[]>`
|
||||
SELECT id, name, path, added_at, last_session_id, status, gitea_remote,
|
||||
default_system_prompt, default_web_search_enabled
|
||||
FROM projects WHERE id = ${req.params.id}
|
||||
`;
|
||||
if (rows.length === 0) {
|
||||
reply.code(404);
|
||||
return { error: 'not found' };
|
||||
}
|
||||
return rows[0];
|
||||
});
|
||||
|
||||
app.patch<{ Params: { id: string } }>('/api/projects/:id', async (req, reply) => {
|
||||
const parsed = PatchProjectBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
reply.code(400);
|
||||
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||
}
|
||||
const { name, default_system_prompt, default_web_search_enabled } = parsed.data;
|
||||
// v1.9: every field optional. COALESCE on the bind keeps the prior value
|
||||
// when the caller omits it. Boolean has its own branch since COALESCE
|
||||
// can't disambiguate "omitted" from "explicitly false" via a single
|
||||
// nullable parameter.
|
||||
const dwsProvided = default_web_search_enabled !== undefined;
|
||||
const rows = await sql<Project[]>`
|
||||
UPDATE projects SET name = ${parsed.data.name}
|
||||
UPDATE projects
|
||||
SET
|
||||
name = COALESCE(${name ?? null}, name),
|
||||
default_system_prompt = COALESCE(${default_system_prompt ?? null}, default_system_prompt),
|
||||
default_web_search_enabled = CASE WHEN ${dwsProvided}
|
||||
THEN ${default_web_search_enabled ?? false}
|
||||
ELSE default_web_search_enabled END
|
||||
WHERE id = ${req.params.id}
|
||||
RETURNING id, name, path, added_at, last_session_id, status, gitea_remote
|
||||
RETURNING id, name, path, added_at, last_session_id, status, gitea_remote,
|
||||
default_system_prompt, default_web_search_enabled
|
||||
`;
|
||||
if (rows.length === 0) {
|
||||
reply.code(404);
|
||||
return { error: 'not found' };
|
||||
}
|
||||
const project = rows[0]!;
|
||||
// v1.9: the project_updated frame still only carries id + name. Clients
|
||||
// that need the new fields refetch via api.projects.list() — keeps the
|
||||
// frame payload lean, per the locked recon decision (d).
|
||||
broker.publishUser('default', {
|
||||
type: 'project_updated',
|
||||
project_id: project.id,
|
||||
@@ -229,7 +269,8 @@ export function registerProjectRoutes(
|
||||
const rows = await sql<Project[]>`
|
||||
UPDATE projects SET status = 'open'
|
||||
WHERE id = ${req.params.id} AND status = 'archived'
|
||||
RETURNING id, name, path, added_at, last_session_id, status, gitea_remote
|
||||
RETURNING id, name, path, added_at, last_session_id, status, gitea_remote,
|
||||
default_system_prompt, default_web_search_enabled
|
||||
`;
|
||||
if (rows.length === 0) {
|
||||
reply.code(404);
|
||||
|
||||
@@ -19,6 +19,8 @@ const PatchBody = z.object({
|
||||
model: z.string().min(1).max(200).optional(),
|
||||
system_prompt: z.string().max(8000).optional(),
|
||||
agent_id: z.string().min(1).max(200).nullable().optional(),
|
||||
// v1.9: null = inherit from project default; true/false = explicit override.
|
||||
web_search_enabled: z.boolean().nullable().optional(),
|
||||
});
|
||||
|
||||
async function resolveDefaultModel(sql: Sql, config: Config): Promise<string> {
|
||||
@@ -50,7 +52,7 @@ export function registerSessionRoutes(
|
||||
}
|
||||
const status = req.query.status === 'archived' ? 'archived' : 'open';
|
||||
const rows = await sql<Session[]>`
|
||||
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id
|
||||
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled
|
||||
FROM sessions
|
||||
WHERE project_id = ${req.params.id} AND status = ${status}
|
||||
ORDER BY updated_at DESC
|
||||
@@ -100,7 +102,7 @@ export function registerSessionRoutes(
|
||||
const [session] = await tx<Session[]>`
|
||||
INSERT INTO sessions (project_id, name, model, system_prompt, agent_id)
|
||||
VALUES (${req.params.id}, ${name}, ${model}, ${systemPrompt}, ${agentId})
|
||||
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id
|
||||
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled
|
||||
`;
|
||||
await tx`
|
||||
INSERT INTO chats (session_id, name, status)
|
||||
@@ -120,7 +122,7 @@ export function registerSessionRoutes(
|
||||
|
||||
app.get<{ Params: { id: string } }>('/api/sessions/:id', async (req, reply) => {
|
||||
const rows = await sql<Session[]>`
|
||||
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id
|
||||
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled
|
||||
FROM sessions WHERE id = ${req.params.id}
|
||||
`;
|
||||
if (rows.length === 0) {
|
||||
@@ -139,10 +141,13 @@ export function registerSessionRoutes(
|
||||
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||
}
|
||||
const { name, model, system_prompt } = parsed.data;
|
||||
// agent_id is tri-state on the wire: omitted = no change, null = clear,
|
||||
// string = set. CASE WHEN inside SET handles all three atomically.
|
||||
// agent_id and web_search_enabled are both tri-state on the wire: omitted
|
||||
// = no change, null = clear/inherit, value = set. CASE WHEN inside SET
|
||||
// handles all three atomically.
|
||||
const agentIdProvided = parsed.data.agent_id !== undefined;
|
||||
const newAgentId = parsed.data.agent_id ?? null;
|
||||
const wseProvided = parsed.data.web_search_enabled !== undefined;
|
||||
const newWse = parsed.data.web_search_enabled ?? null;
|
||||
// Read the prior name so the post-update publish can skip no-op renames
|
||||
// (PATCH { name: "Foo" } where the session is already "Foo"). The window
|
||||
// between SELECT and UPDATE is sub-millisecond in the same request handler;
|
||||
@@ -159,9 +164,11 @@ export function registerSessionRoutes(
|
||||
model = COALESCE(${model ?? null}, model),
|
||||
system_prompt = COALESCE(${system_prompt ?? null}, system_prompt),
|
||||
agent_id = CASE WHEN ${agentIdProvided} THEN ${newAgentId} ELSE agent_id END,
|
||||
web_search_enabled = CASE WHEN ${wseProvided} THEN ${newWse} ELSE web_search_enabled END,
|
||||
updated_at = clock_timestamp()
|
||||
WHERE id = ${req.params.id}
|
||||
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id
|
||||
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at,
|
||||
agent_id, web_search_enabled
|
||||
`;
|
||||
if (rows.length === 0) {
|
||||
reply.code(404);
|
||||
@@ -175,10 +182,69 @@ export function registerSessionRoutes(
|
||||
name: session.name,
|
||||
});
|
||||
}
|
||||
// v1.9: any successful PATCH broadcasts session_updated so listeners
|
||||
// (notably the SettingsPane open in another tab) can refetch and pick
|
||||
// up the new fields. Frame stays lean (decision d) — payload is just
|
||||
// ids + name + updated_at, the client refetches via api.sessions.get.
|
||||
broker.publishUser('default', {
|
||||
type: 'session_updated',
|
||||
session_id: session.id,
|
||||
project_id: session.project_id,
|
||||
name: session.name,
|
||||
updated_at: session.updated_at,
|
||||
});
|
||||
return session;
|
||||
}
|
||||
);
|
||||
|
||||
// v1.9: bulk-archive every open session in a project. Mirrors the
|
||||
// single-archive shape (same broker frame type) so the existing useSidebar
|
||||
// reducer cases handle it without changes — just N frames instead of 1.
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/api/projects/:id/sessions/archive-all',
|
||||
async (req, reply) => {
|
||||
const project = await sql`SELECT id FROM projects WHERE id = ${req.params.id}`;
|
||||
if (project.length === 0) {
|
||||
reply.code(404);
|
||||
return { error: 'project not found' };
|
||||
}
|
||||
const rows = await sql<{ id: string }[]>`
|
||||
UPDATE sessions
|
||||
SET status = 'archived', updated_at = clock_timestamp()
|
||||
WHERE project_id = ${req.params.id} AND status = 'open'
|
||||
RETURNING id
|
||||
`;
|
||||
const ids = rows.map((r) => r.id);
|
||||
for (const id of ids) {
|
||||
broker.publishUser('default', {
|
||||
type: 'session_archived',
|
||||
session_id: id,
|
||||
project_id: req.params.id,
|
||||
});
|
||||
}
|
||||
return { archived: ids.length, ids };
|
||||
}
|
||||
);
|
||||
|
||||
// v1.9: count helper for the confirm dialog. Cheap COUNT(*) — the settings
|
||||
// pane calls it on click, not on render.
|
||||
app.get<{ Params: { id: string } }>(
|
||||
'/api/projects/:id/sessions/open-count',
|
||||
async (req, reply) => {
|
||||
const project = await sql`SELECT id FROM projects WHERE id = ${req.params.id}`;
|
||||
if (project.length === 0) {
|
||||
reply.code(404);
|
||||
return { error: 'project not found' };
|
||||
}
|
||||
const rows = await sql<{ count: number }[]>`
|
||||
SELECT COUNT(*)::int AS count
|
||||
FROM sessions
|
||||
WHERE project_id = ${req.params.id} AND status = 'open'
|
||||
`;
|
||||
return { count: rows[0]?.count ?? 0 };
|
||||
}
|
||||
);
|
||||
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/api/sessions/:id/archive',
|
||||
async (req, reply) => {
|
||||
@@ -207,7 +273,7 @@ export function registerSessionRoutes(
|
||||
const rows = await sql<Session[]>`
|
||||
UPDATE sessions SET status = 'open', updated_at = clock_timestamp()
|
||||
WHERE id = ${req.params.id} AND status = 'archived'
|
||||
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id
|
||||
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled
|
||||
`;
|
||||
if (rows.length === 0) {
|
||||
reply.code(404);
|
||||
|
||||
156
apps/server/src/routes/skills.ts
Normal file
156
apps/server/src/routes/skills.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { Sql } from '../db.js';
|
||||
import type { Chat } from '../types/api.js';
|
||||
import { getSkillBody, listSkills } from '../services/skills.js';
|
||||
|
||||
// Batch 9.6 slash-invoke handlers. Mirrors the MessageHandlers shape in
|
||||
// routes/messages.ts so index.ts can pass thin adapters around broker +
|
||||
// inference runner without skills.ts importing them directly.
|
||||
export interface SkillInvokeHandlers {
|
||||
enqueueInference: (
|
||||
sessionId: string,
|
||||
chatId: string,
|
||||
assistantMessageId: string,
|
||||
user: string,
|
||||
) => void;
|
||||
publishUserMessage: (
|
||||
sessionId: string,
|
||||
chatId: string,
|
||||
userMessageId: string,
|
||||
content: string,
|
||||
) => void;
|
||||
publishSessionFrame: (
|
||||
sessionId: string,
|
||||
frame: Record<string, unknown> & { type: string },
|
||||
) => void;
|
||||
}
|
||||
|
||||
const SkillInvokeBody = z.object({
|
||||
skill_name: z.string().min(1),
|
||||
// Optional — server fills in a default if absent or whitespace-only so the
|
||||
// model always has something to act on (matches the spec's "Apply this
|
||||
// skill." filler).
|
||||
user_message: z.string().max(64_000).nullable().optional(),
|
||||
});
|
||||
|
||||
const DEFAULT_USER_MESSAGE = 'Apply this skill.';
|
||||
|
||||
export function registerSkillsRoutes(
|
||||
app: FastifyInstance,
|
||||
sql: Sql,
|
||||
handlers: SkillInvokeHandlers,
|
||||
): void {
|
||||
// Debug/admin surface — the model interacts with skills via the three
|
||||
// skill_* tools, not through this endpoint.
|
||||
app.get('/api/skills', async () => {
|
||||
return { skills: await listSkills() };
|
||||
});
|
||||
|
||||
// POST /api/chats/:id/skill_invoke — slash-command entry point. Loads the
|
||||
// skill body server-side (clients never get to forge file content),
|
||||
// persists 4 messages in one transaction (synthetic assistant tool_use,
|
||||
// synthetic tool result, real user message, streaming assistant), and
|
||||
// enqueues inference against the updated history.
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/api/chats/:id/skill_invoke',
|
||||
async (req, reply) => {
|
||||
const parsed = SkillInvokeBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
reply.code(400);
|
||||
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||
}
|
||||
const { skill_name } = parsed.data;
|
||||
const userText = parsed.data.user_message?.trim() ? parsed.data.user_message : DEFAULT_USER_MESSAGE;
|
||||
|
||||
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 body = await getSkillBody(skill_name);
|
||||
if (body === null) {
|
||||
reply.code(404);
|
||||
return { error: 'unknown_skill', message: `unknown skill: ${skill_name}` };
|
||||
}
|
||||
|
||||
const toolCallId = randomUUID();
|
||||
const toolCalls = [{ id: toolCallId, name: 'skill_use', args: { name: skill_name } }];
|
||||
const toolResults = { tool_call_id: toolCallId, output: body, truncated: false };
|
||||
|
||||
const result = await sql.begin(async (tx) => {
|
||||
const [synthAssistant] = await tx<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, tool_calls, status, created_at)
|
||||
VALUES (${sessionId}, ${chat.id}, 'assistant', '', ${sql.json(toolCalls as never)}, 'complete', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
const [toolMsg] = await tx<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, tool_results, status, created_at)
|
||||
VALUES (${sessionId}, ${chat.id}, 'tool', '', ${sql.json(toolResults as never)}, 'complete', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
const [userMsg] = await tx<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chat.id}, 'user', ${userText}, 'complete', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
const [assistantMsg] = await tx<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'streaming', clock_timestamp())
|
||||
RETURNING 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 {
|
||||
synth_assistant_id: synthAssistant!.id,
|
||||
tool_message_id: toolMsg!.id,
|
||||
user_message_id: userMsg!.id,
|
||||
assistant_message_id: assistantMsg!.id,
|
||||
};
|
||||
});
|
||||
|
||||
// Synthetic frames so useSessionStream's reducer reflects the new
|
||||
// history without a refetch. Frame shapes match the streaming-inference
|
||||
// protocol (see services/inference.ts InferenceFrame).
|
||||
handlers.publishSessionFrame(sessionId, {
|
||||
type: 'message_started',
|
||||
message_id: result.synth_assistant_id,
|
||||
chat_id: chat.id,
|
||||
role: 'assistant',
|
||||
});
|
||||
handlers.publishSessionFrame(sessionId, {
|
||||
type: 'tool_call',
|
||||
message_id: result.synth_assistant_id,
|
||||
chat_id: chat.id,
|
||||
tool_call: toolCalls[0]!,
|
||||
});
|
||||
handlers.publishSessionFrame(sessionId, {
|
||||
type: 'message_complete',
|
||||
message_id: result.synth_assistant_id,
|
||||
chat_id: chat.id,
|
||||
});
|
||||
// The tool_result frame's reducer branch creates the tool-role message
|
||||
// in-place when it doesn't already exist — no separate message_started
|
||||
// is needed for the tool side.
|
||||
handlers.publishSessionFrame(sessionId, {
|
||||
type: 'tool_result',
|
||||
tool_message_id: result.tool_message_id,
|
||||
tool_call_id: toolCallId,
|
||||
chat_id: chat.id,
|
||||
output: body,
|
||||
truncated: false,
|
||||
});
|
||||
handlers.publishUserMessage(sessionId, chat.id, result.user_message_id, userText);
|
||||
handlers.enqueueInference(sessionId, chat.id, result.assistant_message_id, 'default');
|
||||
|
||||
reply.code(202);
|
||||
return result;
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -171,3 +171,11 @@ ALTER TABLE messages ADD COLUMN IF NOT EXISTS metadata JSONB;
|
||||
-- not new columns. Defaults match docs/themes_v1.md: obsidian (dark).
|
||||
INSERT INTO settings (key, value) VALUES ('theme_id', '"obsidian"') ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO settings (key, value) VALUES ('theme_mode', '"dark"') ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- v1.9: per-project defaults that new sessions inherit, plus a per-session
|
||||
-- web-search override. Empty string on either prompt column means "inherit"
|
||||
-- (resolved in inference.ts buildSystemPrompt). web_search_enabled is the
|
||||
-- only tri-state field: null on session = inherit from project 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 sessions ADD COLUMN IF NOT EXISTS web_search_enabled BOOLEAN;
|
||||
|
||||
@@ -22,6 +22,7 @@ function makeSession(overrides: Partial<Session> = {}): Session {
|
||||
created_at: new Date(0).toISOString(),
|
||||
updated_at: new Date(0).toISOString(),
|
||||
agent_id: null,
|
||||
web_search_enabled: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -35,6 +36,8 @@ function makeProject(overrides: Partial<Project> = {}): Project {
|
||||
last_session_id: null,
|
||||
status: 'open',
|
||||
gitea_remote: null,
|
||||
default_system_prompt: '',
|
||||
default_web_search_enabled: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,7 +11,17 @@ const GLOBAL_AGENTS_PATH = '/data/AGENTS.md';
|
||||
const CACHE_TTL_MS = 60_000;
|
||||
|
||||
// Tools whitelist universe matches services/tools.ts ALL_TOOLS. Keep in sync.
|
||||
const ALL_TOOL_NAMES = ['view_file', 'list_dir', 'grep', 'find_files', 'git_status'] as const;
|
||||
// Batch 9.6: skill_find / skill_use / skill_resource added. Agents without an
|
||||
// explicit `tools:` field inherit the full default set (which now includes
|
||||
// the skill tools); agents with an explicit `tools:` array must list any
|
||||
// skill tool they want to use — strict opt-in.
|
||||
// Batch 9.7: ask_user_input added — same opt-in semantics. Agents with an
|
||||
// explicit tools list that omits it cannot trigger the interactive picker.
|
||||
const ALL_TOOL_NAMES = [
|
||||
'view_file', 'list_dir', 'grep', 'find_files', 'git_status',
|
||||
'skill_find', 'skill_use', 'skill_resource',
|
||||
'ask_user_input',
|
||||
] as const;
|
||||
const DEFAULT_TOOLS: string[] = [...ALL_TOOL_NAMES];
|
||||
const DEFAULT_TEMPERATURE = 0.7;
|
||||
|
||||
|
||||
@@ -149,9 +149,11 @@ export interface InferenceContext {
|
||||
publishUser: (frame: UserStreamFrame) => void;
|
||||
}
|
||||
|
||||
// Resolution order: base prompt < agent.system_prompt < session.system_prompt.
|
||||
// Agent prompts layer on top of the base; session prompt is the most specific
|
||||
// override and stacks last so callers can append per-session instructions.
|
||||
// Resolution order: base prompt < agent.system_prompt < user prompt, where
|
||||
// user prompt = session.system_prompt if non-empty, else project's
|
||||
// default_system_prompt if non-empty, else nothing. Empty/whitespace-only
|
||||
// counts as "no override" for both layers (v1.9 inherit semantics — keeps
|
||||
// the column non-nullable so the existing key/value store stays put).
|
||||
export function buildSystemPrompt(
|
||||
project: Project,
|
||||
session: Session,
|
||||
@@ -161,8 +163,11 @@ export function buildSystemPrompt(
|
||||
if (agent && agent.system_prompt.trim().length > 0) {
|
||||
out += '\n\n' + agent.system_prompt.trim();
|
||||
}
|
||||
if (session.system_prompt && session.system_prompt.trim().length > 0) {
|
||||
out += '\n\n' + session.system_prompt.trim();
|
||||
const sessionPrompt = session.system_prompt?.trim() ?? '';
|
||||
const projectPrompt = project.default_system_prompt?.trim() ?? '';
|
||||
const userPrompt = sessionPrompt || projectPrompt;
|
||||
if (userPrompt.length > 0) {
|
||||
out += '\n\n' + userPrompt;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -240,14 +245,16 @@ async function loadContext(
|
||||
chatId: string
|
||||
): Promise<{ session: Session; project: Project; history: Message[] } | null> {
|
||||
const sessionRows = await sql<Session[]>`
|
||||
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id
|
||||
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at,
|
||||
agent_id, web_search_enabled
|
||||
FROM sessions WHERE id = ${sessionId}
|
||||
`;
|
||||
if (sessionRows.length === 0) return null;
|
||||
const session = sessionRows[0]!;
|
||||
|
||||
const projectRows = await sql<Project[]>`
|
||||
SELECT id, name, path, added_at, last_session_id
|
||||
SELECT id, name, path, added_at, last_session_id, status, gitea_remote,
|
||||
default_system_prompt, default_web_search_enabled
|
||||
FROM projects WHERE id = ${session.project_id}
|
||||
`;
|
||||
if (projectRows.length === 0) return null;
|
||||
@@ -658,6 +665,12 @@ async function executeToolPhase(
|
||||
model: session.model,
|
||||
});
|
||||
|
||||
// Batch 9.7: ask_user_input pauses the loop. The tool row is still inserted
|
||||
// (the answer endpoint needs a target row to UPDATE), but tool_results is
|
||||
// pre-stamped with output=null as a "pending" sentinel and no tool_result
|
||||
// frame goes out — the card renders from the tool_call frame alone. Mixed
|
||||
// batches still execute the other tools normally.
|
||||
let pausingForUserInput = false;
|
||||
await Promise.all(
|
||||
toolCalls.map(async (tc) => {
|
||||
const [toolRow] = await ctx.sql<{ id: string }[]>`
|
||||
@@ -666,6 +679,16 @@ async function executeToolPhase(
|
||||
RETURNING id
|
||||
`;
|
||||
const toolMessageId = toolRow!.id;
|
||||
if (tc.name === 'ask_user_input') {
|
||||
pausingForUserInput = true;
|
||||
const sentinel = { tool_call_id: tc.id, output: null, truncated: false };
|
||||
await ctx.sql`
|
||||
UPDATE messages
|
||||
SET tool_results = ${ctx.sql.json(sentinel as never)}
|
||||
WHERE id = ${toolMessageId}
|
||||
`;
|
||||
return;
|
||||
}
|
||||
const tres = await executeToolCall(projectRoot, tc);
|
||||
const stored = {
|
||||
tool_call_id: tc.id,
|
||||
@@ -690,6 +713,23 @@ async function executeToolPhase(
|
||||
})
|
||||
);
|
||||
|
||||
if (pausingForUserInput) {
|
||||
// Drop the dot back to idle — the card is the actionable surface now.
|
||||
// The next inference turn fires from POST /api/chats/:id/answer_user_input
|
||||
// once the user submits their answers.
|
||||
ctx.publishUser({
|
||||
type: 'chat_status',
|
||||
chat_id: chatId,
|
||||
status: 'idle',
|
||||
at: new Date().toISOString(),
|
||||
});
|
||||
ctx.log.info(
|
||||
{ sessionId, chatId, assistantMessageId },
|
||||
'inference paused awaiting user input',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const [nextAssistant] = await ctx.sql<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
|
||||
|
||||
321
apps/server/src/services/skills.ts
Normal file
321
apps/server/src/services/skills.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { join, isAbsolute, basename } from 'node:path';
|
||||
import { pathGuard, PathScopeError } from './path_guard.js';
|
||||
|
||||
// Batch 9.6: read-only skill library. Folders under /data/skills/<group>/<skill>/
|
||||
// contain a SKILL.md with YAML frontmatter (name + description) and a markdown
|
||||
// body. Three tools expose the library: skill_find (search), skill_use (load
|
||||
// body), skill_resource (read a support file inside the folder).
|
||||
//
|
||||
// Layout is intentionally uniform — scan /data/skills/*/*/SKILL.md at fixed
|
||||
// depth 3. Group folders (depth 1) hold LICENSE + ATTRIBUTION.md + skill
|
||||
// subfolders and are NOT themselves skills. Support files inside skill
|
||||
// folders are reachable via skill_resource, never auto-parsed.
|
||||
//
|
||||
// Cache model mirrors agents.ts: walk on first access, TTL re-walk to pick up
|
||||
// new skills, per-entry mtime check on body access so a hot-edited SKILL.md
|
||||
// is re-read without a restart. No watcher.
|
||||
|
||||
const SKILLS_ROOT = '/data/skills';
|
||||
const MAX_RESOURCE_BYTES = 5 * 1024 * 1024;
|
||||
const LIST_CACHE_TTL_MS = 60_000;
|
||||
|
||||
export interface Skill {
|
||||
name: string;
|
||||
description: string;
|
||||
path: string;
|
||||
mtime: number;
|
||||
}
|
||||
|
||||
interface CachedSkill extends Skill {
|
||||
body: string;
|
||||
}
|
||||
|
||||
const cache = new Map<string, CachedSkill>();
|
||||
let lastWalkedAt = 0;
|
||||
|
||||
// ---- Frontmatter parser ----------------------------------------------------
|
||||
// Minimal `---\n...\n---` extractor. Only `name` and `description` keys are
|
||||
// honored; other frontmatter keys are silently ignored for forward-compat
|
||||
// with the anthropics/skills upstream spec.
|
||||
|
||||
interface Frontmatter {
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
function stripQuotes(s: string): string {
|
||||
if (s.length >= 2 && (s[0] === '"' || s[0] === "'") && s[0] === s[s.length - 1]) {
|
||||
return s.slice(1, -1);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function parseFrontmatter(yaml: string): Frontmatter {
|
||||
const fm: Frontmatter = {};
|
||||
for (const raw of yaml.split('\n')) {
|
||||
const line = raw.trim();
|
||||
if (line.length === 0) continue;
|
||||
const colon = line.indexOf(':');
|
||||
if (colon < 0) continue;
|
||||
const key = line.slice(0, colon).trim();
|
||||
const val = stripQuotes(line.slice(colon + 1).trim());
|
||||
if (key === 'name') fm.name = val;
|
||||
else if (key === 'description') fm.description = val;
|
||||
}
|
||||
return fm;
|
||||
}
|
||||
|
||||
interface ParsedSkillFile {
|
||||
name: string;
|
||||
description: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
function parseSkillFile(content: string): ParsedSkillFile {
|
||||
const lines = content.split('\n');
|
||||
let openIdx = -1;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const t = lines[i]!.trim();
|
||||
if (t === '') continue;
|
||||
if (t === '---') openIdx = i;
|
||||
break;
|
||||
}
|
||||
if (openIdx < 0) throw new Error('missing opening --- fence');
|
||||
let closeIdx = -1;
|
||||
for (let i = openIdx + 1; i < lines.length; i++) {
|
||||
if (lines[i]!.trim() === '---') { closeIdx = i; break; }
|
||||
}
|
||||
if (closeIdx < 0) throw new Error('missing closing --- fence');
|
||||
|
||||
const yamlText = lines.slice(openIdx + 1, closeIdx).join('\n');
|
||||
const body = lines.slice(closeIdx + 1).join('\n');
|
||||
|
||||
const fm = parseFrontmatter(yamlText);
|
||||
if (!fm.name) throw new Error('frontmatter missing name');
|
||||
if (!fm.description) throw new Error('frontmatter missing description');
|
||||
return { name: fm.name, description: fm.description, body };
|
||||
}
|
||||
|
||||
// ---- Tree walk -------------------------------------------------------------
|
||||
|
||||
// Fixed depth-3 scan: /data/skills/<group>/<skill>/SKILL.md. Two layers of
|
||||
// readdir, no recursion. Group folders without SKILL.md are skipped silently;
|
||||
// LICENSE / ATTRIBUTION.md / other non-SKILL.md files are ignored entirely.
|
||||
// Returns all parseable skills as-found — dedup + collision logging happens
|
||||
// in ensureCache where the sort order is established.
|
||||
async function walkSkills(root: string): Promise<CachedSkill[]> {
|
||||
const found: CachedSkill[] = [];
|
||||
let groups;
|
||||
try {
|
||||
groups = await fs.readdir(root, { withFileTypes: true });
|
||||
} catch {
|
||||
return found;
|
||||
}
|
||||
for (const group of groups) {
|
||||
if (!group.isDirectory() || group.name.startsWith('.')) continue;
|
||||
const groupPath = join(root, group.name);
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(groupPath, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
|
||||
const skillFolder = join(groupPath, entry.name);
|
||||
const skillFile = join(skillFolder, 'SKILL.md');
|
||||
let stat;
|
||||
try {
|
||||
stat = await fs.stat(skillFile);
|
||||
} catch {
|
||||
continue; // folder without SKILL.md — silent skip
|
||||
}
|
||||
if (!stat.isFile()) continue;
|
||||
try {
|
||||
const content = await fs.readFile(skillFile, 'utf8');
|
||||
const parsed = parseSkillFile(content);
|
||||
found.push({
|
||||
name: parsed.name,
|
||||
description: parsed.description,
|
||||
path: skillFolder,
|
||||
mtime: stat.mtimeMs,
|
||||
body: parsed.body,
|
||||
});
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
console.warn(`skills: failed to parse ${skillFile} — ${reason}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
// ---- Cache ----------------------------------------------------------------
|
||||
|
||||
async function ensureCache(): Promise<void> {
|
||||
const now = Date.now();
|
||||
if (cache.size > 0 && now - lastWalkedAt < LIST_CACHE_TTL_MS) return;
|
||||
let stat;
|
||||
try {
|
||||
stat = await fs.stat(SKILLS_ROOT);
|
||||
} catch {
|
||||
cache.clear();
|
||||
lastWalkedAt = now;
|
||||
return;
|
||||
}
|
||||
if (!stat.isDirectory()) {
|
||||
cache.clear();
|
||||
lastWalkedAt = now;
|
||||
return;
|
||||
}
|
||||
const found = await walkSkills(SKILLS_ROOT);
|
||||
// Sort by name asc, then path asc — gives alphabetically-first-wins on
|
||||
// collision and stable, deterministic ordering for /api/skills + skill_find.
|
||||
found.sort((a, b) => {
|
||||
const n = a.name.localeCompare(b.name);
|
||||
return n !== 0 ? n : a.path.localeCompare(b.path);
|
||||
});
|
||||
cache.clear();
|
||||
const winnerPath = new Map<string, string>();
|
||||
for (const skill of found) {
|
||||
const prev = winnerPath.get(skill.name);
|
||||
if (prev) {
|
||||
console.warn(
|
||||
`skills: name collision "${skill.name}" — kept ${prev}, skipped ${skill.path}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
winnerPath.set(skill.name, skill.path);
|
||||
cache.set(skill.name, skill);
|
||||
}
|
||||
lastWalkedAt = now;
|
||||
}
|
||||
|
||||
// ---- Public API -----------------------------------------------------------
|
||||
|
||||
export async function listSkills(): Promise<Skill[]> {
|
||||
await ensureCache();
|
||||
return Array.from(cache.values()).map((s) => ({
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
path: s.path,
|
||||
mtime: s.mtime,
|
||||
}));
|
||||
}
|
||||
|
||||
export interface SkillSummary {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export async function findSkills(query: string): Promise<SkillSummary[]> {
|
||||
await ensureCache();
|
||||
const all = Array.from(cache.values());
|
||||
const q = (query ?? '').trim().toLowerCase();
|
||||
if (q === '' || q === '*') {
|
||||
return all.map((s) => ({ name: s.name, description: s.description }));
|
||||
}
|
||||
// name match weighted 2x description match. No fancy ranking — substring
|
||||
// scoring is enough for ≤20 skills.
|
||||
const scored = all
|
||||
.map((s) => {
|
||||
let score = 0;
|
||||
if (s.name.toLowerCase().includes(q)) score += 2;
|
||||
if (s.description.toLowerCase().includes(q)) score += 1;
|
||||
return { s, score };
|
||||
})
|
||||
.filter((x) => x.score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 5);
|
||||
return scored.map(({ s }) => ({ name: s.name, description: s.description }));
|
||||
}
|
||||
|
||||
// Returns the SKILL.md body with frontmatter stripped, or null if the skill
|
||||
// is unknown. Single-entry mtime refresh: a hot edit shows up on next call.
|
||||
export async function getSkillBody(name: string): Promise<string | null> {
|
||||
await ensureCache();
|
||||
const cached = cache.get(name);
|
||||
if (!cached) return null;
|
||||
|
||||
let stat;
|
||||
try {
|
||||
stat = await fs.stat(join(cached.path, 'SKILL.md'));
|
||||
} catch {
|
||||
cache.delete(name);
|
||||
return null;
|
||||
}
|
||||
if (stat.mtimeMs === cached.mtime) return cached.body;
|
||||
try {
|
||||
const raw = await fs.readFile(join(cached.path, 'SKILL.md'), 'utf8');
|
||||
const parsed = parseSkillFile(raw);
|
||||
if (parsed.name !== name) {
|
||||
// Skill renamed itself; drop the stale entry. Next listSkills() walks.
|
||||
cache.delete(name);
|
||||
return null;
|
||||
}
|
||||
cached.body = parsed.body;
|
||||
cached.description = parsed.description;
|
||||
cached.mtime = stat.mtimeMs;
|
||||
return cached.body;
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
console.warn(`skills: re-parse failed for ${name} — ${reason}`);
|
||||
cache.delete(name);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export type SkillResourceErrorCode = 'unknown_skill' | 'unknown_resource' | 'path_escape';
|
||||
|
||||
export type SkillResourceResult =
|
||||
| { ok: true; content: string }
|
||||
| { ok: false; code: SkillResourceErrorCode; message: string };
|
||||
|
||||
export async function getSkillResource(
|
||||
name: string,
|
||||
relativePath: string,
|
||||
): Promise<SkillResourceResult> {
|
||||
await ensureCache();
|
||||
const cached = cache.get(name);
|
||||
if (!cached) {
|
||||
return { ok: false, code: 'unknown_skill', message: `unknown skill: ${name}` };
|
||||
}
|
||||
if (typeof relativePath !== 'string' || relativePath.trim() === '') {
|
||||
return { ok: false, code: 'unknown_resource', message: 'path is required' };
|
||||
}
|
||||
// Syntactic pre-check — catches the common "../../etc/passwd" attempt
|
||||
// before realpath dereferences any symlinks.
|
||||
if (isAbsolute(relativePath) || relativePath.split(/[\\/]/).some((seg) => seg === '..')) {
|
||||
return { ok: false, code: 'path_escape', message: `path escapes skill folder: ${relativePath}` };
|
||||
}
|
||||
// SKILL.md is the manifest — skill_use is the right tool to read it.
|
||||
if (basename(relativePath) === 'SKILL.md') {
|
||||
return { ok: false, code: 'unknown_resource', message: 'use skill_use to read SKILL.md' };
|
||||
}
|
||||
let real: string;
|
||||
try {
|
||||
real = await pathGuard(cached.path, relativePath);
|
||||
} catch (err) {
|
||||
if (err instanceof PathScopeError) {
|
||||
const code: SkillResourceErrorCode = err.message.includes('escapes')
|
||||
? 'path_escape'
|
||||
: 'unknown_resource';
|
||||
return { ok: false, code, message: err.message };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const stat = await fs.stat(real);
|
||||
if (!stat.isFile()) {
|
||||
return { ok: false, code: 'unknown_resource', message: 'not a file' };
|
||||
}
|
||||
if (stat.size > MAX_RESOURCE_BYTES) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'unknown_resource',
|
||||
message: `file too large (${stat.size} bytes, max ${MAX_RESOURCE_BYTES})`,
|
||||
};
|
||||
}
|
||||
const content = await fs.readFile(real, 'utf8');
|
||||
return { ok: true, content };
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { z } from 'zod';
|
||||
import { pathGuard, PathScopeError } from './path_guard.js';
|
||||
import { grep as fileOpsGrep, findFiles as fileOpsFindFiles } from './file_ops.js';
|
||||
import { getGitMeta } from './git_meta.js';
|
||||
import { findSkills, getSkillBody, getSkillResource } from './skills.js';
|
||||
|
||||
const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
||||
const DEFAULT_VIEW_LINES = 200;
|
||||
@@ -300,12 +301,195 @@ export const gitStatus: ToolDef<GitStatusInputT> = {
|
||||
},
|
||||
};
|
||||
|
||||
// Batch 9.6: skill_find, skill_use, skill_resource. Lazy-loaded markdown
|
||||
// playbooks at /data/skills/. Three tools rather than one to keep each call
|
||||
// cheap — the model lists, then loads, then optionally pulls support files.
|
||||
|
||||
const SkillFindInput = z.object({
|
||||
query: z.string().optional(),
|
||||
});
|
||||
type SkillFindInputT = z.infer<typeof SkillFindInput>;
|
||||
|
||||
export const skillFind: ToolDef<SkillFindInputT> = {
|
||||
name: 'skill_find',
|
||||
description:
|
||||
'Find skills (markdown playbooks under /data/skills) by name or description. Returns up to 5 matches. Empty query or "*" returns all available skills. Call this first to discover what skills are available.',
|
||||
inputSchema: SkillFindInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'skill_find',
|
||||
description:
|
||||
'Find skills by name or description. Returns up to 5 matches. Empty or "*" returns all.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string', description: 'substring matched against skill name and description' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(input) {
|
||||
return await findSkills(input.query ?? '');
|
||||
},
|
||||
};
|
||||
|
||||
const SkillUseInput = z.object({
|
||||
name: z.string().min(1),
|
||||
});
|
||||
type SkillUseInputT = z.infer<typeof SkillUseInput>;
|
||||
|
||||
export const skillUse: ToolDef<SkillUseInputT> = {
|
||||
name: 'skill_use',
|
||||
description:
|
||||
"Load the full body of a skill's SKILL.md by name. Returns the markdown playbook to follow. Discover names via skill_find. Errors: unknown_skill.",
|
||||
inputSchema: SkillUseInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'skill_use',
|
||||
description: "Load the full body of a skill's SKILL.md by name.",
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'skill name from skill_find' },
|
||||
},
|
||||
required: ['name'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(input) {
|
||||
const body = await getSkillBody(input.name);
|
||||
if (body === null) {
|
||||
return { error: 'unknown_skill', message: `unknown skill: ${input.name}` };
|
||||
}
|
||||
return { body };
|
||||
},
|
||||
};
|
||||
|
||||
const SkillResourceInput = z.object({
|
||||
name: z.string().min(1),
|
||||
path: z.string().min(1),
|
||||
});
|
||||
type SkillResourceInputT = z.infer<typeof SkillResourceInput>;
|
||||
|
||||
export const skillResource: ToolDef<SkillResourceInputT> = {
|
||||
name: 'skill_resource',
|
||||
description:
|
||||
"Read a support file inside a skill's folder (e.g. references/root-cause-tracing.md). Path is relative to the skill folder. Use skill_use to read SKILL.md itself. Errors: unknown_skill, unknown_resource, path_escape.",
|
||||
inputSchema: SkillResourceInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'skill_resource',
|
||||
description: "Read a support file inside a skill's folder. Path is relative to the skill folder.",
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'skill name' },
|
||||
path: { type: 'string', description: 'relative path under the skill folder' },
|
||||
},
|
||||
required: ['name', 'path'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(input) {
|
||||
const result = await getSkillResource(input.name, input.path);
|
||||
if (!result.ok) {
|
||||
return { error: result.code, message: result.message };
|
||||
}
|
||||
return { content: result.content };
|
||||
},
|
||||
};
|
||||
|
||||
// Batch 9.7: ask_user_input. Interactive elicitation. The model emits a tool
|
||||
// call with 1-3 structured questions; the inference loop PAUSES (does not
|
||||
// execute the tool server-side, does not recurse) and waits for the frontend
|
||||
// to POST /api/chats/:id/answer_user_input with the user's selections. See
|
||||
// routes/messages.ts for the resume path and services/inference.ts for the
|
||||
// pause branch in executeToolPhase.
|
||||
const AskUserInputInput = z.object({
|
||||
questions: z
|
||||
.array(
|
||||
z.object({
|
||||
question: z.string().min(1).max(200),
|
||||
type: z.enum(['single_select', 'multi_select']),
|
||||
options: z.array(z.string().min(1).max(80)).min(2).max(6),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(3),
|
||||
});
|
||||
type AskUserInputInputT = z.infer<typeof AskUserInputInput>;
|
||||
|
||||
export const askUserInput: ToolDef<AskUserInputInputT> = {
|
||||
name: 'ask_user_input',
|
||||
description:
|
||||
"Ask the user 1-3 structured questions through an inline picker UI. Use when you genuinely need a choice the user must make (e.g. scope, options, preferences) before continuing. Each question has 2-6 options and accepts free-text answers in addition. The tool call pauses the conversation until the user submits — the next assistant turn sees their answers as the tool result. Do not use for trivial yes/no clarifications you could infer; prefer it over multi-paragraph speculation about what the user might want.",
|
||||
inputSchema: AskUserInputInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'ask_user_input',
|
||||
description:
|
||||
'Ask the user 1-3 structured questions through an inline picker. Pauses the conversation until the user answers; the next turn sees their selections.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
questions: {
|
||||
type: 'array',
|
||||
minItems: 1,
|
||||
maxItems: 3,
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
question: { type: 'string', description: '<=200 chars, shown to the user' },
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['single_select', 'multi_select'],
|
||||
description: 'single_select = at most one option; multi_select = any subset',
|
||||
},
|
||||
options: {
|
||||
type: 'array',
|
||||
minItems: 2,
|
||||
maxItems: 6,
|
||||
items: { type: 'string' },
|
||||
description: '2-6 strings, each <=80 chars; free-text input is always available alongside',
|
||||
},
|
||||
},
|
||||
required: ['question', 'type', 'options'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['questions'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
// Server-side no-op. The "execution" of ask_user_input is the user's
|
||||
// response, captured client-side and posted to /api/chats/:id/answer_user_input.
|
||||
// The inference loop detects this tool by name and pauses before reaching
|
||||
// executeToolCall — this fallback only runs if something bypasses that
|
||||
// branch, in which case the pending sentinel matches the pause-path shape.
|
||||
async execute(input) {
|
||||
return { _pending: true, questions: input.questions };
|
||||
},
|
||||
};
|
||||
|
||||
export const ALL_TOOLS: ReadonlyArray<ToolDef<unknown>> = [
|
||||
viewFile as ToolDef<unknown>,
|
||||
listDir as ToolDef<unknown>,
|
||||
grep as ToolDef<unknown>,
|
||||
findFiles as ToolDef<unknown>,
|
||||
gitStatus as ToolDef<unknown>,
|
||||
skillFind as ToolDef<unknown>,
|
||||
skillUse as ToolDef<unknown>,
|
||||
skillResource as ToolDef<unknown>,
|
||||
askUserInput as ToolDef<unknown>,
|
||||
];
|
||||
|
||||
// v1.8.2: forward-compatible read-only whitelist. An agent whose `tools` is
|
||||
@@ -313,12 +497,19 @@ export const ALL_TOOLS: ReadonlyArray<ToolDef<unknown>> = [
|
||||
// anything outside means the agent can mutate state and gets a tighter
|
||||
// default (10). Every tool in v1.8.2 happens to be read-only, so the
|
||||
// non-RO branch only takes effect once BooCoder lands write tools.
|
||||
// Batch 9.6: skill_* added; all still read-only.
|
||||
// Batch 9.7: ask_user_input added — it pauses execution but doesn't mutate
|
||||
// project state, so it belongs in the read-only set for budget purposes.
|
||||
export const READ_ONLY_TOOL_NAMES = [
|
||||
'view_file',
|
||||
'list_dir',
|
||||
'grep',
|
||||
'find_files',
|
||||
'git_status',
|
||||
'skill_find',
|
||||
'skill_use',
|
||||
'skill_resource',
|
||||
'ask_user_input',
|
||||
] as const;
|
||||
|
||||
export const TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries(
|
||||
|
||||
@@ -10,6 +10,12 @@ export interface Project {
|
||||
last_session_id: string | null;
|
||||
status: ProjectStatus;
|
||||
gitea_remote: string | null;
|
||||
// v1.9: per-project defaults inherited by new sessions. Empty string on
|
||||
// default_system_prompt means "no override" — the model gets the base
|
||||
// BooCode system prompt only. default_web_search_enabled is the inherited
|
||||
// value for sessions where web_search_enabled is null.
|
||||
default_system_prompt: string;
|
||||
default_web_search_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface AvailableProject {
|
||||
@@ -29,6 +35,10 @@ export interface Session {
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
agent_id: string | null;
|
||||
// v1.9: per-session override for web_search. null = inherit from
|
||||
// project.default_web_search_enabled. Plumbed but inert in v1.9 — the
|
||||
// actual web_search tool ships in Batch 8.
|
||||
web_search_enabled: boolean | null;
|
||||
}
|
||||
|
||||
// v1.8.1: agents come from two sources. 'global' = /data/AGENTS.md (always
|
||||
|
||||
@@ -10,6 +10,8 @@ import type {
|
||||
ViewFileResult,
|
||||
AgentsResponse,
|
||||
GitMeta,
|
||||
Skill,
|
||||
AskUserAnswer,
|
||||
} from './types';
|
||||
|
||||
export class ApiError extends Error {
|
||||
@@ -51,15 +53,29 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
update: (id: string, body: { name: string }) =>
|
||||
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;
|
||||
@@ -106,7 +122,7 @@ export const api = {
|
||||
get: (id: string) => request<Session>(`/api/sessions/${id}`),
|
||||
update: (
|
||||
id: string,
|
||||
body: Partial<Pick<Session, 'name' | 'model' | 'system_prompt' | 'agent_id'>>
|
||||
body: Partial<Pick<Session, 'name' | 'model' | 'system_prompt' | 'agent_id' | 'web_search_enabled'>>
|
||||
) =>
|
||||
request<Session>(`/api/sessions/${id}`, {
|
||||
method: 'PATCH',
|
||||
@@ -118,6 +134,15 @@ export const api = {
|
||||
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`),
|
||||
},
|
||||
|
||||
chats: {
|
||||
@@ -164,6 +189,31 @@ export const api = {
|
||||
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 }),
|
||||
},
|
||||
),
|
||||
},
|
||||
|
||||
messages: {
|
||||
@@ -195,6 +245,10 @@ export const api = {
|
||||
request<AgentsResponse>(`/api/projects/${projectId}/agents`),
|
||||
},
|
||||
|
||||
skills: {
|
||||
list: () => request<{ skills: Skill[] }>('/api/skills'),
|
||||
},
|
||||
|
||||
settings: {
|
||||
get: () => request<Record<string, unknown>>('/api/settings'),
|
||||
patch: (body: Record<string, unknown>) =>
|
||||
|
||||
@@ -9,6 +9,10 @@ export interface Project {
|
||||
last_session_id: string | null;
|
||||
status: ProjectStatus;
|
||||
gitea_remote: string | null;
|
||||
// v1.9: per-project defaults. Empty string on default_system_prompt means
|
||||
// "no override" — inference falls through to the base system prompt.
|
||||
default_system_prompt: string;
|
||||
default_web_search_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface AvailableProject {
|
||||
@@ -28,6 +32,8 @@ export interface Session {
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
agent_id: string | null;
|
||||
// v1.9: null = inherit from project.default_web_search_enabled.
|
||||
web_search_enabled: boolean | null;
|
||||
}
|
||||
|
||||
// v1.8.1: 'global' = /data/AGENTS.md (always-on), 'project' = per-project
|
||||
@@ -225,7 +231,41 @@ export interface GitMeta {
|
||||
behind: number;
|
||||
}
|
||||
|
||||
export type WorkspacePaneKind = 'chat' | 'terminal' | 'agent' | 'empty';
|
||||
// Batch 9.6: skill catalog row. Returned by GET /api/skills and consumed by
|
||||
// the slash-command dropdown. `path` and `mtime` are exposed for debug surface
|
||||
// (/api/skills) but the dropdown only renders name + description.
|
||||
export interface Skill {
|
||||
name: string;
|
||||
description: string;
|
||||
path: string;
|
||||
mtime: number;
|
||||
}
|
||||
|
||||
// Batch 9.7: ask_user_input shapes. The tool_call.args is { questions: AskUserQuestion[] }
|
||||
// (1-3 entries); the eventual tool_result.output is { answers: AskUserAnswer[] } in the
|
||||
// same order. AskUserInputCard renders questions and POSTs answers.
|
||||
export type AskUserQuestionType = 'single_select' | 'multi_select';
|
||||
|
||||
export interface AskUserQuestion {
|
||||
question: string;
|
||||
type: AskUserQuestionType;
|
||||
options: string[];
|
||||
}
|
||||
|
||||
export interface AskUserAnswer {
|
||||
question: string;
|
||||
selected_options: string[];
|
||||
free_text: string | null;
|
||||
}
|
||||
|
||||
export interface AskUserAnswerSet {
|
||||
answers: AskUserAnswer[];
|
||||
}
|
||||
|
||||
// v1.9: 'settings' is an ephemeral pane kind — never persisted, always
|
||||
// singleton per workspace. The pane hook filters it out before writing to
|
||||
// localStorage and dedupes on insertion via toggleSettingsPane().
|
||||
export type WorkspacePaneKind = 'chat' | 'terminal' | 'agent' | 'empty' | 'settings';
|
||||
|
||||
export interface WorkspacePane {
|
||||
id: string;
|
||||
|
||||
324
apps/web/src/components/AskUserInputCard.tsx
Normal file
324
apps/web/src/components/AskUserInputCard.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api/client';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type {
|
||||
AskUserAnswer,
|
||||
AskUserAnswerSet,
|
||||
AskUserQuestion,
|
||||
ToolCall,
|
||||
ToolResult,
|
||||
} from '@/api/types';
|
||||
|
||||
// Batch 9.7. Inline interactive picker. Renders inside MessageList in place of
|
||||
// the standard ToolCallLine when the assistant emits an ask_user_input tool
|
||||
// call. While the tool result is null (server pre-stamps a sentinel with
|
||||
// output=null), shows the form; once the WS tool_result frame arrives with a
|
||||
// real AnswerSet, flips to read-only review mode.
|
||||
|
||||
interface Props {
|
||||
toolCall: ToolCall;
|
||||
toolResult: ToolResult | null;
|
||||
chatId: string;
|
||||
}
|
||||
|
||||
function parseQuestions(raw: unknown): AskUserQuestion[] {
|
||||
if (!raw || typeof raw !== 'object' || !('questions' in raw)) return [];
|
||||
const arr = (raw as { questions: unknown }).questions;
|
||||
if (!Array.isArray(arr)) return [];
|
||||
const out: AskUserQuestion[] = [];
|
||||
for (const item of arr) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const q = item as { question?: unknown; type?: unknown; options?: unknown };
|
||||
if (typeof q.question !== 'string') continue;
|
||||
if (q.type !== 'single_select' && q.type !== 'multi_select') continue;
|
||||
if (!Array.isArray(q.options)) continue;
|
||||
const opts = q.options.filter((o): o is string => typeof o === 'string');
|
||||
if (opts.length < 2) continue;
|
||||
out.push({ question: q.question, type: q.type, options: opts });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseAnswerSet(raw: unknown): AskUserAnswerSet | null {
|
||||
if (!raw || typeof raw !== 'object' || !('answers' in raw)) return null;
|
||||
const arr = (raw as { answers: unknown }).answers;
|
||||
if (!Array.isArray(arr)) return null;
|
||||
const answers: AskUserAnswer[] = [];
|
||||
for (const item of arr) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const a = item as { question?: unknown; selected_options?: unknown; free_text?: unknown };
|
||||
if (typeof a.question !== 'string') continue;
|
||||
if (!Array.isArray(a.selected_options)) continue;
|
||||
if (a.free_text !== null && typeof a.free_text !== 'string') continue;
|
||||
const sel = a.selected_options.filter((s): s is string => typeof s === 'string');
|
||||
answers.push({
|
||||
question: a.question,
|
||||
selected_options: sel,
|
||||
free_text: (a.free_text as string | null) ?? null,
|
||||
});
|
||||
}
|
||||
return { answers };
|
||||
}
|
||||
|
||||
export function AskUserInputCard({ toolCall, toolResult, chatId }: Props) {
|
||||
const questions = useMemo(() => parseQuestions(toolCall.args), [toolCall.args]);
|
||||
|
||||
if (questions.length === 0) {
|
||||
return (
|
||||
<div className="rounded border border-destructive/40 bg-destructive/10 text-xs px-3 py-2 text-destructive">
|
||||
ask_user_input: malformed tool args
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Tool result with a non-null output means the answer is already submitted.
|
||||
// The pending sentinel uses output=null, so this branch only triggers after
|
||||
// the real WS tool_result frame lands.
|
||||
const answered = toolResult && toolResult.output !== null;
|
||||
if (answered) {
|
||||
const answerSet = parseAnswerSet(toolResult!.output);
|
||||
return <AnsweredView questions={questions} answers={answerSet} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PendingView questions={questions} toolCallId={toolCall.id} chatId={chatId} />
|
||||
);
|
||||
}
|
||||
|
||||
function PendingView({
|
||||
questions,
|
||||
toolCallId,
|
||||
chatId,
|
||||
}: {
|
||||
questions: AskUserQuestion[];
|
||||
toolCallId: string;
|
||||
chatId: string;
|
||||
}) {
|
||||
// Per-question selections + free text. Selections are option arrays so the
|
||||
// multi_select case is uniform; single_select just constrains to length 1.
|
||||
const [selections, setSelections] = useState<string[][]>(() => questions.map(() => []));
|
||||
const [freeTexts, setFreeTexts] = useState<string[]>(() => questions.map(() => ''));
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const singleQuestion = questions.length === 1;
|
||||
const anyFreeText = freeTexts.some((t) => t.trim().length > 0);
|
||||
|
||||
// Submit button shows when:
|
||||
// - more than one question (always batched), OR
|
||||
// - one question and the user has typed free text (committing it needs an
|
||||
// explicit Submit so an accidental Tab/click doesn't lose it).
|
||||
// For one question with no free text, clicking an option submits inline.
|
||||
const showSubmitButton = !singleQuestion || anyFreeText;
|
||||
|
||||
// Every question must have at least one of (option, free text).
|
||||
const allComplete = questions.every((_, i) => {
|
||||
return selections[i]!.length > 0 || freeTexts[i]!.trim().length > 0;
|
||||
});
|
||||
|
||||
function buildAnswers(): AskUserAnswer[] {
|
||||
return questions.map((q, i) => {
|
||||
const freeText = freeTexts[i]!.trim();
|
||||
return {
|
||||
question: q.question,
|
||||
selected_options: selections[i]!,
|
||||
free_text: freeText.length > 0 ? freeText : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function submit(answers: AskUserAnswer[]) {
|
||||
if (submitting) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await api.chats.answerUserInput(chatId, toolCallId, answers);
|
||||
// Card stays mounted; the incoming WS tool_result frame will flip it
|
||||
// into AnsweredView via the parent prop change.
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'submit failed');
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function pickSingle(qIdx: number, option: string) {
|
||||
setSelections((prev) => prev.map((arr, i) => (i === qIdx ? [option] : arr)));
|
||||
// Immediate submit for the single-question single-select shortcut. Only
|
||||
// fires when no free text exists anywhere — once the user typed, the
|
||||
// Submit button takes over so the typed text isn't silently dropped.
|
||||
if (singleQuestion && !anyFreeText) {
|
||||
const answers: AskUserAnswer[] = [
|
||||
{
|
||||
question: questions[0]!.question,
|
||||
selected_options: [option],
|
||||
free_text: null,
|
||||
},
|
||||
];
|
||||
void submit(answers);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMulti(qIdx: number, option: string) {
|
||||
setSelections((prev) =>
|
||||
prev.map((arr, i) => {
|
||||
if (i !== qIdx) return arr;
|
||||
return arr.includes(option) ? arr.filter((o) => o !== option) : [...arr, option];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function setFreeText(qIdx: number, value: string) {
|
||||
setFreeTexts((prev) => prev.map((t, i) => (i === qIdx ? value : t)));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-muted/20 text-sm">
|
||||
<div className="px-4 py-3 space-y-4">
|
||||
{questions.map((q, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
{questions.length > 1 && (
|
||||
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
|
||||
Question {i + 1}
|
||||
</div>
|
||||
)}
|
||||
<div className="font-medium leading-snug">{q.question}</div>
|
||||
{q.type === 'single_select' ? (
|
||||
<RadioGroup
|
||||
value={selections[i]![0] ?? ''}
|
||||
onValueChange={(v) => pickSingle(i, v)}
|
||||
disabled={submitting}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{q.options.map((opt, j) => {
|
||||
const id = `q${i}-opt${j}`;
|
||||
return (
|
||||
<label
|
||||
key={j}
|
||||
htmlFor={id}
|
||||
className="flex items-start gap-2 text-sm leading-snug cursor-pointer rounded px-1 py-0.5 hover:bg-muted/40"
|
||||
>
|
||||
<RadioGroupItem id={id} value={opt} className="mt-0.5" />
|
||||
<span>{opt}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</RadioGroup>
|
||||
) : (
|
||||
<div className="grid gap-1.5">
|
||||
{q.options.map((opt, j) => {
|
||||
const id = `q${i}-opt${j}`;
|
||||
const checked = selections[i]!.includes(opt);
|
||||
return (
|
||||
<label
|
||||
key={j}
|
||||
htmlFor={id}
|
||||
className="flex items-start gap-2 text-sm leading-snug cursor-pointer rounded px-1 py-0.5 hover:bg-muted/40"
|
||||
>
|
||||
<input
|
||||
id={id}
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={submitting}
|
||||
onChange={() => toggleMulti(i, opt)}
|
||||
className="mt-1 size-3.5 rounded border-input accent-primary"
|
||||
/>
|
||||
<span>{opt}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="pt-1 space-y-1">
|
||||
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
|
||||
Or type a custom answer
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={freeTexts[i]}
|
||||
disabled={submitting}
|
||||
placeholder="Free text…"
|
||||
onChange={(e) => setFreeText(i, e.target.value)}
|
||||
className="w-full rounded border border-input bg-background px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:opacity-60"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{showSubmitButton && (
|
||||
<div className="flex justify-end gap-2 border-t px-4 py-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!allComplete || submitting}
|
||||
onClick={() => void submit(buildAnswers())}
|
||||
>
|
||||
{submitting ? 'Submitting…' : 'Submit'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AnsweredView({
|
||||
questions,
|
||||
answers,
|
||||
}: {
|
||||
questions: AskUserQuestion[];
|
||||
answers: AskUserAnswerSet | null;
|
||||
}) {
|
||||
if (!answers) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-muted/20 text-xs px-4 py-3 text-muted-foreground">
|
||||
ask_user_input: answers unavailable
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-muted/10 text-sm">
|
||||
<div className="px-4 py-3 space-y-3">
|
||||
{questions.map((q, i) => {
|
||||
const a = answers.answers[i];
|
||||
if (!a) return null;
|
||||
return (
|
||||
<div key={i} className="space-y-1.5">
|
||||
{questions.length > 1 && (
|
||||
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
|
||||
Question {i + 1}
|
||||
</div>
|
||||
)}
|
||||
<div className="font-medium leading-snug">{q.question}</div>
|
||||
<div className="space-y-0.5">
|
||||
{q.options.map((opt, j) => {
|
||||
const selected = a.selected_options.includes(opt);
|
||||
return (
|
||||
<div
|
||||
key={j}
|
||||
className={
|
||||
selected
|
||||
? 'flex items-start gap-2 text-sm leading-snug text-foreground'
|
||||
: 'flex items-start gap-2 text-sm leading-snug text-muted-foreground/60 line-through'
|
||||
}
|
||||
>
|
||||
<span className="mt-0.5 size-3.5 shrink-0 inline-flex items-center justify-center">
|
||||
{selected && <Check className="size-3 text-primary" />}
|
||||
</span>
|
||||
<span>{opt}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{a.free_text && (
|
||||
<div className="rounded bg-background border px-2 py-1 text-xs font-mono whitespace-pre-wrap">
|
||||
{a.free_text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
import { useCallback, useEffect, useRef, useState, type DragEvent, type KeyboardEvent } from 'react';
|
||||
import { Send } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type KeyboardEvent } from 'react';
|
||||
import { Check, Plus, Send } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
flattenToMessage,
|
||||
inferLanguage,
|
||||
@@ -16,8 +22,10 @@ import { AttachmentPreviewModal } from '@/components/AttachmentPreviewModal';
|
||||
import { FileMentionPopover } from '@/components/FileMentionPopover';
|
||||
import { DropOverlay } from '@/components/DropOverlay';
|
||||
import { AgentPicker } from '@/components/AgentPicker';
|
||||
import { SkillSlashCommand } from '@/components/SkillSlashCommand';
|
||||
import { api } from '@/api/client';
|
||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||
import { useSkills } from '@/hooks/useSkills';
|
||||
import { useViewport } from '@/hooks/useViewport';
|
||||
|
||||
const MAX_ATTACHMENTS = 10;
|
||||
@@ -29,11 +37,23 @@ interface Props {
|
||||
// When omitted, the toolbar row is hidden entirely.
|
||||
agentId?: string | null;
|
||||
onAgentChange?: (agentId: string | null) => void | Promise<void>;
|
||||
// v1.9: when sessionId + webSearchEnabled are both provided, the + menu
|
||||
// renders next to the AgentPicker with a single "Web search" toggle item.
|
||||
// The check reflects the *stored* session value (not the effective one):
|
||||
// null counts as unchecked. Clicking PATCHes session.web_search_enabled
|
||||
// with the inverted boolean (null → true, true → false, false → true).
|
||||
sessionId?: string;
|
||||
webSearchEnabled?: boolean | null;
|
||||
onSend: (content: string) => void | Promise<void>;
|
||||
onForceSend?: (content: string) => void | Promise<void>;
|
||||
// Batch 9.6: slash-command dispatch. When the input parses to a known skill,
|
||||
// ChatInput calls this with the skill name + the post-name args (possibly
|
||||
// empty). Callers wire this to api.chats.skillInvoke. Omitting the prop
|
||||
// disables slash-command dispatch (input is sent as literal text).
|
||||
onSlashCommand?: (skillName: string, userMessage: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function ChatInput({ disabled, projectId, agentId, onAgentChange, onSend, onForceSend }: Props) {
|
||||
export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, onSlashCommand }: Props) {
|
||||
const { isMobile } = useViewport();
|
||||
const [value, setValue] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -48,6 +68,19 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, onSend,
|
||||
atIdx: number;
|
||||
anchorRect: { top: number; left: number };
|
||||
} | null>(null);
|
||||
// Batch 9.6: slash-command dropdown. Opens when `/` is the first char of
|
||||
// the input and stays open while the input is `/<word>` with no whitespace.
|
||||
// Disabled entirely when the caller doesn't pass onSlashCommand.
|
||||
const [slashState, setSlashState] = useState<{
|
||||
query: string;
|
||||
anchorRect: { top: number; left: number };
|
||||
} | null>(null);
|
||||
const { skills } = useSkills();
|
||||
const skillsLookup = useMemo(() => {
|
||||
const m = new Map<string, true>();
|
||||
for (const s of skills) m.set(s.name, true);
|
||||
return m;
|
||||
}, [skills]);
|
||||
const [fileIndex, setFileIndex] = useState<string[] | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
@@ -82,6 +115,31 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, onSend,
|
||||
const text = value.trim();
|
||||
if (!text && attachments.length === 0) return;
|
||||
if (disabled || busy) return;
|
||||
|
||||
// Batch 9.6: slash-command dispatch. Only when no attachments and the
|
||||
// input parses to a known skill. Falls through to onSend for unknown
|
||||
// slash names (literal text) or when slash dispatch isn't wired.
|
||||
if (onSlashCommand && attachments.length === 0 && text.startsWith('/')) {
|
||||
const match = text.match(/^\/(\S+)\s*([\s\S]*)$/);
|
||||
if (match && skillsLookup.has(match[1]!)) {
|
||||
const skillName = match[1]!;
|
||||
const args = (match[2] ?? '').trim();
|
||||
setBusy(true);
|
||||
try {
|
||||
await onSlashCommand(skillName, args);
|
||||
setValue('');
|
||||
setAttachments([]);
|
||||
setSlashState(null);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'skill invocation failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Unknown skill name — fall through and send as literal text.
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
const body = flattenToMessage(attachments, text);
|
||||
@@ -95,6 +153,19 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, onSend,
|
||||
}
|
||||
}
|
||||
|
||||
function handleSlashSelect(skillName: string) {
|
||||
const next = `/${skillName} `;
|
||||
setValue(next);
|
||||
setSlashState(null);
|
||||
requestAnimationFrame(() => {
|
||||
const ta = textareaRef.current;
|
||||
if (ta) {
|
||||
ta.selectionStart = ta.selectionEnd = next.length;
|
||||
ta.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getCaretCoords(textarea: HTMLTextAreaElement): { top: number; left: number } {
|
||||
const mirror = document.createElement('div');
|
||||
const style = window.getComputedStyle(textarea);
|
||||
@@ -145,6 +216,23 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, onSend,
|
||||
const ta = e.target;
|
||||
const pos = ta.selectionStart;
|
||||
|
||||
// Batch 9.6: slash-command trigger. Active while the input is a single
|
||||
// slash-prefixed token with no whitespace (i.e. user is still typing the
|
||||
// skill name). Hand off to args mode the moment a space appears or the
|
||||
// slash leaves position 0.
|
||||
if (onSlashCommand && /^\/[^\s]*$/.test(newValue)) {
|
||||
const query = newValue.slice(1);
|
||||
if (!slashState) {
|
||||
const rect = ta.getBoundingClientRect();
|
||||
setSlashState({ query, anchorRect: { top: rect.top, left: rect.left } });
|
||||
} else if (slashState.query !== query) {
|
||||
setSlashState({ ...slashState, query });
|
||||
}
|
||||
if (mentionState?.open) setMentionState(null);
|
||||
return;
|
||||
}
|
||||
if (slashState) setSlashState(null);
|
||||
|
||||
// Check for @ trigger
|
||||
if (pos > 0 && newValue[pos - 1] === '@') {
|
||||
const charBefore = pos >= 2 ? newValue[pos - 2] : null;
|
||||
@@ -361,6 +449,9 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, onSend,
|
||||
|
||||
function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (mentionState?.open) return;
|
||||
// SkillSlashCommand owns Arrow/Enter/Tab/Esc via a document listener; let
|
||||
// it consume them so the textarea doesn't also submit on Enter.
|
||||
if (slashState) return;
|
||||
// IME safety: never act on Enter while an IME composition is in flight
|
||||
// (CJK input methods commit composition via Enter). Without this, the
|
||||
// first Enter of a Japanese/Chinese/Korean composition would submit
|
||||
@@ -425,16 +516,51 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, onSend,
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Batch 9 toolbar — agent picker. Sits above the input row so it
|
||||
doesn't compete with the send button for vertical alignment.
|
||||
When Batch 7 lands, ModelPicker and the + button join this row. */}
|
||||
{onAgentChange && (
|
||||
{/* Batch 9 toolbar — agent picker. v1.9 adds the icon-only + menu next
|
||||
to it for quick toggles (currently: Web search). When omitted at the
|
||||
callsite the row stays collapsed so nothing else has to change. */}
|
||||
{(onAgentChange || sessionId) && (
|
||||
<div className="px-4 pt-2 flex items-center gap-1.5">
|
||||
<AgentPicker
|
||||
projectId={projectId}
|
||||
value={agentId ?? null}
|
||||
onChange={onAgentChange}
|
||||
/>
|
||||
{onAgentChange && (
|
||||
<AgentPicker
|
||||
projectId={projectId}
|
||||
value={agentId ?? null}
|
||||
onChange={onAgentChange}
|
||||
/>
|
||||
)}
|
||||
{sessionId && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Quick toggles"
|
||||
title="Quick toggles"
|
||||
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem
|
||||
onSelect={async () => {
|
||||
// v1.9: tri-state collapses to two on the wire when toggled
|
||||
// here. null (inherit) treated as off; click flips to true.
|
||||
// To restore "inherit" the user opens SettingsPane.
|
||||
const next = webSearchEnabled === true ? false : true;
|
||||
try {
|
||||
await api.sessions.update(sessionId, { web_search_enabled: next });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'failed to toggle web search');
|
||||
}
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check className={`size-3 ${webSearchEnabled === true ? 'opacity-100' : 'opacity-0'}`} />
|
||||
Web search
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="px-4 py-3 flex items-end gap-2">
|
||||
@@ -476,6 +602,15 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, onSend,
|
||||
onClose={closeMention}
|
||||
/>
|
||||
)}
|
||||
{slashState && (
|
||||
<SkillSlashCommand
|
||||
query={slashState.query}
|
||||
skills={skills}
|
||||
anchorRect={slashState.anchorRect}
|
||||
onSelect={handleSlashSelect}
|
||||
onClose={() => setSlashState(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Chat, Message } from '@/api/types';
|
||||
import { MessageBubble } from './MessageBubble';
|
||||
import { ToolCallGroup } from './ToolCallGroup';
|
||||
import { ToolCallLine, type ToolRun } from './ToolCallLine';
|
||||
import { AskUserInputCard } from './AskUserInputCard';
|
||||
|
||||
interface Props {
|
||||
messages: Message[];
|
||||
@@ -12,9 +13,11 @@ interface Props {
|
||||
// v1.8.2: pre-render units. The single linear `messages` array gets walked
|
||||
// into a render-time list where each tool_call is a first-class item and
|
||||
// tool_result messages are folded onto their matching tool_run by id.
|
||||
// Batch 9.7: tool_run carries chat_id so AskUserInputCard can post the
|
||||
// answer without threading the chat id through MessageList's parent.
|
||||
type RenderItem =
|
||||
| { kind: 'message'; message: Message; capHitInfo?: { position: number; isLatest: boolean } }
|
||||
| { kind: 'tool_run'; run: ToolRun; key: string }
|
||||
| { kind: 'tool_run'; run: ToolRun; key: string; chatId: string }
|
||||
| { kind: 'tool_group'; runs: ToolRun[]; key: string };
|
||||
|
||||
const GROUP_THRESHOLD = 3;
|
||||
@@ -50,7 +53,7 @@ function flatten(messages: Message[]): RenderItem[] {
|
||||
for (const tc of m.tool_calls!) {
|
||||
const run: ToolRun = { call: tc, result: null };
|
||||
runsByCallId.set(tc.id, run);
|
||||
items.push({ kind: 'tool_run', run, key: tc.id });
|
||||
items.push({ kind: 'tool_run', run, key: tc.id, chatId: m.chat_id });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -63,6 +66,9 @@ function flatten(messages: Message[]): RenderItem[] {
|
||||
// Second pass: collapse runs of >=GROUP_THRESHOLD consecutive tool_run items
|
||||
// of the same tool name into a single tool_group. Any other render item
|
||||
// (text bubble, sentinel, user message) breaks the chain.
|
||||
// Batch 9.7: ask_user_input never groups — each pause has its own card so
|
||||
// grouping would render them as collapsed ToolCallLines which can't surface
|
||||
// the interactive form.
|
||||
function group(items: RenderItem[]): RenderItem[] {
|
||||
const out: RenderItem[] = [];
|
||||
let i = 0;
|
||||
@@ -74,6 +80,11 @@ function group(items: RenderItem[]): RenderItem[] {
|
||||
continue;
|
||||
}
|
||||
const name = item.run.call.name;
|
||||
if (name === 'ask_user_input') {
|
||||
out.push(item);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let j = i + 1;
|
||||
while (
|
||||
j < items.length &&
|
||||
@@ -82,7 +93,12 @@ function group(items: RenderItem[]): RenderItem[] {
|
||||
) {
|
||||
j += 1;
|
||||
}
|
||||
const run = items.slice(i, j) as Array<{ kind: 'tool_run'; run: ToolRun; key: string }>;
|
||||
const run = items.slice(i, j) as Array<{
|
||||
kind: 'tool_run';
|
||||
run: ToolRun;
|
||||
key: string;
|
||||
chatId: string;
|
||||
}>;
|
||||
if (run.length >= GROUP_THRESHOLD) {
|
||||
out.push({
|
||||
kind: 'tool_group',
|
||||
@@ -150,6 +166,16 @@ export function MessageList({ messages, sessionChats }: Props) {
|
||||
);
|
||||
}
|
||||
if (item.kind === 'tool_run') {
|
||||
if (item.run.call.name === 'ask_user_input') {
|
||||
return (
|
||||
<AskUserInputCard
|
||||
key={item.key}
|
||||
toolCall={item.run.call}
|
||||
toolResult={item.run.result}
|
||||
chatId={item.chatId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <ToolCallLine key={item.key} run={item.run} />;
|
||||
}
|
||||
return <ToolCallGroup key={item.key} runs={item.runs} />;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Edit2,
|
||||
MessageSquare,
|
||||
MoreHorizontal,
|
||||
Settings as SettingsIcon,
|
||||
Terminal,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
@@ -33,6 +34,7 @@ interface Props {
|
||||
function paneIcon(kind: WorkspacePane['kind']) {
|
||||
if (kind === 'terminal') return <Terminal size={14} />;
|
||||
if (kind === 'agent') return <Bot size={14} />;
|
||||
if (kind === 'settings') return <SettingsIcon size={14} />;
|
||||
return <MessageSquare size={14} />;
|
||||
}
|
||||
|
||||
@@ -53,6 +55,7 @@ function paneLabel(pane: WorkspacePane, chats: Chat[]): string {
|
||||
if (pane.kind === 'chat') return 'Chat';
|
||||
if (pane.kind === 'terminal') return 'Terminal';
|
||||
if (pane.kind === 'agent') return 'Agent';
|
||||
if (pane.kind === 'settings') return 'Settings';
|
||||
return 'Empty';
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Check, ChevronDown } from 'lucide-react';
|
||||
import { Check, ChevronDown, Cpu } from 'lucide-react';
|
||||
import { api } from '@/api/client';
|
||||
import type { ModelInfo } from '@/api/types';
|
||||
import {
|
||||
@@ -8,26 +8,94 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { BottomSheet } from '@/components/BottomSheet';
|
||||
import { useViewport } from '@/hooks/useViewport';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (model: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
// v1.9: shared list rendered inside both shells. Lazy-fetches /api/models on
|
||||
// first open so the picker doesn't pay for a request when it's never shown.
|
||||
function ModelList({
|
||||
models,
|
||||
error,
|
||||
value,
|
||||
onPick,
|
||||
}: {
|
||||
models: ModelInfo[] | null;
|
||||
error: string | null;
|
||||
value: string;
|
||||
onPick: (id: string) => void;
|
||||
}) {
|
||||
if (error) {
|
||||
return <div className="px-2 py-1.5 text-xs text-destructive">{error}</div>;
|
||||
}
|
||||
if (models === null) {
|
||||
return <div className="px-2 py-1.5 text-xs text-muted-foreground">Loading…</div>;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{models.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => onPick(m.id)}
|
||||
className="w-full text-left flex items-center gap-2 font-mono text-xs px-2 py-1.5 hover:bg-accent rounded"
|
||||
>
|
||||
<Check className={`size-3 ${m.id === value ? 'opacity-100' : 'opacity-0'}`} />
|
||||
<span className="truncate">{m.id}</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModelPicker({ value, onChange }: Props) {
|
||||
const { isMobile } = useViewport();
|
||||
const [models, setModels] = useState<ModelInfo[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || models !== null) return;
|
||||
api.models()
|
||||
api
|
||||
.models()
|
||||
.then(setModels)
|
||||
.catch((err) =>
|
||||
setError(err instanceof Error ? err.message : 'failed to load models')
|
||||
setError(err instanceof Error ? err.message : 'failed to load models'),
|
||||
);
|
||||
}, [open, models]);
|
||||
|
||||
function handlePick(id: string) {
|
||||
setOpen(false);
|
||||
void onChange(id);
|
||||
}
|
||||
|
||||
// v1.9: mobile = icon-only trigger + bottom-sheet shell. Desktop = labeled
|
||||
// trigger (model name + chevron) + dropdown. Same ModelList under the hood.
|
||||
if (isMobile) {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label={`Model: ${value}`}
|
||||
title={value}
|
||||
className="inline-flex items-center justify-center min-h-[44px] min-w-[44px] rounded text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Cpu className="size-4" />
|
||||
</button>
|
||||
<BottomSheet open={open} onClose={() => setOpen(false)} title="Model">
|
||||
<div className="px-2 py-2 space-y-1">
|
||||
<ModelList models={models} error={error} value={value} onPick={handlePick} />
|
||||
</div>
|
||||
</BottomSheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -49,7 +117,7 @@ export function ModelPicker({ value, onChange }: Props) {
|
||||
{models?.map((m) => (
|
||||
<DropdownMenuItem
|
||||
key={m.id}
|
||||
onSelect={() => void onChange(m.id)}
|
||||
onSelect={() => handlePick(m.id)}
|
||||
className="font-mono text-xs"
|
||||
>
|
||||
<Check
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { ChevronRight, ExternalLink, Folder, MessageSquare, Plus } from 'lucide-react';
|
||||
import { ChevronRight, ExternalLink, Folder, MessageSquare, Plus, Settings as SettingsIcon } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
@@ -198,7 +199,7 @@ export function ProjectSidebar() {
|
||||
const rowCls = (active: boolean) =>
|
||||
active ? 'bg-sidebar-accent text-sidebar-accent-foreground' : 'hover:bg-sidebar-accent/60';
|
||||
|
||||
const { open: drawerOpen } = useSidebarDrawer();
|
||||
const { open: drawerOpen, setOpen: setDrawerOpen } = useSidebarDrawer();
|
||||
const { isMobile } = useViewport();
|
||||
const pull = usePullToRefresh(() => retry(), { enabled: isMobile });
|
||||
|
||||
@@ -412,6 +413,30 @@ export function ProjectSidebar() {
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* v1.9: bottom-pinned Settings button. In a session, opens/focuses the
|
||||
workspace settings pane via the sessionEvents bus (Session.tsx owns
|
||||
the panesHook). Outside a session there's no workspace to mount the
|
||||
pane in, so we navigate to /settings (themes page) instead. */}
|
||||
<div className="border-t shrink-0 p-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (activeSession) {
|
||||
sessionEvents.emit({ type: 'open_settings_pane' });
|
||||
if (isMobile) setDrawerOpen(false);
|
||||
} else {
|
||||
navigate('/settings');
|
||||
if (isMobile) setDrawerOpen(false);
|
||||
}
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm hover:bg-sidebar-accent/60 text-sidebar-foreground"
|
||||
aria-label="Settings"
|
||||
>
|
||||
<SettingsIcon className="size-3.5 shrink-0 opacity-70" />
|
||||
<span className="flex-1 text-left">Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AddProjectModal open={addOpen} onOpenChange={setAddOpen} onAdded={() => {}} />
|
||||
|
||||
<Dialog open={archiveProjectConfirm !== null} onOpenChange={(open) => { if (!open) setArchiveProjectConfirm(null); }}>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Archive, MessageSquare, Send, ChevronDown, ChevronRight, RotateCcw, Tra
|
||||
import type { Chat } from '@/api/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
@@ -165,7 +164,6 @@ export function SessionLandingPage({
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
const [archiveConfirm, setArchiveConfirm] = useState<Chat | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<Chat | null>(null);
|
||||
const [deleteInput, setDeleteInput] = useState('');
|
||||
|
||||
const openChats = chats
|
||||
.filter((c) => c.status === 'open')
|
||||
@@ -193,9 +191,6 @@ export function SessionLandingPage({
|
||||
setRenamingId(null);
|
||||
}
|
||||
|
||||
const deleteExpected = deleteConfirm?.name ?? '';
|
||||
const deleteEnabled = deleteConfirm !== null && deleteInput === deleteExpected && deleteExpected.length > 0;
|
||||
|
||||
// TODO: Landing page chat counts are a snapshot at mount. New messages in
|
||||
// visible chats won't update the per-row stats until next mount/navigation.
|
||||
return (
|
||||
@@ -217,7 +212,7 @@ export function SessionLandingPage({
|
||||
onCancelRename={() => setRenamingId(null)}
|
||||
onContextStartRename={() => startRename(chat)}
|
||||
onContextArchive={() => setArchiveConfirm(chat)}
|
||||
onContextDelete={() => { setDeleteConfirm(chat); setDeleteInput(''); }}
|
||||
onContextDelete={() => setDeleteConfirm(chat)}
|
||||
showContextMenu
|
||||
actions={
|
||||
<>
|
||||
@@ -242,7 +237,6 @@ export function SessionLandingPage({
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeleteConfirm(chat);
|
||||
setDeleteInput('');
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
@@ -352,36 +346,25 @@ export function SessionLandingPage({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={deleteConfirm !== null} onOpenChange={(open) => { if (!open) { setDeleteConfirm(null); setDeleteInput(''); } }}>
|
||||
<Dialog open={deleteConfirm !== null} onOpenChange={(open) => { if (!open) setDeleteConfirm(null); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete chat?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Type the chat name to confirm:
|
||||
{' '}
|
||||
<span className="font-mono font-medium text-foreground">{deleteExpected || '(unnamed — cannot type-confirm)'}</span>
|
||||
Permanently delete{' '}
|
||||
<span className="font-mono font-medium text-foreground">{deleteConfirm?.name || '(unnamed)'}</span>
|
||||
{' '}and all its messages. This cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
value={deleteInput}
|
||||
onChange={(e) => setDeleteInput(e.target.value)}
|
||||
placeholder={deleteExpected}
|
||||
disabled={!deleteExpected}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
This will permanently delete this chat and all its messages. This cannot be undone.
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end pt-2">
|
||||
<Button variant="outline" onClick={() => { setDeleteConfirm(null); setDeleteInput(''); }}>
|
||||
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={!deleteEnabled}
|
||||
onClick={() => {
|
||||
if (deleteConfirm && deleteEnabled) void onDeleteChat(deleteConfirm.id);
|
||||
if (deleteConfirm) void onDeleteChat(deleteConfirm.id);
|
||||
setDeleteConfirm(null);
|
||||
setDeleteInput('');
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
|
||||
137
apps/web/src/components/SkillSlashCommand.tsx
Normal file
137
apps/web/src/components/SkillSlashCommand.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Skill } from '@/api/types';
|
||||
|
||||
interface Props {
|
||||
query: string;
|
||||
skills: Skill[];
|
||||
anchorRect: { top: number; left: number };
|
||||
onSelect: (skillName: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// Batch 9.6: slash-command dropdown. Models FileMentionPopover's pattern —
|
||||
// fixed-positioned popover, keyboard nav, click-outside-to-close. shadcn
|
||||
// `Command` (cmdk) isn't installed in this project; per the addendum we use
|
||||
// a plain div + Tailwind instead of pulling a new primitive autonomously.
|
||||
|
||||
// Case-insensitive prefix match on `name` only. Description is display-only
|
||||
// in v1 (substring search across description is deferred to a polish batch).
|
||||
function filterByPrefix(skills: Skill[], query: string): Skill[] {
|
||||
const q = query.toLowerCase();
|
||||
const filtered = q
|
||||
? skills.filter((s) => s.name.toLowerCase().startsWith(q))
|
||||
: skills;
|
||||
// Stable alphabetical ordering matches the server's cache order (skills.ts
|
||||
// sorts on name asc) but we re-sort here so a stale client cache doesn't
|
||||
// surprise the user.
|
||||
return [...filtered].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
export function SkillSlashCommand({ query, skills, anchorRect, onSelect, onClose }: Props) {
|
||||
const [highlightIndex, setHighlightIndex] = useState(0);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const filtered = useMemo(() => filterByPrefix(skills, query), [skills, query]);
|
||||
|
||||
useEffect(() => { setHighlightIndex(0); }, [query]);
|
||||
|
||||
// Arrow / Enter / Tab / Escape. Bound on document so keystrokes from the
|
||||
// textarea reach the popover even though focus stays in the textarea.
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setHighlightIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : 0));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setHighlightIndex((prev) => (prev > 0 ? prev - 1 : filtered.length - 1));
|
||||
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
if (filtered.length === 0) return;
|
||||
e.preventDefault();
|
||||
const target = filtered[highlightIndex] ?? filtered[0];
|
||||
if (target) onSelect(target.name);
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [filtered, highlightIndex, onSelect, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleMouseDown);
|
||||
return () => document.removeEventListener('mousedown', handleMouseDown);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = popoverRef.current?.querySelector('[data-highlighted="true"]');
|
||||
if (el) el.scrollIntoView({ block: 'nearest' });
|
||||
}, [highlightIndex]);
|
||||
|
||||
// Anchor sits above the input — translate(-100%) on Y so the dropdown
|
||||
// expands upward from the anchor point rather than over the textarea.
|
||||
const style = {
|
||||
top: anchorRect.top,
|
||||
left: anchorRect.left,
|
||||
transform: 'translateY(-100%)',
|
||||
} as const;
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="fixed z-50 bg-popover border border-border rounded-md shadow min-w-[320px] p-2"
|
||||
style={style}
|
||||
>
|
||||
<div className="text-xs text-muted-foreground px-2 py-1">
|
||||
{query ? `No skill starts with "/${query}"` : 'No skills available'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="fixed z-50 bg-popover border border-border rounded-md shadow min-w-[320px] max-w-[420px] max-h-[320px] overflow-y-auto"
|
||||
style={style}
|
||||
>
|
||||
{filtered.map((skill, i) => (
|
||||
<button
|
||||
key={skill.name}
|
||||
type="button"
|
||||
data-highlighted={i === highlightIndex}
|
||||
className={cn(
|
||||
'w-full text-left px-2.5 py-2 cursor-pointer block',
|
||||
i === highlightIndex && 'bg-muted',
|
||||
)}
|
||||
onMouseEnter={() => setHighlightIndex(i)}
|
||||
onMouseDown={(e) => {
|
||||
// mousedown not click — click runs after blur/focus shuffles which
|
||||
// can race with the textarea's onBlur close path.
|
||||
e.preventDefault();
|
||||
onSelect(skill.name);
|
||||
}}
|
||||
>
|
||||
<div className="font-mono text-xs font-bold text-foreground">/{skill.name}</div>
|
||||
<div
|
||||
className="text-xs text-muted-foreground overflow-hidden"
|
||||
style={{
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}
|
||||
>
|
||||
{skill.description}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
122
apps/web/src/components/ThemePicker.tsx
Normal file
122
apps/web/src/components/ThemePicker.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { useState } from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { THEMES, setTheme, useTheme, type ThemeId, type ThemeMode } from '@/lib/theme';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// v1.9: lifted out of pages/Settings.tsx so the SettingsPane Theme tab and
|
||||
// the standalone /settings route render the same picker. Theme is global —
|
||||
// not per-project, not per-session — so no contextual props are needed.
|
||||
|
||||
const MODES: { value: ThemeMode; label: string; hint: string }[] = [
|
||||
{ value: 'dark', label: 'Dark', hint: 'Use the dark variant.' },
|
||||
{ value: 'light', label: 'Light', hint: 'Use the light variant.' },
|
||||
{ value: 'system', label: 'System', hint: 'Follow OS preference.' },
|
||||
];
|
||||
|
||||
export function ThemePicker() {
|
||||
const { id: currentId, mode: currentMode } = useTheme();
|
||||
// Track the most recent in-flight pick so the picker can show a subtle
|
||||
// "applying…" state on the targeted card while the PATCH is in flight.
|
||||
const [pending, setPending] = useState<
|
||||
{ kind: 'theme'; id: ThemeId } | { kind: 'mode'; mode: ThemeMode } | null
|
||||
>(null);
|
||||
|
||||
async function pickTheme(id: ThemeId) {
|
||||
if (id === currentId || pending) return;
|
||||
setPending({ kind: 'theme', id });
|
||||
try {
|
||||
await setTheme(id, currentMode);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'failed to apply theme');
|
||||
} finally {
|
||||
setPending(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function pickMode(mode: ThemeMode) {
|
||||
if (mode === currentMode || pending) return;
|
||||
setPending({ kind: 'mode', mode });
|
||||
try {
|
||||
await setTheme(currentId, mode);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'failed to apply mode');
|
||||
} finally {
|
||||
setPending(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-medium">Mode</h2>
|
||||
<RadioGroup
|
||||
value={currentMode}
|
||||
onValueChange={(v) => void pickMode(v as ThemeMode)}
|
||||
className="flex flex-wrap gap-4"
|
||||
>
|
||||
{MODES.map((m) => (
|
||||
<div key={m.value} className="flex items-center gap-2">
|
||||
<RadioGroupItem id={`mode-${m.value}`} value={m.value} />
|
||||
<Label htmlFor={`mode-${m.value}`} className="cursor-pointer">
|
||||
<span className="font-medium">{m.label}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{m.hint}</span>
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-medium">Theme</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{THEMES.map((t) => {
|
||||
const isActive = t.id === currentId;
|
||||
const isPending = pending?.kind === 'theme' && pending.id === t.id;
|
||||
const isLightOnly = !t.supportsDark;
|
||||
return (
|
||||
<Card
|
||||
key={t.id}
|
||||
onClick={() => void pickTheme(t.id)}
|
||||
className={cn(
|
||||
'p-3 cursor-pointer transition-colors',
|
||||
'hover:bg-accent/10',
|
||||
isActive && 'ring-2 ring-ring',
|
||||
isPending && 'opacity-60',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="font-mono text-sm truncate">{t.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{t.family}</div>
|
||||
</div>
|
||||
{isActive && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-primary shrink-0">
|
||||
<Check className="size-3" /> Selected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex mt-2 rounded overflow-hidden border border-border/40">
|
||||
{t.anchors.map((hex, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 h-6"
|
||||
style={{ backgroundColor: hex }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{isLightOnly && (
|
||||
<div className="mt-2 text-xs text-muted-foreground italic">Light only</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { PanelRight, MessageSquare, Terminal, Bot } from 'lucide-react';
|
||||
import type { Chat, WorkspacePane } from '@/api/types';
|
||||
import type { Chat, Project, Session, WorkspacePane } from '@/api/types';
|
||||
import { MAX_PANES, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes';
|
||||
import type { UseSessionChatsResult } from '@/hooks/useSessionChats';
|
||||
import { useViewport } from '@/hooks/useViewport';
|
||||
import { ChatPane } from '@/components/panes/ChatPane';
|
||||
import { SettingsPane } from '@/components/panes/SettingsPane';
|
||||
import { ChatTabBar } from '@/components/ChatTabBar';
|
||||
import { SessionLandingPage } from '@/components/SessionLandingPage';
|
||||
import {
|
||||
@@ -24,6 +26,9 @@ interface Props {
|
||||
// (MobileTabSwitcher) can share state with the pane grid.
|
||||
panesHook: UseWorkspacePanesResult;
|
||||
chatsHook: UseSessionChatsResult;
|
||||
// v1.9: passed through to SettingsPane when one is mounted in the grid.
|
||||
session: Session;
|
||||
project: Project | null;
|
||||
}
|
||||
|
||||
export function Workspace({
|
||||
@@ -33,6 +38,8 @@ export function Workspace({
|
||||
onAgentChange,
|
||||
panesHook,
|
||||
chatsHook,
|
||||
session,
|
||||
project,
|
||||
}: Props) {
|
||||
const {
|
||||
panes,
|
||||
@@ -67,6 +74,41 @@ export function Workspace({
|
||||
|
||||
const { isMobile } = useViewport();
|
||||
|
||||
// v1.9: workspace-level maximize state for the settings pane. CSS-only:
|
||||
// sibling panes get display:none, the maximized pane fills the grid cell.
|
||||
// ESC listener only mounted while maximized. Mobile is always full-width
|
||||
// for a single pane so maximize doesn't apply.
|
||||
const [maximized, setMaximized] = useState(false);
|
||||
const settingsIdx = panes.findIndex((p) => p.kind === 'settings');
|
||||
|
||||
// Esc semantics: maximized → restore; otherwise → close settings pane (only
|
||||
// when it's the active pane). Bail when the user is typing in a field or
|
||||
// inside an open dialog so we don't eat their cancel keystroke.
|
||||
useEffect(() => {
|
||||
if (settingsIdx < 0) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key !== 'Escape') return;
|
||||
const t = e.target;
|
||||
if (t instanceof HTMLElement) {
|
||||
if (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable) return;
|
||||
if (t.closest('[role="dialog"]')) return;
|
||||
}
|
||||
if (maximized) {
|
||||
setMaximized(false);
|
||||
} else if (activePaneIdx === settingsIdx) {
|
||||
removePane(settingsIdx);
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [maximized, settingsIdx, activePaneIdx, removePane]);
|
||||
|
||||
// If the settings pane was closed (no longer in panes) while maximized,
|
||||
// clear the maximize state so the grid renders normally.
|
||||
useEffect(() => {
|
||||
if (maximized && settingsIdx < 0) setMaximized(false);
|
||||
}, [maximized, settingsIdx]);
|
||||
|
||||
function chatsForPane(pane: WorkspacePane): Chat[] {
|
||||
return pane.chatIds
|
||||
.map((id) => chats.find((c) => c.id === id))
|
||||
@@ -81,10 +123,12 @@ export function Workspace({
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={panes.length >= MAX_PANES}
|
||||
// v1.9: settings panes excluded from the MAX cap (decision c).
|
||||
disabled={panes.filter((p) => p.kind !== 'settings').length >= MAX_PANES}
|
||||
className={cn(
|
||||
'flex items-center gap-1 text-xs px-2 py-1 rounded hover:bg-muted',
|
||||
panes.length >= MAX_PANES && 'opacity-40 cursor-not-allowed hover:bg-transparent'
|
||||
panes.filter((p) => p.kind !== 'settings').length >= MAX_PANES &&
|
||||
'opacity-40 cursor-not-allowed hover:bg-transparent'
|
||||
)}
|
||||
>
|
||||
<PanelRight size={14} />
|
||||
@@ -114,12 +158,24 @@ export function Workspace({
|
||||
style={
|
||||
isMobile
|
||||
? undefined
|
||||
: { gridTemplateColumns: `repeat(${panes.length}, minmax(0, 1fr))` }
|
||||
: maximized && settingsIdx >= 0
|
||||
? { gridTemplateColumns: 'minmax(0, 1fr)' }
|
||||
: { gridTemplateColumns: `repeat(${panes.length}, minmax(0, 1fr))` }
|
||||
}
|
||||
>
|
||||
{panes.map((pane, idx) => {
|
||||
const visible = !isMobile || idx === activePaneIdx;
|
||||
if (!visible) return null;
|
||||
const isSettings = pane.kind === 'settings';
|
||||
// v1.9: when maximized, hide every pane except the settings one.
|
||||
// display:none keeps the React tree mounted so streams / drafts
|
||||
// survive the toggle without re-mount cost.
|
||||
const hiddenForMaximize = !isMobile && maximized && idx !== settingsIdx;
|
||||
const visible = (!isMobile || idx === activePaneIdx) && !hiddenForMaximize;
|
||||
if (!visible) {
|
||||
if (hiddenForMaximize) {
|
||||
return <div key={pane.id} className="hidden" />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={pane.id}
|
||||
@@ -131,19 +187,19 @@ export function Workspace({
|
||||
'before:absolute before:inset-y-0 before:left-0 before:w-0.5 before:bg-primary before:z-10'
|
||||
)}
|
||||
onClick={() => setActivePaneIdx(idx)}
|
||||
onDragOver={!isMobile && panes.length > 1 ? handlePaneDragOver(idx) : undefined}
|
||||
onDragLeave={!isMobile && panes.length > 1 ? handlePaneDragLeave : undefined}
|
||||
onDrop={!isMobile && panes.length > 1 ? handlePaneDrop(idx) : undefined}
|
||||
onDragOver={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragOver(idx) : undefined}
|
||||
onDragLeave={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragLeave : undefined}
|
||||
onDrop={!isMobile && !isSettings && panes.length > 1 ? handlePaneDrop(idx) : undefined}
|
||||
>
|
||||
<div
|
||||
draggable={!isMobile && panes.length > 1}
|
||||
onDragStart={!isMobile && panes.length > 1 ? handlePaneDragStart(idx) : undefined}
|
||||
onDragEnd={!isMobile && panes.length > 1 ? handlePaneDragEnd : undefined}
|
||||
draggable={!isMobile && !isSettings && panes.length > 1}
|
||||
onDragStart={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragStart(idx) : undefined}
|
||||
onDragEnd={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragEnd : undefined}
|
||||
>
|
||||
{/* Hidden on mobile per v1.8: chat-within-pane navigation
|
||||
is not exposed on small screens; users switch panes via
|
||||
the header pill instead. */}
|
||||
{!isMobile && (
|
||||
{/* Hidden on mobile per v1.8; settings panes own their own
|
||||
section nav / maximize toggle so they skip ChatTabBar
|
||||
entirely. */}
|
||||
{!isMobile && !isSettings && (
|
||||
<ChatTabBar
|
||||
pane={pane}
|
||||
tabs={chatsForPane(pane)}
|
||||
@@ -161,7 +217,16 @@ export function Workspace({
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
{pane.kind === 'chat' && pane.chatId ? (
|
||||
{isSettings && project ? (
|
||||
<SettingsPane
|
||||
session={session}
|
||||
project={project}
|
||||
maximized={maximized}
|
||||
onToggleMaximize={() => setMaximized((v) => !v)}
|
||||
onClose={() => removePane(idx)}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
) : pane.kind === 'chat' && pane.chatId ? (
|
||||
<ChatPane
|
||||
sessionId={sessionId}
|
||||
chatId={pane.chatId}
|
||||
@@ -169,6 +234,7 @@ export function Workspace({
|
||||
agentId={agentId}
|
||||
onAgentChange={onAgentChange}
|
||||
sessionChats={chats}
|
||||
webSearchEnabled={session.web_search_enabled}
|
||||
/>
|
||||
) : (
|
||||
<SessionLandingPage
|
||||
|
||||
@@ -22,9 +22,13 @@ interface Props {
|
||||
agentId?: string | null;
|
||||
onAgentChange?: (agentId: string | null) => void | Promise<void>;
|
||||
sessionChats?: import('@/api/types').Chat[];
|
||||
// v1.9: threaded down to ChatInput's + menu (Web search quick toggle).
|
||||
// null means "inherit project default" — ChatInput PATCHes with the
|
||||
// opposite of the effective value.
|
||||
webSearchEnabled?: boolean | null;
|
||||
}
|
||||
|
||||
export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange, sessionChats }: Props) {
|
||||
export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange, sessionChats, webSearchEnabled }: Props) {
|
||||
const stream = useSessionStream(sessionId);
|
||||
const lastErrorRef = useRef<string | null>(null);
|
||||
const [queue, setQueue] = useState<string[]>([]);
|
||||
@@ -92,6 +96,18 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
|
||||
}
|
||||
}, [chatId]);
|
||||
|
||||
// Batch 9.6: slash-command dispatch. Sent regardless of streaming state —
|
||||
// matches the existing /compact precedent (which also fires immediately).
|
||||
// Empty args go to the server as null; the server fills in a default user
|
||||
// message ("Apply this skill.") so the model has something to act on.
|
||||
const handleSlashCommand = useCallback(async (skillName: string, userMessage: string) => {
|
||||
try {
|
||||
await api.chats.skillInvoke(chatId, skillName, userMessage.length > 0 ? userMessage : null);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : `/${skillName} failed`);
|
||||
}
|
||||
}, [chatId]);
|
||||
|
||||
function removeQueued(idx: number) {
|
||||
setQueue((prev) => prev.filter((_, i) => i !== idx));
|
||||
}
|
||||
@@ -173,10 +189,13 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
|
||||
<ChatInput
|
||||
disabled={false}
|
||||
projectId={projectId}
|
||||
sessionId={sessionId}
|
||||
agentId={agentId}
|
||||
onAgentChange={onAgentChange}
|
||||
webSearchEnabled={webSearchEnabled}
|
||||
onSend={handleSend}
|
||||
onForceSend={streaming ? handleForceSend : undefined}
|
||||
onSlashCommand={handleSlashCommand}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
530
apps/web/src/components/panes/SettingsPane.tsx
Normal file
530
apps/web/src/components/panes/SettingsPane.tsx
Normal file
@@ -0,0 +1,530 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Archive, Maximize2, Minimize2, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api/client';
|
||||
import type { Project, Session } from '@/api/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { ModelPicker } from '@/components/ModelPicker';
|
||||
import { ThemePicker } from '@/components/ThemePicker';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Section = 'session' | 'project' | 'theme';
|
||||
|
||||
interface Props {
|
||||
session: Session;
|
||||
project: Project;
|
||||
maximized: boolean;
|
||||
onToggleMaximize: () => void;
|
||||
onClose: () => void;
|
||||
isMobile: boolean;
|
||||
}
|
||||
|
||||
// v1.9: hand-rolled Switch primitive. No shadcn switch in the existing
|
||||
// ui/ set and the dispatch said don't pnpm dlx for v1.9 either. Single
|
||||
// purpose — clicking flips aria-checked + calls onCheckedChange.
|
||||
function Switch({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
disabled,
|
||||
id,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onCheckedChange: (v: boolean) => void;
|
||||
disabled?: boolean;
|
||||
id?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
id={id}
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={() => onCheckedChange(!checked)}
|
||||
className={cn(
|
||||
'relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full transition-colors',
|
||||
checked ? 'bg-primary' : 'bg-muted',
|
||||
disabled && 'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block h-4 w-4 transform rounded-full bg-background transition-transform',
|
||||
checked ? 'translate-x-[1.125rem]' : 'translate-x-0.5',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsPane({ session, project, maximized, onToggleMaximize, onClose, isMobile }: Props) {
|
||||
const [activeSection, setActiveSection] = useState<Section>('session');
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex items-center gap-2 border-b border-border bg-muted/20 px-3 py-1.5 shrink-0">
|
||||
<div className="flex items-center gap-1 flex-1 min-w-0">
|
||||
{(['session', 'project', 'theme'] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setActiveSection(s)}
|
||||
className={cn(
|
||||
'text-xs px-2 py-1 rounded capitalize',
|
||||
activeSection === s
|
||||
? 'bg-background text-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted',
|
||||
)}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{!isMobile && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleMaximize}
|
||||
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
aria-label={maximized ? 'Restore' : 'Maximize'}
|
||||
title={maximized ? 'Restore (Esc)' : 'Maximize'}
|
||||
>
|
||||
{maximized ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
aria-label="Close settings"
|
||||
title="Close (Esc)"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-[720px] mx-auto w-full px-4 py-4 space-y-6">
|
||||
{activeSection === 'session' && <SessionSection session={session} project={project} />}
|
||||
{activeSection === 'project' && <ProjectSection project={project} />}
|
||||
{activeSection === 'theme' && <ThemePicker />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionSection({ session, project }: { session: Session; project: Project }) {
|
||||
const [name, setName] = useState(session.name);
|
||||
const [systemPrompt, setSystemPrompt] = useState(session.system_prompt);
|
||||
// v1.9: tri-state on the wire (null = inherit). UI surfaces a 3-way toggle
|
||||
// via "Inherit project default" checkbox plus the override switch.
|
||||
const [webSearch, setWebSearch] = useState<boolean | null>(session.web_search_enabled);
|
||||
const [saving, setSaving] = useState(false);
|
||||
// v1.9: bulk-archive chats. Two-step: openChatsCount → confirm dialog →
|
||||
// archiveAllChats. Server publishes one chat_archived frame per id so
|
||||
// useSidebar / chat lists update incrementally.
|
||||
const [archiveOpen, setArchiveOpen] = useState(false);
|
||||
const [archiveCount, setArchiveCount] = useState(0);
|
||||
const [archiving, setArchiving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setName(session.name);
|
||||
setSystemPrompt(session.system_prompt);
|
||||
setWebSearch(session.web_search_enabled);
|
||||
}, [session.id, session.name, session.system_prompt, session.web_search_enabled]);
|
||||
|
||||
const dirty =
|
||||
name !== session.name ||
|
||||
systemPrompt !== session.system_prompt ||
|
||||
webSearch !== session.web_search_enabled;
|
||||
|
||||
const effectiveWebSearch = webSearch ?? project.default_web_search_enabled;
|
||||
const projectPreview = project.default_system_prompt.trim().slice(0, 200);
|
||||
|
||||
async function save() {
|
||||
if (saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.sessions.update(session.id, {
|
||||
name: name.trim() || session.name,
|
||||
system_prompt: systemPrompt,
|
||||
web_search_enabled: webSearch,
|
||||
});
|
||||
toast.success('Session saved');
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'save failed');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function resetSystemPrompt() {
|
||||
if (saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.sessions.update(session.id, { system_prompt: '' });
|
||||
toast.success('Reset to project default');
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'reset failed');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function openArchiveDialog() {
|
||||
if (archiving) return;
|
||||
try {
|
||||
const { count } = await api.sessions.openChatsCount(session.id);
|
||||
if (count === 0) {
|
||||
toast('No open chats to archive.');
|
||||
return;
|
||||
}
|
||||
setArchiveCount(count);
|
||||
setArchiveOpen(true);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'failed to count chats');
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmArchive() {
|
||||
if (archiving) return;
|
||||
setArchiving(true);
|
||||
try {
|
||||
const { archived } = await api.sessions.archiveAllChats(session.id);
|
||||
toast.success(`Archived ${archived} chat${archived === 1 ? '' : 's'}`);
|
||||
setArchiveOpen(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'archive failed');
|
||||
} finally {
|
||||
setArchiving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Session name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full bg-background border border-border rounded px-2 py-1.5 text-sm outline-none focus:border-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Model
|
||||
</label>
|
||||
<div className="inline-flex items-center rounded-full bg-muted/40 hover:bg-muted/70 px-1">
|
||||
<ModelPicker
|
||||
value={session.model}
|
||||
onChange={async (model) => {
|
||||
try {
|
||||
await api.sessions.update(session.id, { model });
|
||||
toast.success('Model updated');
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'failed to set model');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label htmlFor="session-web-search" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Web search
|
||||
</label>
|
||||
<Switch
|
||||
id="session-web-search"
|
||||
checked={effectiveWebSearch}
|
||||
onCheckedChange={(v) => setWebSearch(v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="session-web-search-inherit"
|
||||
checked={webSearch === null}
|
||||
onChange={(e) => setWebSearch(e.target.checked ? null : project.default_web_search_enabled)}
|
||||
/>
|
||||
<label htmlFor="session-web-search-inherit" className="cursor-pointer">
|
||||
Inherit project default ({project.default_web_search_enabled ? 'on' : 'off'})
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
Plumbed for Batch 8 (web_search tool). No effect yet.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
System prompt
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void resetSystemPrompt()}
|
||||
disabled={saving || session.system_prompt === ''}
|
||||
className="text-xs text-muted-foreground hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Reset to project default
|
||||
</button>
|
||||
</div>
|
||||
<Textarea
|
||||
value={systemPrompt}
|
||||
onChange={(e) => setSystemPrompt(e.target.value)}
|
||||
rows={6}
|
||||
className="resize-y min-h-[120px] max-h-[60vh]"
|
||||
placeholder="Per-session override (optional). Empty = inherit project default."
|
||||
/>
|
||||
{systemPrompt.trim().length === 0 && projectPreview.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Falls back to project default: <span className="italic">{projectPreview}{projectPreview.length === 200 ? '…' : ''}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button onClick={() => void save()} disabled={!dirty || saving}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void openArchiveDialog()}
|
||||
disabled={archiving}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Archive size={14} /> Archive all chats
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Dialog open={archiveOpen} onOpenChange={(open) => { if (!archiving) setArchiveOpen(open); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Archive all chats?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Archive {archiveCount} open chat{archiveCount === 1 ? '' : 's'} in this session?
|
||||
Archived chats stay accessible via the archive view.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setArchiveOpen(false)} disabled={archiving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => void confirmArchive()} disabled={archiving}>
|
||||
{archiving ? 'Archiving…' : `Archive ${archiveCount}`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectSection({ project }: { project: Project }) {
|
||||
const [name, setName] = useState(project.name);
|
||||
const [defaultPrompt, setDefaultPrompt] = useState(project.default_system_prompt);
|
||||
const [defaultWebSearch, setDefaultWebSearch] = useState(project.default_web_search_enabled);
|
||||
const [saving, setSaving] = useState(false);
|
||||
// v1.9: bulk-archive sessions. Same shape as the chats-archive flow in
|
||||
// SessionSection — count, confirm, fire.
|
||||
const [archiveOpen, setArchiveOpen] = useState(false);
|
||||
const [archiveCount, setArchiveCount] = useState(0);
|
||||
const [archiving, setArchiving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setName(project.name);
|
||||
setDefaultPrompt(project.default_system_prompt);
|
||||
setDefaultWebSearch(project.default_web_search_enabled);
|
||||
}, [
|
||||
project.id,
|
||||
project.name,
|
||||
project.default_system_prompt,
|
||||
project.default_web_search_enabled,
|
||||
]);
|
||||
|
||||
const dirty =
|
||||
name !== project.name ||
|
||||
defaultPrompt !== project.default_system_prompt ||
|
||||
defaultWebSearch !== project.default_web_search_enabled;
|
||||
|
||||
async function save() {
|
||||
if (saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.projects.update(project.id, {
|
||||
name: name.trim() || project.name,
|
||||
default_system_prompt: defaultPrompt,
|
||||
default_web_search_enabled: defaultWebSearch,
|
||||
});
|
||||
toast.success('Project saved');
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'save failed');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function clearDefaultPrompt() {
|
||||
if (saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.projects.update(project.id, { default_system_prompt: '' });
|
||||
toast.success('Cleared');
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'clear failed');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function openArchiveDialog() {
|
||||
if (archiving) return;
|
||||
try {
|
||||
const { count } = await api.projects.openSessionsCount(project.id);
|
||||
if (count === 0) {
|
||||
toast('No open sessions to archive.');
|
||||
return;
|
||||
}
|
||||
setArchiveCount(count);
|
||||
setArchiveOpen(true);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'failed to count sessions');
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmArchive() {
|
||||
if (archiving) return;
|
||||
setArchiving(true);
|
||||
try {
|
||||
const { archived } = await api.projects.archiveAllSessions(project.id);
|
||||
toast.success(`Archived ${archived} session${archived === 1 ? '' : 's'}`);
|
||||
setArchiveOpen(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'archive failed');
|
||||
} finally {
|
||||
setArchiving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Project name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full bg-background border border-border rounded px-2 py-1.5 text-sm outline-none focus:border-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Root path
|
||||
</label>
|
||||
<div className="font-mono text-xs text-muted-foreground bg-muted/40 rounded px-2 py-1.5 select-all">
|
||||
{project.path}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label htmlFor="project-default-web-search" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Default web search
|
||||
</label>
|
||||
<Switch
|
||||
id="project-default-web-search"
|
||||
checked={defaultWebSearch}
|
||||
onCheckedChange={setDefaultWebSearch}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
Applies to new sessions only. Plumbed for Batch 8.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Default system prompt
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void clearDefaultPrompt()}
|
||||
disabled={saving || project.default_system_prompt === ''}
|
||||
className="text-xs text-muted-foreground hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<Textarea
|
||||
value={defaultPrompt}
|
||||
onChange={(e) => setDefaultPrompt(e.target.value)}
|
||||
rows={6}
|
||||
className="resize-y min-h-[120px] max-h-[60vh]"
|
||||
placeholder="Prepended to every new session's system prompt (when its own is empty). Empty = no project default."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Existing sessions are not affected by changes here.
|
||||
</p>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button onClick={() => void save()} disabled={!dirty || saving}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void openArchiveDialog()}
|
||||
disabled={archiving}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Archive size={14} /> Archive all sessions
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Dialog open={archiveOpen} onOpenChange={(open) => { if (!archiving) setArchiveOpen(open); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Archive all sessions?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Archive {archiveCount} open session{archiveCount === 1 ? '' : 's'} in this project?
|
||||
Archived sessions stay accessible via the archive view.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setArchiveOpen(false)} disabled={archiving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => void confirmArchive()} disabled={archiving}>
|
||||
{archiving ? 'Archiving…' : `Archive ${archiveCount}`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -62,6 +62,14 @@ export interface OpenChatInActivePaneEvent {
|
||||
chat_id: string;
|
||||
}
|
||||
|
||||
// Client-side event fired by the sidebar Settings button when a session is
|
||||
// currently mounted. Session.tsx subscribes and calls
|
||||
// panesHook.toggleSettingsPane() (open on first click, close on second).
|
||||
// Sidebar handles the no-session case by navigating to /settings directly.
|
||||
export interface OpenSettingsPaneEvent {
|
||||
type: 'open_settings_pane';
|
||||
}
|
||||
|
||||
export interface SessionArchivedEvent {
|
||||
type: 'session_archived';
|
||||
session_id: string;
|
||||
@@ -139,6 +147,7 @@ export type SessionEvent =
|
||||
| OpenFileInBrowserEvent
|
||||
| AttachChatFileEvent
|
||||
| OpenChatInActivePaneEvent
|
||||
| OpenSettingsPaneEvent
|
||||
| SessionArchivedEvent
|
||||
| ChatCreatedEvent
|
||||
| ChatUpdatedEvent
|
||||
|
||||
@@ -151,6 +151,10 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
|
||||
case 'open_chat_in_active_pane':
|
||||
// Consumed by Workspace; sidebar has no business with pane state.
|
||||
return prev;
|
||||
case 'open_settings_pane':
|
||||
// Consumed by Session.tsx (calls toggleSettingsPane on its panesHook).
|
||||
// Sidebar data is untouched.
|
||||
return prev;
|
||||
case 'session_archived': {
|
||||
let changed = false;
|
||||
const projects = prev.projects.map((p) => {
|
||||
|
||||
43
apps/web/src/hooks/useSkills.ts
Normal file
43
apps/web/src/hooks/useSkills.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '@/api/client';
|
||||
import type { Skill } from '@/api/types';
|
||||
|
||||
// Batch 9.6: shared in-memory cache for the slash-command dropdown. One fetch
|
||||
// per process; subsequent mounts of useSkills() return the cached list and
|
||||
// don't re-hit /api/skills. Matches the useSidebar / useChatStatus module-
|
||||
// singleton pattern so the dropdown stays cheap even with many ChatInputs
|
||||
// mounted at once.
|
||||
|
||||
let cachedSkills: Skill[] | null = null;
|
||||
let inflight: Promise<Skill[]> | null = null;
|
||||
const subscribers = new Set<(s: Skill[]) => void>();
|
||||
|
||||
async function loadSkills(): Promise<Skill[]> {
|
||||
if (inflight) return inflight;
|
||||
inflight = api.skills
|
||||
.list()
|
||||
.then((r) => {
|
||||
cachedSkills = r.skills;
|
||||
for (const sub of subscribers) {
|
||||
try { sub(cachedSkills); } catch { /* swallow */ }
|
||||
}
|
||||
return cachedSkills;
|
||||
})
|
||||
.finally(() => { inflight = null; });
|
||||
return inflight;
|
||||
}
|
||||
|
||||
export function useSkills(): { skills: Skill[]; loaded: boolean } {
|
||||
const [skills, setSkills] = useState<Skill[]>(cachedSkills ?? []);
|
||||
const [loaded, setLoaded] = useState<boolean>(cachedSkills !== null);
|
||||
|
||||
useEffect(() => {
|
||||
subscribers.add(setSkills);
|
||||
if (cachedSkills === null) {
|
||||
void loadSkills().then(() => setLoaded(true)).catch(() => setLoaded(true));
|
||||
}
|
||||
return () => { subscribers.delete(setSkills); };
|
||||
}, []);
|
||||
|
||||
return { skills, loaded };
|
||||
}
|
||||
@@ -19,6 +19,26 @@ function chatPane(chatId: string): WorkspacePane {
|
||||
return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 };
|
||||
}
|
||||
|
||||
// v1.9: settings pane factory. No chats, no state beyond identity — the
|
||||
// SettingsPane component renders Session/Project sections from the
|
||||
// surrounding session/project.
|
||||
function settingsPane(): WorkspacePane {
|
||||
return { id: generateId(), kind: 'settings', chatIds: [], activeChatIdx: -1 };
|
||||
}
|
||||
|
||||
// v1.9: settings panes are ephemeral. Filter them out before persisting so a
|
||||
// page reload always returns to a clean workspace; the user re-opens via the
|
||||
// sidebar Settings button when needed.
|
||||
function persistablePanes(panes: WorkspacePane[]): WorkspacePane[] {
|
||||
return panes.filter((p) => p.kind !== 'settings');
|
||||
}
|
||||
|
||||
// v1.9: per recon decision (c), settings panes don't count toward MAX_PANES.
|
||||
// Helper used at every pane-insertion site so the rule lives in one place.
|
||||
function nonSettingsCount(panes: WorkspacePane[]): number {
|
||||
return panes.reduce((n, p) => n + (p.kind === 'settings' ? 0 : 1), 0);
|
||||
}
|
||||
|
||||
function loadPanes(sessionId: string): WorkspacePane[] | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(`${STORAGE_KEY}.${sessionId}`);
|
||||
@@ -33,7 +53,10 @@ function loadPanes(sessionId: string): WorkspacePane[] | null {
|
||||
|
||||
function savePanes(sessionId: string, panes: WorkspacePane[]): void {
|
||||
try {
|
||||
localStorage.setItem(`${STORAGE_KEY}.${sessionId}`, JSON.stringify(panes));
|
||||
localStorage.setItem(
|
||||
`${STORAGE_KEY}.${sessionId}`,
|
||||
JSON.stringify(persistablePanes(panes)),
|
||||
);
|
||||
} catch { /* quota or disabled */ }
|
||||
}
|
||||
|
||||
@@ -50,6 +73,10 @@ export interface UseWorkspacePanesResult {
|
||||
closeAllTabs: (paneIdx: number) => void;
|
||||
showLandingPage: (paneIdx: number) => void;
|
||||
addSplitPane: (kind: 'chat' | 'terminal' | 'agent') => void;
|
||||
// Open-on-first-click, close-on-second-click. Singleton — settings panes
|
||||
// don't count toward MAX_PANES. Closing the only remaining pane (edge case)
|
||||
// falls back to an empty pane to preserve the "always one pane" invariant.
|
||||
toggleSettingsPane: () => void;
|
||||
removePane: (idx: number) => void;
|
||||
removeChatFromPanes: (chatId: string) => void;
|
||||
initializeFirstChatIfEmpty: (chatId: string) => void;
|
||||
@@ -216,7 +243,8 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
||||
return;
|
||||
}
|
||||
setPanes((prev) => {
|
||||
if (prev.length >= MAX_PANES) {
|
||||
// v1.9: settings panes are excluded from the MAX cap (decision c).
|
||||
if (nonSettingsCount(prev) >= MAX_PANES) {
|
||||
toast.error(`Maximum ${MAX_PANES} panes`);
|
||||
return prev;
|
||||
}
|
||||
@@ -226,9 +254,35 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleSettingsPane = useCallback(() => {
|
||||
setPanes((prev) => {
|
||||
const existingIdx = prev.findIndex((p) => p.kind === 'settings');
|
||||
if (existingIdx < 0) {
|
||||
const next = [...prev, settingsPane()];
|
||||
setActivePaneIdx(next.length - 1);
|
||||
return next;
|
||||
}
|
||||
if (prev.length <= 1) {
|
||||
setActivePaneIdx(0);
|
||||
return [emptyPane()];
|
||||
}
|
||||
const next = prev.filter((_, i) => i !== existingIdx);
|
||||
setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removePane = useCallback((idx: number) => {
|
||||
setPanes((prev) => {
|
||||
if (prev.length <= 1) return prev;
|
||||
if (prev.length <= 1) {
|
||||
// Settings is the only kind that can be the last pane and still need
|
||||
// closing (X / Esc / sidebar toggle). Fall back to empty.
|
||||
if (prev[idx]?.kind === 'settings') {
|
||||
setActivePaneIdx(0);
|
||||
return [emptyPane()];
|
||||
}
|
||||
return prev;
|
||||
}
|
||||
const next = prev.filter((_, i) => i !== idx);
|
||||
setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
|
||||
return next;
|
||||
@@ -325,6 +379,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
||||
closeAllTabs,
|
||||
showLandingPage,
|
||||
addSplitPane,
|
||||
toggleSettingsPane,
|
||||
removePane,
|
||||
removeChatFromPanes,
|
||||
initializeFirstChatIfEmpty,
|
||||
|
||||
@@ -47,6 +47,11 @@ export function Home() {
|
||||
last_session_id: null,
|
||||
status: 'archived' as const,
|
||||
gitea_remote: fromSidebar.gitea_remote,
|
||||
// v1.9: synthesized stub for an archived project that only the
|
||||
// sidebar cache has — defaults match the schema NOT NULL DEFAULT
|
||||
// values. The full row gets re-fetched on unarchive.
|
||||
default_system_prompt: '',
|
||||
default_web_search_enabled: false,
|
||||
},
|
||||
...prev,
|
||||
];
|
||||
|
||||
@@ -116,9 +116,31 @@ function SessionInner({ sessionId }: { sessionId: string }) {
|
||||
event.session_id === sessionId
|
||||
) {
|
||||
navigate(`/project/${event.project_id}`);
|
||||
return;
|
||||
}
|
||||
// v1.9: any session_updated for this session triggers a full refetch so
|
||||
// SettingsPane (mounted in a workspace pane) picks up system_prompt /
|
||||
// web_search_enabled / model edits made from another tab.
|
||||
if (event.type === 'session_updated' && event.session_id === sessionId) {
|
||||
void api.sessions.get(sessionId).then((s) => {
|
||||
setSession(s);
|
||||
setName((prev) => (editingName ? prev : s.name));
|
||||
}).catch(() => {});
|
||||
return;
|
||||
}
|
||||
// v1.9: project_updated → refetch project so the Project section in
|
||||
// SettingsPane reflects the new defaults.
|
||||
if (event.type === 'project_updated' && project && event.project_id === project.id) {
|
||||
void api.projects.get(project.id).then(setProject).catch(() => {});
|
||||
return;
|
||||
}
|
||||
// Sidebar Settings button broadcasts this when a session is mounted;
|
||||
// toggleSettingsPane opens on first click, closes on second.
|
||||
if (event.type === 'open_settings_pane') {
|
||||
panesHook.toggleSettingsPane();
|
||||
}
|
||||
});
|
||||
}, [sessionId, editingName, navigate]);
|
||||
}, [sessionId, editingName, navigate, project, panesHook]);
|
||||
|
||||
// v1.8: URL ?pane= sync (mobile only). Lifted from Workspace.tsx so
|
||||
// MobileTabSwitcher's onSwitchPane can push the same URL state and the
|
||||
@@ -211,15 +233,13 @@ function SessionInner({ sessionId }: { sessionId: string }) {
|
||||
</div>
|
||||
|
||||
{session && (
|
||||
<div className="inline-flex items-center rounded-full bg-muted/40 hover:bg-muted/70 px-1 shrink-0">
|
||||
<ModelPicker
|
||||
value={session.model}
|
||||
onChange={async (model) => {
|
||||
const updated = await api.sessions.update(session.id, { model });
|
||||
setSession(updated);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ModelPicker
|
||||
value={session.model}
|
||||
onChange={async (model) => {
|
||||
const updated = await api.sessions.update(session.id, { model });
|
||||
setSession(updated);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
@@ -337,6 +357,8 @@ function SessionInner({ sessionId }: { sessionId: string }) {
|
||||
}}
|
||||
panesHook={panesHook}
|
||||
chatsHook={chatsHook}
|
||||
session={session}
|
||||
project={project}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,124 +1,46 @@
|
||||
import { useState } from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { THEMES, setTheme, useTheme, type ThemeId, type ThemeMode } from '@/lib/theme';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const MODES: { value: ThemeMode; label: string; hint: string }[] = [
|
||||
{ value: 'dark', label: 'Dark', hint: 'Use the dark variant.' },
|
||||
{ value: 'light', label: 'Light', hint: 'Use the light variant.' },
|
||||
{ value: 'system', label: 'System', hint: 'Follow OS preference.' },
|
||||
];
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ThemePicker } from '@/components/ThemePicker';
|
||||
|
||||
// v1.9: thin wrapper around <ThemePicker />. The picker itself moved to a
|
||||
// reusable component (also rendered in the workspace SettingsPane Theme tab).
|
||||
// This page-level shell adds the back affordance + heading chrome that's
|
||||
// appropriate when the picker is the entire route.
|
||||
export function Settings() {
|
||||
const { id: currentId, mode: currentMode } = useTheme();
|
||||
// Track the most recent in-flight pick so the picker can show a subtle
|
||||
// "applying…" state on the targeted card while the PATCH is in flight.
|
||||
const [pending, setPending] = useState<{ kind: 'theme'; id: ThemeId } | { kind: 'mode'; mode: ThemeMode } | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
async function pickTheme(id: ThemeId) {
|
||||
if (id === currentId || pending) return;
|
||||
setPending({ kind: 'theme', id });
|
||||
try {
|
||||
await setTheme(id, currentMode);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'failed to apply theme');
|
||||
} finally {
|
||||
setPending(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function pickMode(mode: ThemeMode) {
|
||||
if (mode === currentMode || pending) return;
|
||||
setPending({ kind: 'mode', mode });
|
||||
try {
|
||||
await setTheme(currentId, mode);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'failed to apply mode');
|
||||
} finally {
|
||||
setPending(null);
|
||||
function handleBack() {
|
||||
// History-aware: jump back to where the user came from when possible.
|
||||
// Direct loads of /settings (no history) land on Home so the button
|
||||
// always does *something* useful.
|
||||
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-8">
|
||||
<header>
|
||||
<h1 className="text-xl font-semibold">Settings</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Theme appearance. Saved on change, applies immediately.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-medium">Mode</h2>
|
||||
<RadioGroup
|
||||
value={currentMode}
|
||||
onValueChange={(v) => void pickMode(v as ThemeMode)}
|
||||
className="flex flex-wrap gap-4"
|
||||
<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"
|
||||
>
|
||||
{MODES.map((m) => (
|
||||
<div key={m.value} className="flex items-center gap-2">
|
||||
<RadioGroupItem id={`mode-${m.value}`} value={m.value} />
|
||||
<Label htmlFor={`mode-${m.value}`} className="cursor-pointer">
|
||||
<span className="font-medium">{m.label}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{m.hint}</span>
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-medium">Theme</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{THEMES.map((t) => {
|
||||
const isActive = t.id === currentId;
|
||||
const isPending = pending?.kind === 'theme' && pending.id === t.id;
|
||||
const isLightOnly = !t.supportsDark;
|
||||
return (
|
||||
<Card
|
||||
key={t.id}
|
||||
onClick={() => void pickTheme(t.id)}
|
||||
className={cn(
|
||||
'p-3 cursor-pointer transition-colors',
|
||||
'hover:bg-accent/10',
|
||||
isActive && 'ring-2 ring-ring',
|
||||
isPending && 'opacity-60',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="font-mono text-sm truncate">{t.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{t.family}</div>
|
||||
</div>
|
||||
{isActive && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-primary shrink-0">
|
||||
<Check className="size-3" /> Selected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex mt-2 rounded overflow-hidden border border-border/40">
|
||||
{t.anchors.map((hex, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 h-6"
|
||||
style={{ backgroundColor: hex }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{isLightOnly && (
|
||||
<div className="mt-2 text-xs text-muted-foreground italic">Light only</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
<ArrowLeft className="size-4" />
|
||||
<span>Back</span>
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Settings</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Theme appearance. Saved on change, applies immediately.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</header>
|
||||
<ThemePicker />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,341 +1,201 @@
|
||||
# BooCode v1.x — Roadmap
|
||||
# BooCode — Roadmap
|
||||
|
||||
Last updated: 2026-05-16
|
||||
Last updated: 2026-05-17
|
||||
|
||||
## Overview
|
||||
|
||||
BooCode is a standalone code-chat tool at `/opt/boocode/`. Read-only by design — pick a project, chat with a local LLM that has file-inspection tools, get streaming responses over WebSocket.
|
||||
BooCode is a standalone code-chat tool at `/opt/boocode/`. Read-only by design in v1.x — pick a project, chat with a local LLM that has file-inspection tools, get streaming responses over WebSocket.
|
||||
|
||||
Live at `https://code.indifferentketchup.com` (Caddy → Authelia → Tailscale → `100.114.205.53:9500`).
|
||||
|
||||
**Architectural commitments:**
|
||||
|
||||
- No embeddings. The model uses file-view tools (`view_file`, `list_dir`, `grep`, `find_files`) + sidecar analyzers (codecontext, codesight). Walked away from the RAG pipeline May 2026.
|
||||
- No embeddings. File-view tools + sidecar analyzers replace RAG.
|
||||
- Read-only in v1.x. Write tools land in BooCoder (separate container, post-v1.x).
|
||||
- One Postgres (`boocode_db`), one frontend SPA, container-per-service for new capabilities.
|
||||
|
||||
External code lifted from / referenced in: see `boocode_code_review.md` for full inventory.
|
||||
## Current state
|
||||
|
||||
-----
|
||||
- **main:** v1.8.1 (`b09d0ff` was last known tip prior to v1.8.2).
|
||||
- **Just merged / committed to main:** v1.8.2 — tool-loop fixes (read-only loop cap raised, "tool loop depth exceeded" error surfaced with continue button, `max_tool_calls` AGENTS.md frontmatter, `messages.metadata` column).
|
||||
- **In flight RIGHT NOW:** **v1.x-themes** branch — Claude Code implementing 18-theme system. See "Active work" below.
|
||||
|
||||
## Active work
|
||||
|
||||
### v1.x-themes — Theme system (in flight)
|
||||
|
||||
**Spec source:** locked in this session. Anchors below derived from `/mnt/user-data/uploads/boocode-theme-previews.html` (16 themes extracted) + spec §3 family rules for the two missing (`fuchsia-noir`, `midnight-sapphire`).
|
||||
|
||||
**18 themes, grouped:**
|
||||
|
||||
| Family | IDs |
|
||||
|---|---|
|
||||
| Neutral dark | obsidian (default), gunmetal |
|
||||
| Brown / warm | espresso, volcanic-brown |
|
||||
| Orange / amber | copper, gold |
|
||||
| Red | oxblood, crimson |
|
||||
| Purple | elderflower, plum |
|
||||
| Pink / magenta | steel-pink, fuchsia-noir |
|
||||
| Green | matrix, sage |
|
||||
| Blue | cobalt, midnight-sapphire |
|
||||
| Light-only | ivory, chalk |
|
||||
|
||||
**Dark anchors (bg, card, border, muted-fg, accent):**
|
||||
|
||||
```
|
||||
obsidian #0c0c0e #15151a #1f1f23 #6b6b75 #8b5cf6
|
||||
gunmetal #0d1117 #161b22 #21262d #7d8590 #388bfd
|
||||
espresso #1c1410 #241a14 #2e2218 #8a7058 #c8a880
|
||||
volcanic-brown #140906 #1e0e0a #2e1610 #7a4030 #cc4a1a
|
||||
copper #100800 #1c1408 #2e1f0a #8a6040 #b87333
|
||||
gold #0e0800 #1a1200 #2a1f00 #a07c30 #d4af37
|
||||
oxblood #0a0303 #180606 #2a0808 #7a3028 #8b1a1a
|
||||
crimson #0e0404 #1a0808 #2e0a0a #8a3030 #dc143c
|
||||
elderflower #100818 #1c1024 #2c1830 #8a78a0 #b89cd8
|
||||
plum #0c0814 #180e20 #241830 #7a4878 #8e4585
|
||||
steel-pink #0e0408 #1a080e #2e0c1a #9a4070 #cc33aa
|
||||
fuchsia-noir #0a0610 #14081a #2a0c2e #8a3878 #ff1493
|
||||
matrix #000a00 #031403 #0a200a #208030 #00ff41
|
||||
sage #0a0e08 #141a10 #1e2e1a #7a8870 #9caf88
|
||||
cobalt #020817 #061434 #0c2244 #3060a0 #0047ab
|
||||
midnight-sapphire #02050e #060c1f #0e1a36 #4a6088 #1e3a8a
|
||||
ivory #fdfcf8 #f5f2e8 #e8e4d8 #8a8478 #3a3328 (light-only)
|
||||
chalk #fafaf7 #f0f0ec #e5e5e0 #75756e #2a2a28 (light-only)
|
||||
```
|
||||
|
||||
**Light-variant derivation (for the 16 dark themes):**
|
||||
- Lightest anchor → background
|
||||
- Accent darkens ~15% (HSL L − 15pp)
|
||||
- Foreground = near-black tinted toward family hue
|
||||
- Surfaces / borders scale up symmetrically
|
||||
|
||||
**Fallback:** `ivory` or `chalk` + dark mode → `obsidian` dark.
|
||||
|
||||
**Token map (shadcn nova set):**
|
||||
```
|
||||
background ← anchor 1
|
||||
card / popover ← anchor 2
|
||||
border / muted ← anchor 3
|
||||
muted-foreground ← anchor 4
|
||||
primary / accent ← anchor 5
|
||||
foreground ← derived: anchor-5 hue, ~92% L, ~25% S
|
||||
--destructive ← red family, unchanged across themes
|
||||
--ring ← per-theme accent
|
||||
--radius ← 0.5rem locked
|
||||
fonts ← Inter + JetBrains Mono locked
|
||||
```
|
||||
|
||||
**Wiring locked:**
|
||||
- Schema: `settings.theme_id TEXT NOT NULL DEFAULT 'obsidian'`, `settings.theme_mode TEXT NOT NULL DEFAULT 'dark' CHECK IN ('dark','light','system')`
|
||||
- API: GET `/api/settings` extended, PATCH whitelists 18 theme ids → 400 otherwise
|
||||
- CSS: `apps/web/src/styles/themes/*.css` (18 + `_tokens.css`), imported from `globals.css` (NOT `index.css`)
|
||||
- `.theme-<id>` + `.theme-<id>.dark` composed on `<html>`
|
||||
- `apps/web/src/lib/theme.ts` (new): `THEMES` const, `applyTheme(id, mode)`, `useTheme()` hook. matchMedia subscribed only when `mode === 'system'`
|
||||
- `apps/web/src/App.tsx`: `useTheme()` at top
|
||||
- Settings page: card grid, mode toggle (radio: Dark/Light/System). No header dropdown.
|
||||
- shadcn primitives: `card`, `radio-group` installed via `pnpm dlx shadcn@latest add`. `button`, `label` already present.
|
||||
- FOUC mitigation: localStorage cache + inline `<script>` in `index.html` sets `<html>` class before React hydrates
|
||||
|
||||
**Out of scope (v1):**
|
||||
- Custom user palettes (no color picker)
|
||||
- Per-project / per-session themes
|
||||
- Shiki syntax-highlighting themes
|
||||
- Header quick-switcher
|
||||
|
||||
**Verify after Claude Code hands back:**
|
||||
- `fuchsia-noir` and `midnight-sapphire` visual check — derived, not from preview. Swap hexes if they read wrong.
|
||||
- Light variants of the 16 dark themes — algorithmic. Spot-check 3-4 across families (warm/cool/dark/saturated).
|
||||
- FOUC on hard reload, theme-switch persistence, system-mode matchMedia teardown.
|
||||
|
||||
## Batch summary
|
||||
|
||||
|Batch |Theme |Status |Branch / Notes |
|
||||
|------------------------------------------|-----------------------------------------------------------------------------------|-----------|---------------------------------------|
|
||||
|1 |Markdown, Copy + Regen, tok/s + ctx, AI naming |✅ Done |`v1.1-batch1` merged |
|
||||
|2 |Sidebar restructure |✅ Done |`v1.1-batch2` merged |
|
||||
|3 |Pane system, FileBrowserPane + Shiki, cross-tab |✅ Done |`v1.1-batch3` merged |
|
||||
|3.5 |Chip infrastructure, `@file`, line-select |✅ Done |merged |
|
||||
|4 (v1.2) |Chats inside sessions, right-rail, `/compact`, archive, force-send |✅ Done |merged |
|
||||
|4.1–4.4 |Project archive, sidebar context, Gitea API, bootstrap |✅ Done |merged |
|
||||
|v1.5 cleanup |resolveProjectPath, BOOTSTRAP_ROOT, vitest pin |✅ Done |merged |
|
||||
|v1.6 mobile |Drawer, single-pane, long-press, IME-safe, pull-to-refresh, swipe-close |✅ Done |merged |
|
||||
|v1.6.1 |RightRail mobile wrapper fix |✅ Done |merged |
|
||||
|Tool-loop bump |MAX_TOOL_LOOP_DEPTH 5→15 |✅ Done |merged |
|
||||
|v1.6.2 |Workspace + Session+Project headers + ChatTabBar new-chat + RightRail mobile drawer|🔄 In flight|`v1.6.2-mobile-ui-fixes` |
|
||||
|**v1.8 mobile tabs** |**Bottom-sheet pane switcher + cross-tab `pane_status` WS sync + StatusDot on tabs**|**Next up**|`v1.8-mobile-tabs`; hand-rolled sheet |
|
||||
|9 (REORDERED, DECOUPLED) |Agents (Tier 2): `AGENTS.md`, per-agent temp/tools, picker in ChatInput toolbar |✅ Implemented, uncommitted|six builtins; on `main` awaiting commit|
|
||||
|5 |Fork message, delete message, header polish |Planned | |
|
||||
|6 |Drag-drop file + paste-as-attachment |Planned |thin extension of 3.5 chips |
|
||||
|7 |Settings drawer: system prompt, web search toggle, agent entry |Planned |adds SettingsDrawer agent entry (Batch 9 deferred half) |
|
||||
|8 |Web search backend: SearXNG `web_search` + `web_fetch` |Planned | |
|
||||
|10 |BooTerm: separate container, xterm.js + node-pty + tmux |Planned | |
|
||||
|11 — Architect: codebase map |codecontext sidecar + MCP tool wiring |Planned |from nmakod/codecontext |
|
||||
|11b — Architect: repo health |call graph, circular deps, dead code |Planned |from spirituslab/codesight |
|
||||
|12 — Tool approval + plan/act mode |Read-only invariant, per-tool gating |Planned |from cline |
|
||||
|13 — Append-only event log |Replace messages-table semantics |Planned |from OpenHands V1 |
|
||||
|14 — BooCoder: pending changes |Sandboxed edit queue, atomic apply |Post-v1.x |from plandex |
|
||||
|15 — BooCoder runtime isolation |Per-session Docker sandbox |Post-v1.x |from OpenHands |
|
||||
|16 — Multi-provider LLM |Optional litellm-style abstraction |Optional |from pi-ai |
|
||||
|17 — Workflow graphs |Multi-agent coordination |Far future |from microsoft/agent-framework concepts|
|
||||
|
||||
**Old Batch 12 (codebase indexer w/ Harrier embeddings) — REMOVED.** Replaced by Batch 11/11b sidecar approach. See `boocode_code_review.md` decisions log.
|
||||
|
||||
**Batch 9 reordered ahead of 5–8, 10.** Picker mounts in `ChatInput.tsx` toolbar only. SettingsDrawer agent entry rolled into Batch 7 when it lands. No UI dependency on Batches 5/6/7, so it can ship anytime after v1.6.2.
|
||||
|
||||
-----
|
||||
|
||||
## Batch details (planned / new)
|
||||
|
||||
### Batch 9 — Agents (Tier 2, DECOUPLED)
|
||||
|
||||
**Spec:** `boocode_batch9.md` with the deltas below.
|
||||
|
||||
**Status:** Next up after v1.6.2 merges. Decoupled from Batch 7.
|
||||
|
||||
**Deltas from `boocode_batch9.md`:**
|
||||
|
||||
1. Builtin defaults in `agents.ts` OMIT the `model` field. Resolution order makes `session.model` win when `agent.model` is null. Spec line 30 example is misleading — do not hardcode any model in builtins.
|
||||
2. Builtin defaults are the six agents shipped in `/opt/boocode/AGENTS.md`: **Code Reviewer, Debugger, Refactorer, Architect, Security Auditor, Prompt Builder.** If project root `AGENTS.md` exists, only its agents show. If absent, show the six builtins.
|
||||
3. AgentPicker mounts in `ChatInput.tsx` toolbar between ModelPicker and the `+` button. **No `SettingsDrawer.tsx` or `Header.tsx` changes in this batch.**
|
||||
4. SettingsDrawer agent entry + Header active-agent badge moved to Batch 7.
|
||||
|
||||
**Files to create:**
|
||||
|
||||
- `apps/server/src/services/agents.ts` — parser, six builtin defaults, mtime-keyed cache.
|
||||
- `apps/server/src/routes/agents.ts` — `GET /api/projects/:id/agents`.
|
||||
- `apps/web/src/components/AgentPicker.tsx` — dropdown, matches ModelPicker pattern.
|
||||
|
||||
**Files to modify:**
|
||||
|
||||
- `apps/server/src/schema.sql` — `ALTER TABLE sessions ADD COLUMN IF NOT EXISTS agent_id TEXT;`
|
||||
- `apps/server/src/services/inference.ts` — resolution order: `effective_system_prompt`, `effective_model`, `effective_temperature`, `effective_tools` from session + agent + project. Filter tools array against agent whitelist before sending to llama-swap.
|
||||
- `apps/server/src/routes/sessions.ts` — PATCH accepts `agent_id`.
|
||||
- `apps/server/src/types/api.ts` — Agent type, extend Session with `agent_id`.
|
||||
- `apps/web/src/api/client.ts`, `apps/web/src/api/types.ts` — Agent type, `api.agents.list(projectId)`.
|
||||
- `apps/web/src/components/ChatInput.tsx` — mount AgentPicker.
|
||||
|
||||
**Testing plan (manual, before locking temps):**
|
||||
|
||||
- Drop `/opt/boocode/AGENTS.md` (six agents, no `model` field on any).
|
||||
- For each of the 7 keeper models, switch session model and run the same target prompt against each agent. Log tok/s, instruction-following quality.
|
||||
- Adjust per-agent temperature in `AGENTS.md` based on results.
|
||||
- A/B candidates: qwen3.6-35b-a3b-mxfp4 (daily), qwopus3.6-35b-a3b-q4 (reasoning), qwopus3.5-27b-q4, qwen3.6-27b-ud-q4-xl, nemotron-3-nano-30b, gemma-4-26b-a4b-mxfp4, qwen3-coder-30b-apex.
|
||||
|
||||
**Dependencies:** v1.6.2 merged.
|
||||
|
||||
-----
|
||||
|
||||
### Batch 11 — Architect: codebase map (REVISED)
|
||||
|
||||
**Inspiration / lift:** `nmakod/codecontext` (MIT, Go binary).
|
||||
|
||||
**What it gives BooCode:** an architect-grade codebase overview without embeddings. Codecontext parses the repo with tree-sitter, extracts symbols, builds import/dependency relationships, and exposes the result via an MCP server with 8 tools. The model gets a structural map of any codebase on demand.
|
||||
|
||||
**Why this replaces the original Batch 11 (aider PageRank port):** codecontext is a finished binary in our stack language (Go), with watch mode, incremental updates, framework detection, and git-co-change-based semantic neighborhoods (no embeddings). The aider port would be reimplementing what codecontext already ships.
|
||||
|
||||
**Scope:**
|
||||
|
||||
- Add `codecontext` sidecar container to `docker-compose.yml`. Mount the project root read-only. One sidecar per BooCode instance — projects are addressed by absolute path.
|
||||
- Wire each codecontext MCP tool into BooCode’s `inference/tools.ts` as a native tool the model can call:
|
||||
- `repo_overview(project_id)` → codecontext `get_codebase_overview`
|
||||
- `repo_file_analysis(project_id, path)` → `get_file_analysis`
|
||||
- `repo_symbol_info(project_id, symbol)` → `get_symbol_info`
|
||||
- `repo_search_symbols(project_id, query)` → `search_symbols`
|
||||
- `repo_dependencies(project_id, path)` → `get_dependencies`
|
||||
- `repo_semantic_neighborhoods(project_id, path)` → `get_semantic_neighborhoods` (git co-change)
|
||||
- `repo_framework_analysis(project_id)` → `get_framework_analysis`
|
||||
- `path_guard.ts` extension: incorporate `continuedev/continue` `DEFAULT_SECURITY_IGNORE_FILETYPES` so codecontext can’t surface `.env`, `.pem`, keys, etc.
|
||||
- Fallback grammars: drop `Aider-AI/aider`‘s `aider/queries/tree-sitter-*.scm` files for any language codecontext doesn’t cover. Use them via an in-process tree-sitter wrapper *only if* a project needs an unsupported language. Defer wrapper build until that’s an actual gap.
|
||||
|
||||
**Where it goes:** new `apps/server/src/architect/` directory. No new tables — codecontext maintains its own state on disk. New env: `CODECONTEXT_URL=http://codecontext:8765` (MCP endpoint).
|
||||
|
||||
**Decisions to make at recon time:**
|
||||
|
||||
- Bundle the binary directly in the BooCode Dockerfile, or run codecontext as its own service? Sidecar is cleaner. Bundle is one less container.
|
||||
- How does the model discover codecontext tools — register them statically in the tools registry, or proxy MCP `tools/list` at startup?
|
||||
|
||||
**Dependencies:** none. Can ship before Batches 5–10.
|
||||
|
||||
-----
|
||||
|
||||
### Batch 11b — Architect: repo health (NEW)
|
||||
|
||||
**Inspiration / lift:** `spirituslab/codesight` (MIT-ish, TS/Node).
|
||||
|
||||
**What it gives BooCode:** complement to Batch 11. Where codecontext answers “what is this codebase,” repo health answers “what’s wrong with this codebase.” Call graph, circular dependency detection, dead code flagging.
|
||||
|
||||
**Scope:**
|
||||
|
||||
- Port codesight’s `analyze.mjs` analyzer core into `apps/server/src/architect/repo_health.ts`. Drop the VS Code extension shell. Keep:
|
||||
- Symbol extraction (already overlaps codecontext — call codecontext where possible, only redo what’s needed for graph edges).
|
||||
- Call graph builder (function-to-function edges).
|
||||
- Circular dependency detector.
|
||||
- Dead code detector (exported symbols never imported or called).
|
||||
- New tool: `repo_health(project_id)` returning `{ circular_dependencies: [...], dead_code: [...] }`. Output respects codesight’s documented false-positive caveats (customElements.define, framework entry points, dynamic imports) — surface those in the tool description so the model doesn’t trust dead-code flags blindly.
|
||||
- Cache results in `boocode_db` keyed by `(project_id, file_hashes)`. Invalidate on file change via file-index hash check.
|
||||
|
||||
**Decisions:**
|
||||
|
||||
- Build it in-process (Node) vs spawn a CLI? In-process is simpler. Spawn matches codecontext sidecar pattern but adds latency.
|
||||
|
||||
**Dependencies:** Batch 11 merged (so we can reuse codecontext’s parse output where possible). Can be deferred until after Batches 5–10.
|
||||
|
||||
-----
|
||||
|
||||
### Batch 12 — Tool approval gating + plan/act mode
|
||||
|
||||
**Inspiration / lift:** `cline/cline` (Apache-2.0).
|
||||
|
||||
**What it gives BooCode:** per-session control over which tools the model can call. Lays the groundwork for BooCoder by building the gating mechanism before there are any write tools to gate.
|
||||
|
||||
**Scope:**
|
||||
|
||||
- New column `sessions.tool_approval_mode TEXT` — values: `read_only` (v1.x default), `plan`, `act_auto`, `act_approve`.
|
||||
- New column `sessions.approved_tools JSONB` — per-session whitelist for `act_approve` mode.
|
||||
- Tool registry refactor: tools tagged `read_only` or `write`. In `read_only` mode (v1.x), write tools never appear in the model’s tools array. In `plan` mode, same — write tools hidden, model produces a plan only. `act_*` modes unlock writes (post-v1.x).
|
||||
- UI: mode picker in SettingsDrawer (Batch 7 dependency). Inline indicator in chat header.
|
||||
|
||||
**Dependencies:** Batch 7 (SettingsDrawer).
|
||||
|
||||
-----
|
||||
|
||||
### Batch 13 — Append-only event log
|
||||
|
||||
**Inspiration / lift:** `OpenHands/OpenHands` V1 (MIT).
|
||||
|
||||
**What it gives BooCode:** replaces the ad-hoc `messages` table semantics with a typed event stream. Unlocks rewind, time-travel, and clean handoff semantics for multi-agent flows.
|
||||
|
||||
**Scope:**
|
||||
|
||||
- New `session_events` table: `(id, session_id, ts, kind, payload JSONB, parent_id)`. Event kinds: `user_message`, `assistant_message`, `tool_call`, `tool_result`, `pane_action`, `mode_change`, `system`.
|
||||
- Existing `messages` table becomes a derived view over `session_events` for backward compatibility, then deprecated over a release.
|
||||
- Inference loop emits events instead of mutating message rows.
|
||||
- Frontend `useSessionStream` reducer rewritten to consume events.
|
||||
|
||||
**Migration is non-trivial.** Plan in a dedicated batch with explicit cutover window.
|
||||
|
||||
**Dependencies:** Batches 5 (fork/delete) and 7 (settings) merged. Must not be in flight with other backend work.
|
||||
|
||||
-----
|
||||
|
||||
### Batch 14 — BooCoder: pending changes
|
||||
|
||||
**Inspiration / lift:** `plandex-ai/plandex` (MIT).
|
||||
|
||||
**What it gives BooCode:** safe write tools. Edits queue in a virtual layer; nothing touches the filesystem until explicit `/apply`.
|
||||
|
||||
**Scope:**
|
||||
|
||||
- New container `boocoder` at `100.114.205.53:9502`. Owns write tools (`edit_file`, `create_file`, `delete_file`, `apply_pending`, `rewind`).
|
||||
- New table `pending_changes (id, session_id, file_path, diff TEXT, status, created_at)`. Status: `pending`, `applied`, `rejected`.
|
||||
- Tools execute against the pending-changes layer, not the filesystem. `apply_pending` is the only path that touches disk. `rewind` rolls back a `pending`-id back to disk state.
|
||||
- BooCode chat container stays read-only (`/opt:/opt:ro`). BooCoder mounts `/opt/repos:/opt/repos:rw` and uses git worktree pattern from paseo for isolation.
|
||||
- Frontend: new pane kind `pending_diff` shows the queued diff inline with Approve/Reject per chunk.
|
||||
|
||||
**Dependencies:** Batches 12 (gating) + 13 (events). Don’t start until both are live.
|
||||
|
||||
-----
|
||||
|
||||
### Batch 15 — BooCoder runtime isolation
|
||||
|
||||
**Inspiration / lift:** `OpenHands/OpenHands` (MIT).
|
||||
|
||||
**What it gives BooCode:** per-session Docker sandbox for BooCoder writes. Closes the `/opt:ro` mount risk identified in v1.x open items.
|
||||
|
||||
**Scope:**
|
||||
|
||||
- Per-session container spawned by BooCoder on first write. Container has only the project’s path mounted, not `/opt`.
|
||||
- Container lifecycle: spawn on first write call, idle-timeout after 30 min, recreate on resume.
|
||||
- Action execution server pattern: HTTP API inside the container, BooCoder calls in. Standard OpenHands runtime contract.
|
||||
|
||||
**Dependencies:** Batch 14.
|
||||
|
||||
-----
|
||||
|
||||
### Batch 16 — Multi-provider LLM abstraction
|
||||
|
||||
**Inspiration / lift:** `earendil-works/pi` `pi-ai` (MIT).
|
||||
|
||||
**What it gives BooCode:** optional non-llama-swap inference paths (Anthropic, OpenAI, Mistral direct). Currently we have one provider (llama-swap) and the existing `streamCompletion` is hardcoded to OpenAI-compatible at that endpoint.
|
||||
|
||||
**Scope:**
|
||||
|
||||
- Provider abstraction: `interface LLMProvider { stream(req): AsyncIterator<Frame> }`.
|
||||
- Built-in: llama-swap (current), Anthropic, OpenAI (Codex-style).
|
||||
- Per-session `provider_id` column.
|
||||
|
||||
**Status:** **Optional. Skip unless a concrete need surfaces.** llama-swap covers daily driver work.
|
||||
|
||||
-----
|
||||
|
||||
### Batch 17 — Workflow graphs
|
||||
|
||||
**Inspiration / lift:** `microsoft/agent-framework` (MIT) — concepts only.
|
||||
|
||||
**What it gives BooCode:** multi-agent coordination. Architect → Coder → Reviewer → Verifier handoffs orchestrated by a YAML-defined workflow.
|
||||
|
||||
**Status:** **Far future.** Read agent-framework’s `docs/decisions/` ADRs. Don’t port code — Azure/.NET-heavy.
|
||||
|
||||
**Dependencies:** Batches 12 (modes), 13 (events). Realistically a v2.x topic.
|
||||
|
||||
-----
|
||||
| Version | Theme | Status |
|
||||
|---|---|---|
|
||||
| v1.0 | Initial scaffold, read-only tools, WS streaming | ✅ Merged |
|
||||
| v1.1-batch1 | Markdown, Copy + Regen, tok/s + ctx, AI naming | ✅ Merged |
|
||||
| v1.1-batch2 | Sidebar restructure | ✅ Merged |
|
||||
| v1.1-batch3 | Pane system, FileBrowserPane + Shiki, cross-tab | ✅ Merged |
|
||||
| v1.1-batch3.5 | Chip infra, `@file`, line-select | ✅ Merged |
|
||||
| v1.2 | Chats inside sessions, right-rail, `/compact`, archive, force-send | ✅ Merged |
|
||||
| v1.2-project-ux | Project archive, sidebar context, Gitea API, bootstrap | ✅ Merged |
|
||||
| v1.3 | Tab-close + chat-archive | ✅ Merged |
|
||||
| v1.4 | Fork message, delete message, header polish (was original Batch 5) | ✅ Merged |
|
||||
| v1.5 | resolveProjectPath, BOOTSTRAP_ROOT, vitest pin | ✅ Merged |
|
||||
| v1.5.1 | Bootstrap hotfix (git in container, SSH keypair, known_hosts) | ✅ Merged (`4a9f207`) |
|
||||
| v1.6 | Mobile pass: drawer, single-pane, long-press, IME-safe, pull-to-refresh, swipe-close | ✅ Merged |
|
||||
| v1.6.1 | RightRail mobile wrapper fix | ✅ Merged |
|
||||
| Tool-loop bump | MAX_TOOL_LOOP_DEPTH 5→15 | ✅ Merged |
|
||||
| v1.6.2 | Workspace + Session+Project headers, ChatTabBar new-chat, RightRail mobile drawer | ✅ Merged |
|
||||
| v1.7 | Drag-drop file + paste-as-attachment (was Batch 6) | ✅ Merged |
|
||||
| v1.8 | Settings drawer + `git_status` added to ALL_TOOL_NAMES (was Batch 7) | ✅ Merged |
|
||||
| v1.8.1 | WS reconnect toast tuning (silent/gray/red thresholds), pane status indicators | ✅ Merged |
|
||||
| v1.8.2 | Tool-loop fixes: read-only cap raised, "depth exceeded" error + continue, `max_tool_calls` frontmatter, `messages.metadata` | ✅ Merged |
|
||||
| **v1.x-themes** | **18 themes, settings page, dark/light/system, FOUC mitigation** | **🔄 Claude Code in flight** |
|
||||
| v1.8.3 | Tool call UI compaction: collapse-by-default, group consecutive same-tool, result preview cap | Planned (small, frontend-only) |
|
||||
| v1.9 | Settings pane (system prompt per project + session, web search toggle, `+` button) | Planned (spec locked, was on branch `v1.9-settings-pane`) |
|
||||
| v1.10 | Web search backend: SearXNG `web_search` + `web_fetch` | Planned |
|
||||
| v1.11 | Agents Tier 2: `AGENTS.md`, per-agent temp/tools whitelist, AgentPicker in ChatInput | Planned |
|
||||
| v1.12 | BooTerm: separate container, xterm.js + node-pty + tmux | Planned |
|
||||
| v1.13 | Architect: codecontext sidecar (MCP, tree-sitter, no embeddings) | Planned |
|
||||
| v1.13b | Architect: repo health (call graph, circular deps, dead code) | Planned |
|
||||
| v1.14 | Tool approval + plan/act mode (cline-style) | Planned |
|
||||
| Post-v1.x | Append-only event log (OpenHands V1) | Planned |
|
||||
| Post-v1.x | BooCoder pending-changes (plandex) | Planned |
|
||||
| Post-v1.x | BooCoder runtime isolation (per-session Docker sandbox) | Planned |
|
||||
| Optional | Multi-provider LLM abstraction (pi-ai) | Skip unless need surfaces |
|
||||
| Far future | Workflow graphs (microsoft/agent-framework concepts) | v2.x topic |
|
||||
|
||||
## Flagged follow-ups (not in a batch yet)
|
||||
|
||||
- Agents in `/data/AGENTS.md` don't list `git_status` in their `tools:` blocks. Out of scope until pre-BooCoder cleanup pass.
|
||||
- v1.9 dispatch had item (g): verify `useUserEvents` broadcasts `project_updated` on PATCH `/projects/:id`. Add if missing.
|
||||
- v1.8.2 follow-up: confirm `messages.metadata` migration ran clean in prod DB after deploy.
|
||||
|
||||
## Order of operations
|
||||
|
||||
Two tracks. Pick one to drive next.
|
||||
1. **v1.x-themes** finishes (Claude Code in flight). Audit + smoke test. Merge.
|
||||
2. **v1.8.3** — tool call UI compaction. Small frontend batch, addresses current pain.
|
||||
3. **v1.9** — settings pane. Branch already named `v1.9-settings-pane`. Spec locked.
|
||||
4. **v1.10** — web search backend.
|
||||
5. **v1.11** — agents.
|
||||
6. **v1.12** — BooTerm.
|
||||
|
||||
**Track A — Finish v1.x mobile + polish then agents:**
|
||||
|
||||
- v1.6.2 ships (in flight)
|
||||
- **Batch 9 (agents)** — decoupled, can land next; no UI dependency on 5/6/7
|
||||
- Batches 5, 6, 7, 8 in order. Each is small, frontend-heavy, no architecture risk. Batch 7 absorbs SettingsDrawer agent entry.
|
||||
|
||||
**Track B — Begin architect capabilities in parallel:**
|
||||
|
||||
- Batch 11 (codecontext sidecar) — biggest single capability jump. Frontend stays the same; new tools appear to the model.
|
||||
- Batch 11b (repo health) — follow-up.
|
||||
- Batch 12 (gating) — sets up everything post-v1.x.
|
||||
|
||||
Recommendation: ship v1.6.2, then **Batch 9 (agents)** next so the test bed exists before Track A continues. Then Track A through Batch 7. Batch 11 can run in parallel with Batches 8–10 since 11 has no UI dependency.
|
||||
|
||||
-----
|
||||
Track B (architect, no UI dep, can run parallel anytime): v1.13 → v1.13b → v1.14.
|
||||
|
||||
## Architecture target state
|
||||
|
||||
### Containers
|
||||
| Container | Port | Mount | Purpose | Status |
|
||||
|---|---|---|---|---|
|
||||
| `boocode` | `100.114.205.53:9500` | `/opt:/opt:ro` | Chat + read-only tools + SPA | Live |
|
||||
| `boocode_db` | `127.0.0.1:5500` | `boocode_pgdata` volume | Postgres 16-alpine | Live |
|
||||
| `codecontext` | `100.114.205.53:8765` (internal) | project root :ro | MCP server for architect tools | v1.13 |
|
||||
| `booterm` | `100.114.205.53:9501` | `/opt/repos:/opt/repos:rw` | Terminals (tmux + node-pty) | v1.12 |
|
||||
| `boocoder` | `100.114.205.53:9502` | per-session sandbox | Write tools | Post-v1.x |
|
||||
|
||||
|Container |Port |Mount |Purpose |Status |
|
||||
|-------------|--------------------------------|--------------------------|------------------------------|--------|
|
||||
|`boocode` |`100.114.205.53:9500` |`/opt:/opt:ro` |Chat + read-only tools + SPA |Live |
|
||||
|`boocode_db` |`127.0.0.1:5500` |`boocode_pgdata` volume |Postgres 16-alpine |Live |
|
||||
|`codecontext`|`100.114.205.53:8765` (internal)|project root :ro |MCP server for architect tools|Batch 11|
|
||||
|`booterm` |`100.114.205.53:9501` |`/opt/repos:/opt/repos:rw`|Terminals (tmux + node-pty) |Batch 10|
|
||||
|`boocoder` |`100.114.205.53:9502` |per-session sandbox |Write tools |Batch 14|
|
||||
## Schema additions ahead
|
||||
|
||||
### Schema additions
|
||||
|
||||
**Batch 9:** `sessions.agent_id TEXT` (nullable; references AGENTS.md by slug).
|
||||
**Batch 11:** none (codecontext stateless on disk).
|
||||
**Batch 11b:** `repo_health_cache (project_id, file_hashes_sig, payload JSONB, created_at)`.
|
||||
**Batch 12:** `sessions.tool_approval_mode`, `sessions.approved_tools`.
|
||||
**Batch 13:** `session_events`; deprecate `messages` long-tail.
|
||||
**Batch 14:** `pending_changes`.
|
||||
|
||||
-----
|
||||
|
||||
## Lift sources (summary)
|
||||
|
||||
Full inventory in `boocode_code_review.md`. Headline items:
|
||||
|
||||
|Source |Used for |Where |
|
||||
|--------------------------------------|----------------------------------------|---------------------|
|
||||
|nmakod/codecontext (MIT, Go) |Architect: codebase map sidecar |Batch 11 |
|
||||
|spirituslab/codesight (MIT-ish, TS) |Architect: repo health analyzer |Batch 11b |
|
||||
|Aider-AI/aider (Apache-2.0) |Fallback `.scm` grammars (60+ languages)|Batch 11 (fallback) |
|
||||
|continuedev/continue (Apache-2.0) |DEFAULT_SECURITY_IGNORE_FILETYPES |Batch 11 prep |
|
||||
|cline/cline (Apache-2.0) |Plan/Act mode pattern |Batch 12 |
|
||||
|plandex-ai/plandex (MIT) |Pending-changes data model |Batch 14 |
|
||||
|OpenHands/OpenHands (MIT) |Event log + sandbox runtime |Batches 13, 15 |
|
||||
|aimasteracc/tree-sitter-analyzer (MIT)|Outline-first response patterns |Reference |
|
||||
|earendil-works/pi (MIT) |Multi-provider LLM |Batch 16 (optional) |
|
||||
|rshah515/claude-code-subagents (MIT) |Reference for builtin agent prompts |Batch 9 (six builtins)|
|
||||
|microsoft/agent-framework (MIT) |Workflow concepts |Batch 17 (far future)|
|
||||
|
||||
-----
|
||||
- v1.x-themes (current): `settings.theme_id`, `settings.theme_mode`
|
||||
- v1.9: `projects.default_system_prompt`, `projects.default_web_search_enabled`, `sessions.web_search_enabled`
|
||||
- v1.11: `sessions.agent_id`
|
||||
- v1.13b: `repo_health_cache (project_id, file_hashes_sig, payload JSONB, created_at)`
|
||||
- v1.14: `sessions.tool_approval_mode`, `sessions.approved_tools`
|
||||
- Post-v1.x: `session_events`; deprecate `messages` long-tail
|
||||
- Post-v1.x: `pending_changes`
|
||||
|
||||
## Decisions log
|
||||
|
||||
- **Embeddings dropped from BooCode.** Replaced RAG with file-view tools + sidecar analyzers.
|
||||
- **Original Batch 11 (aider PageRank port) replaced** by codecontext sidecar approach.
|
||||
- **Original Batch 12 (codebase indexer w/ Harrier) removed** entirely. No embedding infrastructure in BooCode v1.x.
|
||||
- **Globstar parked** — not an architect tool, future verify-before-commit candidate only.
|
||||
- **codeprysm rejected** — embedding-based; node/edge taxonomy noted as reference if we ever build our own graph.
|
||||
- **Batch 9 decoupled from Batch 7 (2026-05-16).** AgentPicker mounts in `ChatInput.tsx` toolbar only. SettingsDrawer agent entry and Header active-agent badge moved to Batch 7. Builtin defaults shipped: six agents (Code Reviewer, Debugger, Refactorer, Architect, Security Auditor, Prompt Builder) with no `model` field — session model wins by default.
|
||||
|
||||
## Follow-ups (post-ship docs / cleanup)
|
||||
|
||||
- **After v1.8.2 ships:** Add explicit `max_tool_calls: 30` to all 6 agents in `/data/AGENTS.md` and `/opt/boocode/AGENTS.md`. Purely for documentation/discoverability — defaults handle behavior identically (all 6 agents use only read-only tools, default is already 30).
|
||||
|
||||
-----
|
||||
- Embeddings dropped from BooCode. File-view tools + sidecar analyzers replace RAG.
|
||||
- Old Batch 11 (aider PageRank port) → replaced by codecontext sidecar (v1.13).
|
||||
- Old Batch 12 (Harrier indexer) → removed entirely.
|
||||
- Batch 9 reordered ahead of 5–8, decoupled from Batch 7 (2026-05-16). Subsequently superseded — settings pane (v1.9) and themes (v1.x-themes) jumped ahead. Agents now slated as v1.11.
|
||||
- Theme work split into its own version (v1.x-themes) rather than blocked behind v1.9 (2026-05-17). Branched off main after v1.8.2 committed.
|
||||
|
||||
## Workflow
|
||||
|
||||
Each batch:
|
||||
|
||||
1. Verify previous batch merged.
|
||||
2. Dispatch via Paseo to Claude Code at `/opt/boocode`.
|
||||
3. Claude Code recon → blocking questions → implement → hand back.
|
||||
1. Verify previous merged.
|
||||
2. Dispatch via Paseo to Claude Code at `/opt/boocode` (or OpenCode for smaller batches).
|
||||
3. Recon → blocking questions → implement → hand back.
|
||||
4. Compliance review in separate Claude chat.
|
||||
5. Deploy: `docker compose up --build -d`.
|
||||
6. Smoke test.
|
||||
|
||||
@@ -9,15 +9,11 @@ services:
|
||||
environment:
|
||||
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boocode
|
||||
volumes:
|
||||
# Read-only mount for legacy/existing project add-existing flow.
|
||||
- /opt:/opt:ro
|
||||
# Writable mount only for the create-new-project bootstrap target.
|
||||
# Host must `mkdir -p /opt/projects` before container start.
|
||||
- /opt:/opt
|
||||
- /opt/projects:/opt/projects:rw
|
||||
- ./secrets/boocode_gitea:/root/.ssh/id_ed25519:ro
|
||||
# v1.8.1: global agents file. Host seeds it once before deploy:
|
||||
# cp /opt/boocode/AGENTS.md /opt/boocode/data/AGENTS.md
|
||||
- ./data:/data:ro
|
||||
- ./data:/data
|
||||
- /opt/skills:/data/skills
|
||||
depends_on:
|
||||
- boocode_db
|
||||
networks:
|
||||
|
||||
Reference in New Issue
Block a user