Compare commits
10 Commits
32c1a2b5f6
...
v1.9.1-ski
| Author | SHA1 | Date | |
|---|---|---|---|
| adb5d7b3bb | |||
| 80fd3d9fa9 | |||
| eaacd432e8 | |||
| 529a77c959 | |||
| 9a7b35b677 | |||
| 98b432ebce | |||
| 1ecccc112f | |||
| b6469055d8 | |||
| 4bf2cd40c3 | |||
| 09aecc4ee9 |
@@ -10,3 +10,13 @@ dist
|
|||||||
.vite
|
.vite
|
||||||
coverage
|
coverage
|
||||||
/tmp
|
/tmp
|
||||||
|
|
||||||
|
# Secrets and runtime data
|
||||||
|
secrets/
|
||||||
|
data/
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
id_rsa*
|
||||||
|
id_ed25519*
|
||||||
|
known_hosts
|
||||||
|
.ssh/
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
## Code Reviewer
|
## Code Reviewer
|
||||||
---
|
---
|
||||||
temperature: 0.3
|
temperature: 0.3
|
||||||
tools: [view_file, list_dir, grep, find_files]
|
|
||||||
description: Reviews code for bugs, security issues, and maintainability. Read-only.
|
description: Reviews code for bugs, security issues, and maintainability. Read-only.
|
||||||
---
|
---
|
||||||
You review code. Find real problems, not style nits.
|
You review code. Find real problems, not style nits.
|
||||||
@@ -33,7 +32,6 @@ If nothing critical or major, say so in one line. Do not pad.
|
|||||||
## Debugger
|
## Debugger
|
||||||
---
|
---
|
||||||
temperature: 0.2
|
temperature: 0.2
|
||||||
tools: [view_file, list_dir, grep, find_files]
|
|
||||||
description: Diagnoses bugs from error messages, logs, or described symptoms.
|
description: Diagnoses bugs from error messages, logs, or described symptoms.
|
||||||
---
|
---
|
||||||
You diagnose bugs. Form a hypothesis, prove it with evidence from the code.
|
You diagnose bugs. Form a hypothesis, prove it with evidence from the code.
|
||||||
@@ -62,7 +60,6 @@ Output:
|
|||||||
## Refactorer
|
## Refactorer
|
||||||
---
|
---
|
||||||
temperature: 0.3
|
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.
|
description: Proposes refactors for clarity, deduplication, or decoupling. Read-only — outputs plans, not edits.
|
||||||
---
|
---
|
||||||
You propose refactors. You do not apply them. The user applies via OpenCode or Claude Code.
|
You propose refactors. You do not apply them. The user applies via OpenCode or Claude Code.
|
||||||
@@ -95,7 +92,6 @@ Output:
|
|||||||
## Architect
|
## Architect
|
||||||
---
|
---
|
||||||
temperature: 0.5
|
temperature: 0.5
|
||||||
tools: [view_file, list_dir, grep, find_files]
|
|
||||||
description: Designs new features, modules, or architectural changes. Outputs a build plan.
|
description: Designs new features, modules, or architectural changes. Outputs a build plan.
|
||||||
---
|
---
|
||||||
You design. You produce build plans, not code.
|
You design. You produce build plans, not code.
|
||||||
@@ -128,7 +124,6 @@ Output:
|
|||||||
## Security Auditor
|
## Security Auditor
|
||||||
---
|
---
|
||||||
temperature: 0.2
|
temperature: 0.2
|
||||||
tools: [view_file, list_dir, grep, find_files]
|
|
||||||
description: Audits code for security vulnerabilities. Read-only.
|
description: Audits code for security vulnerabilities. Read-only.
|
||||||
---
|
---
|
||||||
You audit for security issues. Concrete findings only, no generic warnings.
|
You audit for security issues. Concrete findings only, no generic warnings.
|
||||||
@@ -165,7 +160,6 @@ If the code is clean, say so. Do not invent findings.
|
|||||||
## Prompt Builder
|
## Prompt Builder
|
||||||
---
|
---
|
||||||
temperature: 0.4
|
temperature: 0.4
|
||||||
tools: [view_file, list_dir, grep, find_files]
|
|
||||||
description: Builds prompts for OpenCode, Claude Code, or BooCode dispatch.
|
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.
|
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 { registerWebSocket } from './routes/ws.js';
|
||||||
import { registerModelRoutes } from './routes/models.js';
|
import { registerModelRoutes } from './routes/models.js';
|
||||||
import { registerAgentRoutes } from './routes/agents.js';
|
import { registerAgentRoutes } from './routes/agents.js';
|
||||||
|
import { registerSkillsRoutes } from './routes/skills.js';
|
||||||
import { createInferenceRunner } from './services/inference.js';
|
import { createInferenceRunner } from './services/inference.js';
|
||||||
import { createBroker } from './services/broker.js';
|
import { createBroker } from './services/broker.js';
|
||||||
|
import { listSkills } from './services/skills.js';
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
@@ -62,6 +64,15 @@ async function main() {
|
|||||||
registerSidebarRoutes(app, sql);
|
registerSidebarRoutes(app, sql);
|
||||||
registerChatRoutes(app, sql, broker);
|
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(
|
const inference = createInferenceRunner(
|
||||||
{
|
{
|
||||||
sql,
|
sql,
|
||||||
@@ -113,6 +124,33 @@ async function main() {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
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);
|
registerWebSocket(app, sql, broker);
|
||||||
|
|
||||||
const webDist = process.env.WEB_DIST_PATH ?? resolve(process.cwd(), '../web/dist');
|
const webDist = process.env.WEB_DIST_PATH ?? resolve(process.cwd(), '../web/dist');
|
||||||
|
|||||||
@@ -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 } }>(
|
app.post<{ Params: { id: string } }>(
|
||||||
'/api/chats/:id/archive',
|
'/api/chats/:id/archive',
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
|
|||||||
@@ -22,8 +22,14 @@ const AddProjectBody = z.object({
|
|||||||
name: z.string().min(1).optional(),
|
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({
|
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({
|
const CreateProjectBody = z.object({
|
||||||
@@ -70,7 +76,8 @@ export function registerProjectRoutes(
|
|||||||
app.get<{ Querystring: { status?: string } }>('/api/projects', async (req) => {
|
app.get<{ Querystring: { status?: string } }>('/api/projects', async (req) => {
|
||||||
const status = req.query.status === 'archived' ? 'archived' : 'open';
|
const status = req.query.status === 'archived' ? 'archived' : 'open';
|
||||||
const rows = await sql<Project[]>`
|
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
|
FROM projects
|
||||||
WHERE status = ${status}
|
WHERE status = ${status}
|
||||||
ORDER BY added_at DESC
|
ORDER BY added_at DESC
|
||||||
@@ -119,7 +126,8 @@ export function registerProjectRoutes(
|
|||||||
const [row] = await sql<Project[]>`
|
const [row] = await sql<Project[]>`
|
||||||
INSERT INTO projects (name, path, gitea_remote)
|
INSERT INTO projects (name, path, gitea_remote)
|
||||||
VALUES (${parsed.data.name}, ${bootstrap.folder_real_path}, ${bootstrap.gitea_remote_url})
|
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 });
|
broker.publishUser('default', { type: 'project_created', project: row as unknown as Project });
|
||||||
reply.code(201);
|
reply.code(201);
|
||||||
@@ -173,7 +181,8 @@ export function registerProjectRoutes(
|
|||||||
INSERT INTO projects (name, path)
|
INSERT INTO projects (name, path)
|
||||||
VALUES (${name}, ${resolved.real})
|
VALUES (${name}, ${resolved.real})
|
||||||
ON CONFLICT (path) DO UPDATE SET status = 'open'
|
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) {
|
if (existing.length === 0) {
|
||||||
@@ -187,22 +196,53 @@ export function registerProjectRoutes(
|
|||||||
return row;
|
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) => {
|
app.patch<{ Params: { id: string } }>('/api/projects/:id', async (req, reply) => {
|
||||||
const parsed = PatchProjectBody.safeParse(req.body);
|
const parsed = PatchProjectBody.safeParse(req.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
reply.code(400);
|
reply.code(400);
|
||||||
return { error: 'invalid body', details: parsed.error.flatten() };
|
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[]>`
|
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}
|
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) {
|
if (rows.length === 0) {
|
||||||
reply.code(404);
|
reply.code(404);
|
||||||
return { error: 'not found' };
|
return { error: 'not found' };
|
||||||
}
|
}
|
||||||
const project = rows[0]!;
|
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', {
|
broker.publishUser('default', {
|
||||||
type: 'project_updated',
|
type: 'project_updated',
|
||||||
project_id: project.id,
|
project_id: project.id,
|
||||||
@@ -229,7 +269,8 @@ export function registerProjectRoutes(
|
|||||||
const rows = await sql<Project[]>`
|
const rows = await sql<Project[]>`
|
||||||
UPDATE projects SET status = 'open'
|
UPDATE projects SET status = 'open'
|
||||||
WHERE id = ${req.params.id} AND status = 'archived'
|
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) {
|
if (rows.length === 0) {
|
||||||
reply.code(404);
|
reply.code(404);
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ const PatchBody = z.object({
|
|||||||
model: z.string().min(1).max(200).optional(),
|
model: z.string().min(1).max(200).optional(),
|
||||||
system_prompt: z.string().max(8000).optional(),
|
system_prompt: z.string().max(8000).optional(),
|
||||||
agent_id: z.string().min(1).max(200).nullable().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> {
|
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 status = req.query.status === 'archived' ? 'archived' : 'open';
|
||||||
const rows = await sql<Session[]>`
|
const rows = await sql<Session[]>`
|
||||||
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id
|
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled
|
||||||
FROM sessions
|
FROM sessions
|
||||||
WHERE project_id = ${req.params.id} AND status = ${status}
|
WHERE project_id = ${req.params.id} AND status = ${status}
|
||||||
ORDER BY updated_at DESC
|
ORDER BY updated_at DESC
|
||||||
@@ -100,7 +102,7 @@ export function registerSessionRoutes(
|
|||||||
const [session] = await tx<Session[]>`
|
const [session] = await tx<Session[]>`
|
||||||
INSERT INTO sessions (project_id, name, model, system_prompt, agent_id)
|
INSERT INTO sessions (project_id, name, model, system_prompt, agent_id)
|
||||||
VALUES (${req.params.id}, ${name}, ${model}, ${systemPrompt}, ${agentId})
|
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`
|
await tx`
|
||||||
INSERT INTO chats (session_id, name, status)
|
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) => {
|
app.get<{ Params: { id: string } }>('/api/sessions/:id', async (req, reply) => {
|
||||||
const rows = await sql<Session[]>`
|
const rows = await sql<Session[]>`
|
||||||
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id
|
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}
|
FROM sessions WHERE id = ${req.params.id}
|
||||||
`;
|
`;
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
@@ -139,10 +141,13 @@ export function registerSessionRoutes(
|
|||||||
return { error: 'invalid body', details: parsed.error.flatten() };
|
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||||
}
|
}
|
||||||
const { name, model, system_prompt } = parsed.data;
|
const { name, model, system_prompt } = parsed.data;
|
||||||
// agent_id is tri-state on the wire: omitted = no change, null = clear,
|
// agent_id and web_search_enabled are both tri-state on the wire: omitted
|
||||||
// string = set. CASE WHEN inside SET handles all three atomically.
|
// = no change, null = clear/inherit, value = set. CASE WHEN inside SET
|
||||||
|
// handles all three atomically.
|
||||||
const agentIdProvided = parsed.data.agent_id !== undefined;
|
const agentIdProvided = parsed.data.agent_id !== undefined;
|
||||||
const newAgentId = parsed.data.agent_id ?? null;
|
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
|
// 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
|
// (PATCH { name: "Foo" } where the session is already "Foo"). The window
|
||||||
// between SELECT and UPDATE is sub-millisecond in the same request handler;
|
// between SELECT and UPDATE is sub-millisecond in the same request handler;
|
||||||
@@ -159,9 +164,11 @@ export function registerSessionRoutes(
|
|||||||
model = COALESCE(${model ?? null}, model),
|
model = COALESCE(${model ?? null}, model),
|
||||||
system_prompt = COALESCE(${system_prompt ?? null}, system_prompt),
|
system_prompt = COALESCE(${system_prompt ?? null}, system_prompt),
|
||||||
agent_id = CASE WHEN ${agentIdProvided} THEN ${newAgentId} ELSE agent_id END,
|
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()
|
updated_at = clock_timestamp()
|
||||||
WHERE id = ${req.params.id}
|
WHERE id = ${req.params.id}
|
||||||
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id
|
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at,
|
||||||
|
agent_id, web_search_enabled
|
||||||
`;
|
`;
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
reply.code(404);
|
reply.code(404);
|
||||||
@@ -175,10 +182,69 @@ export function registerSessionRoutes(
|
|||||||
name: session.name,
|
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;
|
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 } }>(
|
app.post<{ Params: { id: string } }>(
|
||||||
'/api/sessions/:id/archive',
|
'/api/sessions/:id/archive',
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
@@ -207,7 +273,7 @@ export function registerSessionRoutes(
|
|||||||
const rows = await sql<Session[]>`
|
const rows = await sql<Session[]>`
|
||||||
UPDATE sessions SET status = 'open', updated_at = clock_timestamp()
|
UPDATE sessions SET status = 'open', updated_at = clock_timestamp()
|
||||||
WHERE id = ${req.params.id} AND status = 'archived'
|
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) {
|
if (rows.length === 0) {
|
||||||
reply.code(404);
|
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).
|
-- 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_id', '"obsidian"') ON CONFLICT (key) DO NOTHING;
|
||||||
INSERT INTO settings (key, value) VALUES ('theme_mode', '"dark"') 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(),
|
created_at: new Date(0).toISOString(),
|
||||||
updated_at: new Date(0).toISOString(),
|
updated_at: new Date(0).toISOString(),
|
||||||
agent_id: null,
|
agent_id: null,
|
||||||
|
web_search_enabled: null,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -35,6 +36,8 @@ function makeProject(overrides: Partial<Project> = {}): Project {
|
|||||||
last_session_id: null,
|
last_session_id: null,
|
||||||
status: 'open',
|
status: 'open',
|
||||||
gitea_remote: null,
|
gitea_remote: null,
|
||||||
|
default_system_prompt: '',
|
||||||
|
default_web_search_enabled: false,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,14 @@ const GLOBAL_AGENTS_PATH = '/data/AGENTS.md';
|
|||||||
const CACHE_TTL_MS = 60_000;
|
const CACHE_TTL_MS = 60_000;
|
||||||
|
|
||||||
// Tools whitelist universe matches services/tools.ts ALL_TOOLS. Keep in sync.
|
// 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.
|
||||||
|
const ALL_TOOL_NAMES = [
|
||||||
|
'view_file', 'list_dir', 'grep', 'find_files', 'git_status',
|
||||||
|
'skill_find', 'skill_use', 'skill_resource',
|
||||||
|
] as const;
|
||||||
const DEFAULT_TOOLS: string[] = [...ALL_TOOL_NAMES];
|
const DEFAULT_TOOLS: string[] = [...ALL_TOOL_NAMES];
|
||||||
const DEFAULT_TEMPERATURE = 0.7;
|
const DEFAULT_TEMPERATURE = 0.7;
|
||||||
|
|
||||||
|
|||||||
@@ -149,9 +149,11 @@ export interface InferenceContext {
|
|||||||
publishUser: (frame: UserStreamFrame) => void;
|
publishUser: (frame: UserStreamFrame) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolution order: base prompt < agent.system_prompt < session.system_prompt.
|
// Resolution order: base prompt < agent.system_prompt < user prompt, where
|
||||||
// Agent prompts layer on top of the base; session prompt is the most specific
|
// user prompt = session.system_prompt if non-empty, else project's
|
||||||
// override and stacks last so callers can append per-session instructions.
|
// 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(
|
export function buildSystemPrompt(
|
||||||
project: Project,
|
project: Project,
|
||||||
session: Session,
|
session: Session,
|
||||||
@@ -161,8 +163,11 @@ export function buildSystemPrompt(
|
|||||||
if (agent && agent.system_prompt.trim().length > 0) {
|
if (agent && agent.system_prompt.trim().length > 0) {
|
||||||
out += '\n\n' + agent.system_prompt.trim();
|
out += '\n\n' + agent.system_prompt.trim();
|
||||||
}
|
}
|
||||||
if (session.system_prompt && session.system_prompt.trim().length > 0) {
|
const sessionPrompt = session.system_prompt?.trim() ?? '';
|
||||||
out += '\n\n' + 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;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -240,14 +245,16 @@ async function loadContext(
|
|||||||
chatId: string
|
chatId: string
|
||||||
): Promise<{ session: Session; project: Project; history: Message[] } | null> {
|
): Promise<{ session: Session; project: Project; history: Message[] } | null> {
|
||||||
const sessionRows = await sql<Session[]>`
|
const sessionRows = await sql<Session[]>`
|
||||||
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, 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}
|
FROM sessions WHERE id = ${sessionId}
|
||||||
`;
|
`;
|
||||||
if (sessionRows.length === 0) return null;
|
if (sessionRows.length === 0) return null;
|
||||||
const session = sessionRows[0]!;
|
const session = sessionRows[0]!;
|
||||||
|
|
||||||
const projectRows = await sql<Project[]>`
|
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}
|
FROM projects WHERE id = ${session.project_id}
|
||||||
`;
|
`;
|
||||||
if (projectRows.length === 0) return null;
|
if (projectRows.length === 0) return null;
|
||||||
|
|||||||
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 { pathGuard, PathScopeError } from './path_guard.js';
|
||||||
import { grep as fileOpsGrep, findFiles as fileOpsFindFiles } from './file_ops.js';
|
import { grep as fileOpsGrep, findFiles as fileOpsFindFiles } from './file_ops.js';
|
||||||
import { getGitMeta } from './git_meta.js';
|
import { getGitMeta } from './git_meta.js';
|
||||||
|
import { findSkills, getSkillBody, getSkillResource } from './skills.js';
|
||||||
|
|
||||||
const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
||||||
const DEFAULT_VIEW_LINES = 200;
|
const DEFAULT_VIEW_LINES = 200;
|
||||||
@@ -300,12 +301,119 @@ 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 };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const ALL_TOOLS: ReadonlyArray<ToolDef<unknown>> = [
|
export const ALL_TOOLS: ReadonlyArray<ToolDef<unknown>> = [
|
||||||
viewFile as ToolDef<unknown>,
|
viewFile as ToolDef<unknown>,
|
||||||
listDir as ToolDef<unknown>,
|
listDir as ToolDef<unknown>,
|
||||||
grep as ToolDef<unknown>,
|
grep as ToolDef<unknown>,
|
||||||
findFiles as ToolDef<unknown>,
|
findFiles as ToolDef<unknown>,
|
||||||
gitStatus as ToolDef<unknown>,
|
gitStatus as ToolDef<unknown>,
|
||||||
|
skillFind as ToolDef<unknown>,
|
||||||
|
skillUse as ToolDef<unknown>,
|
||||||
|
skillResource as ToolDef<unknown>,
|
||||||
];
|
];
|
||||||
|
|
||||||
// v1.8.2: forward-compatible read-only whitelist. An agent whose `tools` is
|
// v1.8.2: forward-compatible read-only whitelist. An agent whose `tools` is
|
||||||
@@ -313,12 +421,16 @@ export const ALL_TOOLS: ReadonlyArray<ToolDef<unknown>> = [
|
|||||||
// anything outside means the agent can mutate state and gets a tighter
|
// 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
|
// 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.
|
// non-RO branch only takes effect once BooCoder lands write tools.
|
||||||
|
// Batch 9.6: skill_* added; all still read-only.
|
||||||
export const READ_ONLY_TOOL_NAMES = [
|
export const READ_ONLY_TOOL_NAMES = [
|
||||||
'view_file',
|
'view_file',
|
||||||
'list_dir',
|
'list_dir',
|
||||||
'grep',
|
'grep',
|
||||||
'find_files',
|
'find_files',
|
||||||
'git_status',
|
'git_status',
|
||||||
|
'skill_find',
|
||||||
|
'skill_use',
|
||||||
|
'skill_resource',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries(
|
export const TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries(
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ export interface Project {
|
|||||||
last_session_id: string | null;
|
last_session_id: string | null;
|
||||||
status: ProjectStatus;
|
status: ProjectStatus;
|
||||||
gitea_remote: string | null;
|
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 {
|
export interface AvailableProject {
|
||||||
@@ -29,6 +35,10 @@ export interface Session {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
agent_id: string | null;
|
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
|
// v1.8.1: agents come from two sources. 'global' = /data/AGENTS.md (always
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
ViewFileResult,
|
ViewFileResult,
|
||||||
AgentsResponse,
|
AgentsResponse,
|
||||||
GitMeta,
|
GitMeta,
|
||||||
|
Skill,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
@@ -51,15 +52,29 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(body),
|
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}`, {
|
request<Project>(`/api/projects/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}),
|
}),
|
||||||
|
get: (id: string) => request<Project>(`/api/projects/${id}`),
|
||||||
archive: (id: string) =>
|
archive: (id: string) =>
|
||||||
request<void>(`/api/projects/${id}/archive`, { method: 'POST' }),
|
request<void>(`/api/projects/${id}/archive`, { method: 'POST' }),
|
||||||
unarchive: (id: string) =>
|
unarchive: (id: string) =>
|
||||||
request<Project>(`/api/projects/${id}/unarchive`, { method: 'POST' }),
|
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: {
|
create: (body: {
|
||||||
name: string;
|
name: string;
|
||||||
commit_message?: string;
|
commit_message?: string;
|
||||||
@@ -106,7 +121,7 @@ export const api = {
|
|||||||
get: (id: string) => request<Session>(`/api/sessions/${id}`),
|
get: (id: string) => request<Session>(`/api/sessions/${id}`),
|
||||||
update: (
|
update: (
|
||||||
id: string,
|
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}`, {
|
request<Session>(`/api/sessions/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
@@ -118,6 +133,15 @@ export const api = {
|
|||||||
request<void>(`/api/sessions/${id}/archive`, { method: 'POST' }),
|
request<void>(`/api/sessions/${id}/archive`, { method: 'POST' }),
|
||||||
unarchive: (id: string) =>
|
unarchive: (id: string) =>
|
||||||
request<Session>(`/api/sessions/${id}/unarchive`, { method: 'POST' }),
|
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: {
|
chats: {
|
||||||
@@ -164,6 +188,20 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ message_id: body.messageId, name: body.name }),
|
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 }),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
messages: {
|
messages: {
|
||||||
@@ -195,6 +233,10 @@ export const api = {
|
|||||||
request<AgentsResponse>(`/api/projects/${projectId}/agents`),
|
request<AgentsResponse>(`/api/projects/${projectId}/agents`),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
skills: {
|
||||||
|
list: () => request<{ skills: Skill[] }>('/api/skills'),
|
||||||
|
},
|
||||||
|
|
||||||
settings: {
|
settings: {
|
||||||
get: () => request<Record<string, unknown>>('/api/settings'),
|
get: () => request<Record<string, unknown>>('/api/settings'),
|
||||||
patch: (body: Record<string, unknown>) =>
|
patch: (body: Record<string, unknown>) =>
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ export interface Project {
|
|||||||
last_session_id: string | null;
|
last_session_id: string | null;
|
||||||
status: ProjectStatus;
|
status: ProjectStatus;
|
||||||
gitea_remote: string | null;
|
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 {
|
export interface AvailableProject {
|
||||||
@@ -28,6 +32,8 @@ export interface Session {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
agent_id: string | null;
|
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
|
// v1.8.1: 'global' = /data/AGENTS.md (always-on), 'project' = per-project
|
||||||
@@ -225,7 +231,20 @@ export interface GitMeta {
|
|||||||
behind: number;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
export interface WorkspacePane {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import { useCallback, useEffect, useRef, useState, type DragEvent, type KeyboardEvent } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type KeyboardEvent } from 'react';
|
||||||
import { Send } from 'lucide-react';
|
import { Check, Plus, Send } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
import {
|
import {
|
||||||
flattenToMessage,
|
flattenToMessage,
|
||||||
inferLanguage,
|
inferLanguage,
|
||||||
@@ -16,8 +22,10 @@ import { AttachmentPreviewModal } from '@/components/AttachmentPreviewModal';
|
|||||||
import { FileMentionPopover } from '@/components/FileMentionPopover';
|
import { FileMentionPopover } from '@/components/FileMentionPopover';
|
||||||
import { DropOverlay } from '@/components/DropOverlay';
|
import { DropOverlay } from '@/components/DropOverlay';
|
||||||
import { AgentPicker } from '@/components/AgentPicker';
|
import { AgentPicker } from '@/components/AgentPicker';
|
||||||
|
import { SkillSlashCommand } from '@/components/SkillSlashCommand';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
|
import { useSkills } from '@/hooks/useSkills';
|
||||||
import { useViewport } from '@/hooks/useViewport';
|
import { useViewport } from '@/hooks/useViewport';
|
||||||
|
|
||||||
const MAX_ATTACHMENTS = 10;
|
const MAX_ATTACHMENTS = 10;
|
||||||
@@ -29,11 +37,23 @@ interface Props {
|
|||||||
// When omitted, the toolbar row is hidden entirely.
|
// When omitted, the toolbar row is hidden entirely.
|
||||||
agentId?: string | null;
|
agentId?: string | null;
|
||||||
onAgentChange?: (agentId: string | null) => void | Promise<void>;
|
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>;
|
onSend: (content: string) => void | Promise<void>;
|
||||||
onForceSend?: (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 { isMobile } = useViewport();
|
||||||
const [value, setValue] = useState('');
|
const [value, setValue] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -48,6 +68,19 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, onSend,
|
|||||||
atIdx: number;
|
atIdx: number;
|
||||||
anchorRect: { top: number; left: number };
|
anchorRect: { top: number; left: number };
|
||||||
} | null>(null);
|
} | 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 [fileIndex, setFileIndex] = useState<string[] | null>(null);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
|
||||||
@@ -82,6 +115,31 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, onSend,
|
|||||||
const text = value.trim();
|
const text = value.trim();
|
||||||
if (!text && attachments.length === 0) return;
|
if (!text && attachments.length === 0) return;
|
||||||
if (disabled || busy) 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);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const body = flattenToMessage(attachments, text);
|
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 } {
|
function getCaretCoords(textarea: HTMLTextAreaElement): { top: number; left: number } {
|
||||||
const mirror = document.createElement('div');
|
const mirror = document.createElement('div');
|
||||||
const style = window.getComputedStyle(textarea);
|
const style = window.getComputedStyle(textarea);
|
||||||
@@ -145,6 +216,23 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, onSend,
|
|||||||
const ta = e.target;
|
const ta = e.target;
|
||||||
const pos = ta.selectionStart;
|
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
|
// Check for @ trigger
|
||||||
if (pos > 0 && newValue[pos - 1] === '@') {
|
if (pos > 0 && newValue[pos - 1] === '@') {
|
||||||
const charBefore = pos >= 2 ? newValue[pos - 2] : null;
|
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>) {
|
function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
|
||||||
if (mentionState?.open) return;
|
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
|
// IME safety: never act on Enter while an IME composition is in flight
|
||||||
// (CJK input methods commit composition via Enter). Without this, the
|
// (CJK input methods commit composition via Enter). Without this, the
|
||||||
// first Enter of a Japanese/Chinese/Korean composition would submit
|
// first Enter of a Japanese/Chinese/Korean composition would submit
|
||||||
@@ -425,16 +516,51 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, onSend,
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Batch 9 toolbar — agent picker. Sits above the input row so it
|
{/* Batch 9 toolbar — agent picker. v1.9 adds the icon-only + menu next
|
||||||
doesn't compete with the send button for vertical alignment.
|
to it for quick toggles (currently: Web search). When omitted at the
|
||||||
When Batch 7 lands, ModelPicker and the + button join this row. */}
|
callsite the row stays collapsed so nothing else has to change. */}
|
||||||
{onAgentChange && (
|
{(onAgentChange || sessionId) && (
|
||||||
<div className="px-4 pt-2 flex items-center gap-1.5">
|
<div className="px-4 pt-2 flex items-center gap-1.5">
|
||||||
<AgentPicker
|
{onAgentChange && (
|
||||||
projectId={projectId}
|
<AgentPicker
|
||||||
value={agentId ?? null}
|
projectId={projectId}
|
||||||
onChange={onAgentChange}
|
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>
|
||||||
)}
|
)}
|
||||||
<div className="px-4 py-3 flex items-end gap-2">
|
<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}
|
onClose={closeMention}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{slashState && (
|
||||||
|
<SkillSlashCommand
|
||||||
|
query={slashState.query}
|
||||||
|
skills={skills}
|
||||||
|
anchorRect={slashState.anchorRect}
|
||||||
|
onSelect={handleSlashSelect}
|
||||||
|
onClose={() => setSlashState(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Edit2,
|
Edit2,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
|
Settings as SettingsIcon,
|
||||||
Terminal,
|
Terminal,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -33,6 +34,7 @@ interface Props {
|
|||||||
function paneIcon(kind: WorkspacePane['kind']) {
|
function paneIcon(kind: WorkspacePane['kind']) {
|
||||||
if (kind === 'terminal') return <Terminal size={14} />;
|
if (kind === 'terminal') return <Terminal size={14} />;
|
||||||
if (kind === 'agent') return <Bot size={14} />;
|
if (kind === 'agent') return <Bot size={14} />;
|
||||||
|
if (kind === 'settings') return <SettingsIcon size={14} />;
|
||||||
return <MessageSquare 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 === 'chat') return 'Chat';
|
||||||
if (pane.kind === 'terminal') return 'Terminal';
|
if (pane.kind === 'terminal') return 'Terminal';
|
||||||
if (pane.kind === 'agent') return 'Agent';
|
if (pane.kind === 'agent') return 'Agent';
|
||||||
|
if (pane.kind === 'settings') return 'Settings';
|
||||||
return 'Empty';
|
return 'Empty';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Check, ChevronDown } from 'lucide-react';
|
import { Check, ChevronDown, Cpu } from 'lucide-react';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import type { ModelInfo } from '@/api/types';
|
import type { ModelInfo } from '@/api/types';
|
||||||
import {
|
import {
|
||||||
@@ -8,26 +8,94 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { BottomSheet } from '@/components/BottomSheet';
|
||||||
|
import { useViewport } from '@/hooks/useViewport';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (model: string) => void | Promise<void>;
|
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) {
|
export function ModelPicker({ value, onChange }: Props) {
|
||||||
|
const { isMobile } = useViewport();
|
||||||
const [models, setModels] = useState<ModelInfo[] | null>(null);
|
const [models, setModels] = useState<ModelInfo[] | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || models !== null) return;
|
if (!open || models !== null) return;
|
||||||
api.models()
|
api
|
||||||
|
.models()
|
||||||
.then(setModels)
|
.then(setModels)
|
||||||
.catch((err) =>
|
.catch((err) =>
|
||||||
setError(err instanceof Error ? err.message : 'failed to load models')
|
setError(err instanceof Error ? err.message : 'failed to load models'),
|
||||||
);
|
);
|
||||||
}, [open, 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 (
|
return (
|
||||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -49,7 +117,7 @@ export function ModelPicker({ value, onChange }: Props) {
|
|||||||
{models?.map((m) => (
|
{models?.map((m) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={m.id}
|
key={m.id}
|
||||||
onSelect={() => void onChange(m.id)}
|
onSelect={() => handlePick(m.id)}
|
||||||
className="font-mono text-xs"
|
className="font-mono text-xs"
|
||||||
>
|
>
|
||||||
<Check
|
<Check
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
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 { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
ContextMenuContent,
|
ContextMenuContent,
|
||||||
@@ -198,7 +199,7 @@ export function ProjectSidebar() {
|
|||||||
const rowCls = (active: boolean) =>
|
const rowCls = (active: boolean) =>
|
||||||
active ? 'bg-sidebar-accent text-sidebar-accent-foreground' : 'hover:bg-sidebar-accent/60';
|
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 { isMobile } = useViewport();
|
||||||
const pull = usePullToRefresh(() => retry(), { enabled: isMobile });
|
const pull = usePullToRefresh(() => retry(), { enabled: isMobile });
|
||||||
|
|
||||||
@@ -412,6 +413,30 @@ export function ProjectSidebar() {
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</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={() => {}} />
|
<AddProjectModal open={addOpen} onOpenChange={setAddOpen} onAdded={() => {}} />
|
||||||
|
|
||||||
<Dialog open={archiveProjectConfirm !== null} onOpenChange={(open) => { if (!open) setArchiveProjectConfirm(null); }}>
|
<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 type { Chat } from '@/api/types';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
ContextMenuContent,
|
ContextMenuContent,
|
||||||
@@ -165,7 +164,6 @@ export function SessionLandingPage({
|
|||||||
const [renameValue, setRenameValue] = useState('');
|
const [renameValue, setRenameValue] = useState('');
|
||||||
const [archiveConfirm, setArchiveConfirm] = useState<Chat | null>(null);
|
const [archiveConfirm, setArchiveConfirm] = useState<Chat | null>(null);
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<Chat | null>(null);
|
const [deleteConfirm, setDeleteConfirm] = useState<Chat | null>(null);
|
||||||
const [deleteInput, setDeleteInput] = useState('');
|
|
||||||
|
|
||||||
const openChats = chats
|
const openChats = chats
|
||||||
.filter((c) => c.status === 'open')
|
.filter((c) => c.status === 'open')
|
||||||
@@ -193,9 +191,6 @@ export function SessionLandingPage({
|
|||||||
setRenamingId(null);
|
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
|
// 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.
|
// visible chats won't update the per-row stats until next mount/navigation.
|
||||||
return (
|
return (
|
||||||
@@ -217,7 +212,7 @@ export function SessionLandingPage({
|
|||||||
onCancelRename={() => setRenamingId(null)}
|
onCancelRename={() => setRenamingId(null)}
|
||||||
onContextStartRename={() => startRename(chat)}
|
onContextStartRename={() => startRename(chat)}
|
||||||
onContextArchive={() => setArchiveConfirm(chat)}
|
onContextArchive={() => setArchiveConfirm(chat)}
|
||||||
onContextDelete={() => { setDeleteConfirm(chat); setDeleteInput(''); }}
|
onContextDelete={() => setDeleteConfirm(chat)}
|
||||||
showContextMenu
|
showContextMenu
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
@@ -242,7 +237,6 @@ export function SessionLandingPage({
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setDeleteConfirm(chat);
|
setDeleteConfirm(chat);
|
||||||
setDeleteInput('');
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
@@ -352,36 +346,25 @@ export function SessionLandingPage({
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={deleteConfirm !== null} onOpenChange={(open) => { if (!open) { setDeleteConfirm(null); setDeleteInput(''); } }}>
|
<Dialog open={deleteConfirm !== null} onOpenChange={(open) => { if (!open) setDeleteConfirm(null); }}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Delete chat?</DialogTitle>
|
<DialogTitle>Delete chat?</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Type the chat name to confirm:
|
Permanently delete{' '}
|
||||||
{' '}
|
<span className="font-mono font-medium text-foreground">{deleteConfirm?.name || '(unnamed)'}</span>
|
||||||
<span className="font-mono font-medium text-foreground">{deleteExpected || '(unnamed — cannot type-confirm)'}</span>
|
{' '}and all its messages. This cannot be undone.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</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">
|
<div className="flex gap-2 justify-end pt-2">
|
||||||
<Button variant="outline" onClick={() => { setDeleteConfirm(null); setDeleteInput(''); }}>
|
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
disabled={!deleteEnabled}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (deleteConfirm && deleteEnabled) void onDeleteChat(deleteConfirm.id);
|
if (deleteConfirm) void onDeleteChat(deleteConfirm.id);
|
||||||
setDeleteConfirm(null);
|
setDeleteConfirm(null);
|
||||||
setDeleteInput('');
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Delete
|
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 { 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 { MAX_PANES, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes';
|
||||||
import type { UseSessionChatsResult } from '@/hooks/useSessionChats';
|
import type { UseSessionChatsResult } from '@/hooks/useSessionChats';
|
||||||
import { useViewport } from '@/hooks/useViewport';
|
import { useViewport } from '@/hooks/useViewport';
|
||||||
import { ChatPane } from '@/components/panes/ChatPane';
|
import { ChatPane } from '@/components/panes/ChatPane';
|
||||||
|
import { SettingsPane } from '@/components/panes/SettingsPane';
|
||||||
import { ChatTabBar } from '@/components/ChatTabBar';
|
import { ChatTabBar } from '@/components/ChatTabBar';
|
||||||
import { SessionLandingPage } from '@/components/SessionLandingPage';
|
import { SessionLandingPage } from '@/components/SessionLandingPage';
|
||||||
import {
|
import {
|
||||||
@@ -24,6 +26,9 @@ interface Props {
|
|||||||
// (MobileTabSwitcher) can share state with the pane grid.
|
// (MobileTabSwitcher) can share state with the pane grid.
|
||||||
panesHook: UseWorkspacePanesResult;
|
panesHook: UseWorkspacePanesResult;
|
||||||
chatsHook: UseSessionChatsResult;
|
chatsHook: UseSessionChatsResult;
|
||||||
|
// v1.9: passed through to SettingsPane when one is mounted in the grid.
|
||||||
|
session: Session;
|
||||||
|
project: Project | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Workspace({
|
export function Workspace({
|
||||||
@@ -33,6 +38,8 @@ export function Workspace({
|
|||||||
onAgentChange,
|
onAgentChange,
|
||||||
panesHook,
|
panesHook,
|
||||||
chatsHook,
|
chatsHook,
|
||||||
|
session,
|
||||||
|
project,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const {
|
const {
|
||||||
panes,
|
panes,
|
||||||
@@ -67,6 +74,41 @@ export function Workspace({
|
|||||||
|
|
||||||
const { isMobile } = useViewport();
|
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[] {
|
function chatsForPane(pane: WorkspacePane): Chat[] {
|
||||||
return pane.chatIds
|
return pane.chatIds
|
||||||
.map((id) => chats.find((c) => c.id === id))
|
.map((id) => chats.find((c) => c.id === id))
|
||||||
@@ -81,10 +123,12 @@ export function Workspace({
|
|||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="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(
|
className={cn(
|
||||||
'flex items-center gap-1 text-xs px-2 py-1 rounded hover:bg-muted',
|
'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} />
|
<PanelRight size={14} />
|
||||||
@@ -114,12 +158,24 @@ export function Workspace({
|
|||||||
style={
|
style={
|
||||||
isMobile
|
isMobile
|
||||||
? undefined
|
? 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) => {
|
{panes.map((pane, idx) => {
|
||||||
const visible = !isMobile || idx === activePaneIdx;
|
const isSettings = pane.kind === 'settings';
|
||||||
if (!visible) return null;
|
// 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={pane.id}
|
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'
|
'before:absolute before:inset-y-0 before:left-0 before:w-0.5 before:bg-primary before:z-10'
|
||||||
)}
|
)}
|
||||||
onClick={() => setActivePaneIdx(idx)}
|
onClick={() => setActivePaneIdx(idx)}
|
||||||
onDragOver={!isMobile && panes.length > 1 ? handlePaneDragOver(idx) : undefined}
|
onDragOver={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragOver(idx) : undefined}
|
||||||
onDragLeave={!isMobile && panes.length > 1 ? handlePaneDragLeave : undefined}
|
onDragLeave={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragLeave : undefined}
|
||||||
onDrop={!isMobile && panes.length > 1 ? handlePaneDrop(idx) : undefined}
|
onDrop={!isMobile && !isSettings && panes.length > 1 ? handlePaneDrop(idx) : undefined}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
draggable={!isMobile && panes.length > 1}
|
draggable={!isMobile && !isSettings && panes.length > 1}
|
||||||
onDragStart={!isMobile && panes.length > 1 ? handlePaneDragStart(idx) : undefined}
|
onDragStart={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragStart(idx) : undefined}
|
||||||
onDragEnd={!isMobile && panes.length > 1 ? handlePaneDragEnd : undefined}
|
onDragEnd={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragEnd : undefined}
|
||||||
>
|
>
|
||||||
{/* Hidden on mobile per v1.8: chat-within-pane navigation
|
{/* Hidden on mobile per v1.8; settings panes own their own
|
||||||
is not exposed on small screens; users switch panes via
|
section nav / maximize toggle so they skip ChatTabBar
|
||||||
the header pill instead. */}
|
entirely. */}
|
||||||
{!isMobile && (
|
{!isMobile && !isSettings && (
|
||||||
<ChatTabBar
|
<ChatTabBar
|
||||||
pane={pane}
|
pane={pane}
|
||||||
tabs={chatsForPane(pane)}
|
tabs={chatsForPane(pane)}
|
||||||
@@ -161,7 +217,16 @@ export function Workspace({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-h-0 overflow-hidden">
|
<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
|
<ChatPane
|
||||||
sessionId={sessionId}
|
sessionId={sessionId}
|
||||||
chatId={pane.chatId}
|
chatId={pane.chatId}
|
||||||
@@ -169,6 +234,7 @@ export function Workspace({
|
|||||||
agentId={agentId}
|
agentId={agentId}
|
||||||
onAgentChange={onAgentChange}
|
onAgentChange={onAgentChange}
|
||||||
sessionChats={chats}
|
sessionChats={chats}
|
||||||
|
webSearchEnabled={session.web_search_enabled}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<SessionLandingPage
|
<SessionLandingPage
|
||||||
|
|||||||
@@ -22,9 +22,13 @@ interface Props {
|
|||||||
agentId?: string | null;
|
agentId?: string | null;
|
||||||
onAgentChange?: (agentId: string | null) => void | Promise<void>;
|
onAgentChange?: (agentId: string | null) => void | Promise<void>;
|
||||||
sessionChats?: import('@/api/types').Chat[];
|
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 stream = useSessionStream(sessionId);
|
||||||
const lastErrorRef = useRef<string | null>(null);
|
const lastErrorRef = useRef<string | null>(null);
|
||||||
const [queue, setQueue] = useState<string[]>([]);
|
const [queue, setQueue] = useState<string[]>([]);
|
||||||
@@ -92,6 +96,18 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
|
|||||||
}
|
}
|
||||||
}, [chatId]);
|
}, [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) {
|
function removeQueued(idx: number) {
|
||||||
setQueue((prev) => prev.filter((_, i) => i !== idx));
|
setQueue((prev) => prev.filter((_, i) => i !== idx));
|
||||||
}
|
}
|
||||||
@@ -173,10 +189,13 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
|
|||||||
<ChatInput
|
<ChatInput
|
||||||
disabled={false}
|
disabled={false}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
|
sessionId={sessionId}
|
||||||
agentId={agentId}
|
agentId={agentId}
|
||||||
onAgentChange={onAgentChange}
|
onAgentChange={onAgentChange}
|
||||||
|
webSearchEnabled={webSearchEnabled}
|
||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
onForceSend={streaming ? handleForceSend : undefined}
|
onForceSend={streaming ? handleForceSend : undefined}
|
||||||
|
onSlashCommand={handleSlashCommand}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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;
|
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 {
|
export interface SessionArchivedEvent {
|
||||||
type: 'session_archived';
|
type: 'session_archived';
|
||||||
session_id: string;
|
session_id: string;
|
||||||
@@ -139,6 +147,7 @@ export type SessionEvent =
|
|||||||
| OpenFileInBrowserEvent
|
| OpenFileInBrowserEvent
|
||||||
| AttachChatFileEvent
|
| AttachChatFileEvent
|
||||||
| OpenChatInActivePaneEvent
|
| OpenChatInActivePaneEvent
|
||||||
|
| OpenSettingsPaneEvent
|
||||||
| SessionArchivedEvent
|
| SessionArchivedEvent
|
||||||
| ChatCreatedEvent
|
| ChatCreatedEvent
|
||||||
| ChatUpdatedEvent
|
| ChatUpdatedEvent
|
||||||
|
|||||||
@@ -151,6 +151,10 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
|
|||||||
case 'open_chat_in_active_pane':
|
case 'open_chat_in_active_pane':
|
||||||
// Consumed by Workspace; sidebar has no business with pane state.
|
// Consumed by Workspace; sidebar has no business with pane state.
|
||||||
return prev;
|
return prev;
|
||||||
|
case 'open_settings_pane':
|
||||||
|
// Consumed by Session.tsx (calls toggleSettingsPane on its panesHook).
|
||||||
|
// Sidebar data is untouched.
|
||||||
|
return prev;
|
||||||
case 'session_archived': {
|
case 'session_archived': {
|
||||||
let changed = false;
|
let changed = false;
|
||||||
const projects = prev.projects.map((p) => {
|
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 };
|
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 {
|
function loadPanes(sessionId: string): WorkspacePane[] | null {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(`${STORAGE_KEY}.${sessionId}`);
|
const raw = localStorage.getItem(`${STORAGE_KEY}.${sessionId}`);
|
||||||
@@ -33,7 +53,10 @@ function loadPanes(sessionId: string): WorkspacePane[] | null {
|
|||||||
|
|
||||||
function savePanes(sessionId: string, panes: WorkspacePane[]): void {
|
function savePanes(sessionId: string, panes: WorkspacePane[]): void {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(`${STORAGE_KEY}.${sessionId}`, JSON.stringify(panes));
|
localStorage.setItem(
|
||||||
|
`${STORAGE_KEY}.${sessionId}`,
|
||||||
|
JSON.stringify(persistablePanes(panes)),
|
||||||
|
);
|
||||||
} catch { /* quota or disabled */ }
|
} catch { /* quota or disabled */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +73,10 @@ export interface UseWorkspacePanesResult {
|
|||||||
closeAllTabs: (paneIdx: number) => void;
|
closeAllTabs: (paneIdx: number) => void;
|
||||||
showLandingPage: (paneIdx: number) => void;
|
showLandingPage: (paneIdx: number) => void;
|
||||||
addSplitPane: (kind: 'chat' | 'terminal' | 'agent') => 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;
|
removePane: (idx: number) => void;
|
||||||
removeChatFromPanes: (chatId: string) => void;
|
removeChatFromPanes: (chatId: string) => void;
|
||||||
initializeFirstChatIfEmpty: (chatId: string) => void;
|
initializeFirstChatIfEmpty: (chatId: string) => void;
|
||||||
@@ -216,7 +243,8 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPanes((prev) => {
|
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`);
|
toast.error(`Maximum ${MAX_PANES} panes`);
|
||||||
return prev;
|
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) => {
|
const removePane = useCallback((idx: number) => {
|
||||||
setPanes((prev) => {
|
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);
|
const next = prev.filter((_, i) => i !== idx);
|
||||||
setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
|
setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
|
||||||
return next;
|
return next;
|
||||||
@@ -325,6 +379,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
closeAllTabs,
|
closeAllTabs,
|
||||||
showLandingPage,
|
showLandingPage,
|
||||||
addSplitPane,
|
addSplitPane,
|
||||||
|
toggleSettingsPane,
|
||||||
removePane,
|
removePane,
|
||||||
removeChatFromPanes,
|
removeChatFromPanes,
|
||||||
initializeFirstChatIfEmpty,
|
initializeFirstChatIfEmpty,
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ export function Home() {
|
|||||||
last_session_id: null,
|
last_session_id: null,
|
||||||
status: 'archived' as const,
|
status: 'archived' as const,
|
||||||
gitea_remote: fromSidebar.gitea_remote,
|
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,
|
...prev,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -116,9 +116,31 @@ function SessionInner({ sessionId }: { sessionId: string }) {
|
|||||||
event.session_id === sessionId
|
event.session_id === sessionId
|
||||||
) {
|
) {
|
||||||
navigate(`/project/${event.project_id}`);
|
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
|
// v1.8: URL ?pane= sync (mobile only). Lifted from Workspace.tsx so
|
||||||
// MobileTabSwitcher's onSwitchPane can push the same URL state and the
|
// MobileTabSwitcher's onSwitchPane can push the same URL state and the
|
||||||
@@ -211,15 +233,13 @@ function SessionInner({ sessionId }: { sessionId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{session && (
|
{session && (
|
||||||
<div className="inline-flex items-center rounded-full bg-muted/40 hover:bg-muted/70 px-1 shrink-0">
|
<ModelPicker
|
||||||
<ModelPicker
|
value={session.model}
|
||||||
value={session.model}
|
onChange={async (model) => {
|
||||||
onChange={async (model) => {
|
const updated = await api.sessions.update(session.id, { model });
|
||||||
const updated = await api.sessions.update(session.id, { model });
|
setSession(updated);
|
||||||
setSession(updated);
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -337,6 +357,8 @@ function SessionInner({ sessionId }: { sessionId: string }) {
|
|||||||
}}
|
}}
|
||||||
panesHook={panesHook}
|
panesHook={panesHook}
|
||||||
chatsHook={chatsHook}
|
chatsHook={chatsHook}
|
||||||
|
session={session}
|
||||||
|
project={project}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,124 +1,46 @@
|
|||||||
import { useState } from 'react';
|
import { ArrowLeft } from 'lucide-react';
|
||||||
import { Check } from 'lucide-react';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { toast } from 'sonner';
|
import { ThemePicker } from '@/components/ThemePicker';
|
||||||
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.' },
|
|
||||||
];
|
|
||||||
|
|
||||||
|
// 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() {
|
export function Settings() {
|
||||||
const { id: currentId, mode: currentMode } = useTheme();
|
const navigate = useNavigate();
|
||||||
// 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) {
|
function handleBack() {
|
||||||
if (id === currentId || pending) return;
|
// History-aware: jump back to where the user came from when possible.
|
||||||
setPending({ kind: 'theme', id });
|
// Direct loads of /settings (no history) land on Home so the button
|
||||||
try {
|
// always does *something* useful.
|
||||||
await setTheme(id, currentMode);
|
if (window.history.length > 1) {
|
||||||
} catch (err) {
|
navigate(-1);
|
||||||
toast.error(err instanceof Error ? err.message : 'failed to apply theme');
|
} else {
|
||||||
} finally {
|
navigate('/');
|
||||||
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 (
|
return (
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="max-w-[1000px] mx-auto w-full px-6 py-6 space-y-8">
|
<div className="max-w-[1000px] mx-auto w-full px-6 py-6 space-y-8">
|
||||||
<header>
|
<header className="space-y-2">
|
||||||
<h1 className="text-xl font-semibold">Settings</h1>
|
<button
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
type="button"
|
||||||
Theme appearance. Saved on change, applies immediately.
|
onClick={handleBack}
|
||||||
</p>
|
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground -ml-1 px-1 py-0.5 rounded"
|
||||||
</header>
|
aria-label="Back"
|
||||||
|
|
||||||
<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) => (
|
<ArrowLeft className="size-4" />
|
||||||
<div key={m.value} className="flex items-center gap-2">
|
<span>Back</span>
|
||||||
<RadioGroupItem id={`mode-${m.value}`} value={m.value} />
|
</button>
|
||||||
<Label htmlFor={`mode-${m.value}`} className="cursor-pointer">
|
<div>
|
||||||
<span className="font-medium">{m.label}</span>
|
<h1 className="text-xl font-semibold">Settings</h1>
|
||||||
<span className="ml-2 text-xs text-muted-foreground">{m.hint}</span>
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
</Label>
|
Theme appearance. Saved on change, applies immediately.
|
||||||
</div>
|
</p>
|
||||||
))}
|
|
||||||
</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>
|
</div>
|
||||||
</section>
|
</header>
|
||||||
|
<ThemePicker />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,341 +1,201 @@
|
|||||||
# BooCode v1.x — Roadmap
|
# BooCode — Roadmap
|
||||||
|
|
||||||
Last updated: 2026-05-16
|
Last updated: 2026-05-17
|
||||||
|
|
||||||
## Overview
|
## 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`).
|
Live at `https://code.indifferentketchup.com` (Caddy → Authelia → Tailscale → `100.114.205.53:9500`).
|
||||||
|
|
||||||
**Architectural commitments:**
|
**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).
|
- 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.
|
- 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 summary
|
||||||
|
|
||||||
|Batch |Theme |Status |Branch / Notes |
|
| Version | Theme | Status |
|
||||||
|------------------------------------------|-----------------------------------------------------------------------------------|-----------|---------------------------------------|
|
|---|---|---|
|
||||||
|1 |Markdown, Copy + Regen, tok/s + ctx, AI naming |✅ Done |`v1.1-batch1` merged |
|
| v1.0 | Initial scaffold, read-only tools, WS streaming | ✅ Merged |
|
||||||
|2 |Sidebar restructure |✅ Done |`v1.1-batch2` merged |
|
| v1.1-batch1 | Markdown, Copy + Regen, tok/s + ctx, AI naming | ✅ Merged |
|
||||||
|3 |Pane system, FileBrowserPane + Shiki, cross-tab |✅ Done |`v1.1-batch3` merged |
|
| v1.1-batch2 | Sidebar restructure | ✅ Merged |
|
||||||
|3.5 |Chip infrastructure, `@file`, line-select |✅ Done |merged |
|
| v1.1-batch3 | Pane system, FileBrowserPane + Shiki, cross-tab | ✅ Merged |
|
||||||
|4 (v1.2) |Chats inside sessions, right-rail, `/compact`, archive, force-send |✅ Done |merged |
|
| v1.1-batch3.5 | Chip infra, `@file`, line-select | ✅ Merged |
|
||||||
|4.1–4.4 |Project archive, sidebar context, Gitea API, bootstrap |✅ Done |merged |
|
| v1.2 | Chats inside sessions, right-rail, `/compact`, archive, force-send | ✅ Merged |
|
||||||
|v1.5 cleanup |resolveProjectPath, BOOTSTRAP_ROOT, vitest pin |✅ Done |merged |
|
| v1.2-project-ux | Project archive, sidebar context, Gitea API, bootstrap | ✅ Merged |
|
||||||
|v1.6 mobile |Drawer, single-pane, long-press, IME-safe, pull-to-refresh, swipe-close |✅ Done |merged |
|
| v1.3 | Tab-close + chat-archive | ✅ Merged |
|
||||||
|v1.6.1 |RightRail mobile wrapper fix |✅ Done |merged |
|
| v1.4 | Fork message, delete message, header polish (was original Batch 5) | ✅ Merged |
|
||||||
|Tool-loop bump |MAX_TOOL_LOOP_DEPTH 5→15 |✅ Done |merged |
|
| v1.5 | resolveProjectPath, BOOTSTRAP_ROOT, vitest pin | ✅ Merged |
|
||||||
|v1.6.2 |Workspace + Session+Project headers + ChatTabBar new-chat + RightRail mobile drawer|🔄 In flight|`v1.6.2-mobile-ui-fixes` |
|
| v1.5.1 | Bootstrap hotfix (git in container, SSH keypair, known_hosts) | ✅ Merged (`4a9f207`) |
|
||||||
|**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 |
|
| v1.6 | Mobile pass: drawer, single-pane, long-press, IME-safe, pull-to-refresh, swipe-close | ✅ Merged |
|
||||||
|9 (REORDERED, DECOUPLED) |Agents (Tier 2): `AGENTS.md`, per-agent temp/tools, picker in ChatInput toolbar |✅ Implemented, uncommitted|six builtins; on `main` awaiting commit|
|
| v1.6.1 | RightRail mobile wrapper fix | ✅ Merged |
|
||||||
|5 |Fork message, delete message, header polish |Planned | |
|
| Tool-loop bump | MAX_TOOL_LOOP_DEPTH 5→15 | ✅ Merged |
|
||||||
|6 |Drag-drop file + paste-as-attachment |Planned |thin extension of 3.5 chips |
|
| v1.6.2 | Workspace + Session+Project headers, ChatTabBar new-chat, RightRail mobile drawer | ✅ Merged |
|
||||||
|7 |Settings drawer: system prompt, web search toggle, agent entry |Planned |adds SettingsDrawer agent entry (Batch 9 deferred half) |
|
| v1.7 | Drag-drop file + paste-as-attachment (was Batch 6) | ✅ Merged |
|
||||||
|8 |Web search backend: SearXNG `web_search` + `web_fetch` |Planned | |
|
| v1.8 | Settings drawer + `git_status` added to ALL_TOOL_NAMES (was Batch 7) | ✅ Merged |
|
||||||
|10 |BooTerm: separate container, xterm.js + node-pty + tmux |Planned | |
|
| v1.8.1 | WS reconnect toast tuning (silent/gray/red thresholds), pane status indicators | ✅ Merged |
|
||||||
|11 — Architect: codebase map |codecontext sidecar + MCP tool wiring |Planned |from nmakod/codecontext |
|
| v1.8.2 | Tool-loop fixes: read-only cap raised, "depth exceeded" error + continue, `max_tool_calls` frontmatter, `messages.metadata` | ✅ Merged |
|
||||||
|11b — Architect: repo health |call graph, circular deps, dead code |Planned |from spirituslab/codesight |
|
| **v1.x-themes** | **18 themes, settings page, dark/light/system, FOUC mitigation** | **🔄 Claude Code in flight** |
|
||||||
|12 — Tool approval + plan/act mode |Read-only invariant, per-tool gating |Planned |from cline |
|
| v1.8.3 | Tool call UI compaction: collapse-by-default, group consecutive same-tool, result preview cap | Planned (small, frontend-only) |
|
||||||
|13 — Append-only event log |Replace messages-table semantics |Planned |from OpenHands V1 |
|
| v1.9 | Settings pane (system prompt per project + session, web search toggle, `+` button) | Planned (spec locked, was on branch `v1.9-settings-pane`) |
|
||||||
|14 — BooCoder: pending changes |Sandboxed edit queue, atomic apply |Post-v1.x |from plandex |
|
| v1.10 | Web search backend: SearXNG `web_search` + `web_fetch` | Planned |
|
||||||
|15 — BooCoder runtime isolation |Per-session Docker sandbox |Post-v1.x |from OpenHands |
|
| v1.11 | Agents Tier 2: `AGENTS.md`, per-agent temp/tools whitelist, AgentPicker in ChatInput | Planned |
|
||||||
|16 — Multi-provider LLM |Optional litellm-style abstraction |Optional |from pi-ai |
|
| v1.12 | BooTerm: separate container, xterm.js + node-pty + tmux | Planned |
|
||||||
|17 — Workflow graphs |Multi-agent coordination |Far future |from microsoft/agent-framework concepts|
|
| v1.13 | Architect: codecontext sidecar (MCP, tree-sitter, no embeddings) | Planned |
|
||||||
|
| v1.13b | Architect: repo health (call graph, circular deps, dead code) | Planned |
|
||||||
**Old Batch 12 (codebase indexer w/ Harrier embeddings) — REMOVED.** Replaced by Batch 11/11b sidecar approach. See `boocode_code_review.md` decisions log.
|
| v1.14 | Tool approval + plan/act mode (cline-style) | Planned |
|
||||||
|
| Post-v1.x | Append-only event log (OpenHands V1) | Planned |
|
||||||
**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.
|
| 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 |
|
||||||
## Batch details (planned / new)
|
|
||||||
|
## Flagged follow-ups (not in a batch yet)
|
||||||
### Batch 9 — Agents (Tier 2, DECOUPLED)
|
|
||||||
|
- Agents in `/data/AGENTS.md` don't list `git_status` in their `tools:` blocks. Out of scope until pre-BooCoder cleanup pass.
|
||||||
**Spec:** `boocode_batch9.md` with the deltas below.
|
- 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.
|
||||||
**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.
|
|
||||||
|
|
||||||
-----
|
|
||||||
|
|
||||||
## Order of operations
|
## 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:**
|
Track B (architect, no UI dep, can run parallel anytime): v1.13 → v1.13b → v1.14.
|
||||||
|
|
||||||
- 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.
|
|
||||||
|
|
||||||
-----
|
|
||||||
|
|
||||||
## Architecture target state
|
## 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 |
|
## Schema additions ahead
|
||||||
|-------------|--------------------------------|--------------------------|------------------------------|--------|
|
|
||||||
|`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
|
- 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`
|
||||||
**Batch 9:** `sessions.agent_id TEXT` (nullable; references AGENTS.md by slug).
|
- v1.11: `sessions.agent_id`
|
||||||
**Batch 11:** none (codecontext stateless on disk).
|
- v1.13b: `repo_health_cache (project_id, file_hashes_sig, payload JSONB, created_at)`
|
||||||
**Batch 11b:** `repo_health_cache (project_id, file_hashes_sig, payload JSONB, created_at)`.
|
- v1.14: `sessions.tool_approval_mode`, `sessions.approved_tools`
|
||||||
**Batch 12:** `sessions.tool_approval_mode`, `sessions.approved_tools`.
|
- Post-v1.x: `session_events`; deprecate `messages` long-tail
|
||||||
**Batch 13:** `session_events`; deprecate `messages` long-tail.
|
- Post-v1.x: `pending_changes`
|
||||||
**Batch 14:** `pending_changes`.
|
|
||||||
|
|
||||||
-----
|
|
||||||
|
|
||||||
## Lift sources (summary)
|
|
||||||
|
|
||||||
Full inventory in `boocode_code_review.md`. Headline items:
|
|
||||||
|
|
||||||
|Source |Used for |Where |
|
|
||||||
|--------------------------------------|----------------------------------------|---------------------|
|
|
||||||
|nmakod/codecontext (MIT, Go) |Architect: codebase map sidecar |Batch 11 |
|
|
||||||
|spirituslab/codesight (MIT-ish, TS) |Architect: repo health analyzer |Batch 11b |
|
|
||||||
|Aider-AI/aider (Apache-2.0) |Fallback `.scm` grammars (60+ languages)|Batch 11 (fallback) |
|
|
||||||
|continuedev/continue (Apache-2.0) |DEFAULT_SECURITY_IGNORE_FILETYPES |Batch 11 prep |
|
|
||||||
|cline/cline (Apache-2.0) |Plan/Act mode pattern |Batch 12 |
|
|
||||||
|plandex-ai/plandex (MIT) |Pending-changes data model |Batch 14 |
|
|
||||||
|OpenHands/OpenHands (MIT) |Event log + sandbox runtime |Batches 13, 15 |
|
|
||||||
|aimasteracc/tree-sitter-analyzer (MIT)|Outline-first response patterns |Reference |
|
|
||||||
|earendil-works/pi (MIT) |Multi-provider LLM |Batch 16 (optional) |
|
|
||||||
|rshah515/claude-code-subagents (MIT) |Reference for builtin agent prompts |Batch 9 (six builtins)|
|
|
||||||
|microsoft/agent-framework (MIT) |Workflow concepts |Batch 17 (far future)|
|
|
||||||
|
|
||||||
-----
|
|
||||||
|
|
||||||
## Decisions log
|
## Decisions log
|
||||||
|
|
||||||
- **Embeddings dropped from BooCode.** Replaced RAG with file-view tools + sidecar analyzers.
|
- Embeddings dropped from BooCode. File-view tools + sidecar analyzers replace RAG.
|
||||||
- **Original Batch 11 (aider PageRank port) replaced** by codecontext sidecar approach.
|
- Old Batch 11 (aider PageRank port) → replaced by codecontext sidecar (v1.13).
|
||||||
- **Original Batch 12 (codebase indexer w/ Harrier) removed** entirely. No embedding infrastructure in BooCode v1.x.
|
- Old Batch 12 (Harrier indexer) → removed entirely.
|
||||||
- **Globstar parked** — not an architect tool, future verify-before-commit candidate only.
|
- 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.
|
||||||
- **codeprysm rejected** — embedding-based; node/edge taxonomy noted as reference if we ever build our own graph.
|
- 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.
|
||||||
- **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).
|
|
||||||
|
|
||||||
-----
|
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
Each batch:
|
Each batch:
|
||||||
|
1. Verify previous merged.
|
||||||
1. Verify previous batch merged.
|
2. Dispatch via Paseo to Claude Code at `/opt/boocode` (or OpenCode for smaller batches).
|
||||||
2. Dispatch via Paseo to Claude Code at `/opt/boocode`.
|
3. Recon → blocking questions → implement → hand back.
|
||||||
3. Claude Code recon → blocking questions → implement → hand back.
|
|
||||||
4. Compliance review in separate Claude chat.
|
4. Compliance review in separate Claude chat.
|
||||||
5. Deploy: `docker compose up --build -d`.
|
5. Deploy: `docker compose up --build -d`.
|
||||||
6. Smoke test.
|
6. Smoke test.
|
||||||
|
|||||||
@@ -9,15 +9,11 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boocode
|
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boocode
|
||||||
volumes:
|
volumes:
|
||||||
# Read-only mount for legacy/existing project add-existing flow.
|
- /opt:/opt
|
||||||
- /opt:/opt:ro
|
|
||||||
# Writable mount only for the create-new-project bootstrap target.
|
|
||||||
# Host must `mkdir -p /opt/projects` before container start.
|
|
||||||
- /opt/projects:/opt/projects:rw
|
- /opt/projects:/opt/projects:rw
|
||||||
- ./secrets/boocode_gitea:/root/.ssh/id_ed25519:ro
|
- ./secrets/boocode_gitea:/root/.ssh/id_ed25519:ro
|
||||||
# v1.8.1: global agents file. Host seeds it once before deploy:
|
- ./data:/data
|
||||||
# cp /opt/boocode/AGENTS.md /opt/boocode/data/AGENTS.md
|
- /opt/skills:/data/skills
|
||||||
- ./data:/data:ro
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- boocode_db
|
- boocode_db
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
Reference in New Issue
Block a user