Compare commits
2 Commits
32c1a2b5f6
...
4bf2cd40c3
| Author | SHA1 | Date | |
|---|---|---|---|
| 4bf2cd40c3 | |||
| 09aecc4ee9 |
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -51,15 +51,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 +120,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 +132,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: {
|
||||||
|
|||||||
@@ -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,10 @@ export interface GitMeta {
|
|||||||
behind: number;
|
behind: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WorkspacePaneKind = 'chat' | 'terminal' | 'agent' | 'empty';
|
// 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 openOrFocusSettingsPane().
|
||||||
|
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, 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,
|
||||||
@@ -29,11 +35,18 @@ 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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatInput({ disabled, projectId, agentId, onAgentChange, onSend, onForceSend }: Props) {
|
export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend }: 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);
|
||||||
@@ -425,16 +438,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">
|
||||||
|
|||||||
@@ -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); }}>
|
||||||
|
|||||||
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,28 @@ 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');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!maximized) return;
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') setMaximized(false);
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [maximized]);
|
||||||
|
|
||||||
|
// 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 +110,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 +145,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 +174,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 +204,15 @@ 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)}
|
||||||
|
isMobile={isMobile}
|
||||||
|
/>
|
||||||
|
) : pane.kind === 'chat' && pane.chatId ? (
|
||||||
<ChatPane
|
<ChatPane
|
||||||
sessionId={sessionId}
|
sessionId={sessionId}
|
||||||
chatId={pane.chatId}
|
chatId={pane.chatId}
|
||||||
@@ -169,6 +220,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[]>([]);
|
||||||
@@ -173,8 +177,10 @@ 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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
520
apps/web/src/components/panes/SettingsPane.tsx
Normal file
520
apps/web/src/components/panes/SettingsPane.tsx
Normal file
@@ -0,0 +1,520 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Archive, Maximize2, Minimize2 } 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;
|
||||||
|
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, 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>
|
||||||
|
)}
|
||||||
|
</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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v1.9: client-side event fired by the sidebar Settings button when a
|
||||||
|
// session is currently mounted. Session.tsx subscribes and calls
|
||||||
|
// panesHook.openOrFocusSettingsPane(). Sidebar handles the no-session case
|
||||||
|
// by navigating to /settings (themes page) 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':
|
||||||
|
// v1.9: consumed by Session.tsx (calls openOrFocusSettingsPane 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) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
// v1.9: idempotent open-or-focus for the settings pane singleton. Appends
|
||||||
|
// a new settings pane if none exists, otherwise just focuses the existing
|
||||||
|
// one. Always succeeds — settings panes don't count toward MAX_PANES.
|
||||||
|
openOrFocusSettingsPane: () => 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,6 +254,19 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const openOrFocusSettingsPane = useCallback(() => {
|
||||||
|
setPanes((prev) => {
|
||||||
|
const existingIdx = prev.findIndex((p) => p.kind === 'settings');
|
||||||
|
if (existingIdx >= 0) {
|
||||||
|
setActivePaneIdx(existingIdx);
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const next = [...prev, settingsPane()];
|
||||||
|
setActivePaneIdx(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) return prev;
|
||||||
@@ -318,6 +359,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
setActivePaneIdx,
|
setActivePaneIdx,
|
||||||
activePaneIdxRef,
|
activePaneIdxRef,
|
||||||
openChatInPane,
|
openChatInPane,
|
||||||
|
openOrFocusSettingsPane,
|
||||||
switchTab,
|
switchTab,
|
||||||
removeTab,
|
removeTab,
|
||||||
closeOtherTabs,
|
closeOtherTabs,
|
||||||
|
|||||||
@@ -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,32 @@ 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;
|
||||||
|
}
|
||||||
|
// v1.9: sidebar Settings button broadcasts this when a session is
|
||||||
|
// mounted; we own the workspace pane state, so we open/focus the
|
||||||
|
// singleton settings pane here.
|
||||||
|
if (event.type === 'open_settings_pane') {
|
||||||
|
panesHook.openOrFocusSettingsPane();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [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 +234,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 +358,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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user