From 89435bf62a197340ae415f27ac55f02e3c9967dc Mon Sep 17 00:00:00 2001 From: Yaowei Zheng Date: Tue, 4 Aug 2026 22:52:34 +0800 Subject: [PATCH] feat(project): per-project new-chat defaults (agent, workspace, approval, thinking, model) (#191) Co-authored-by: Claude Fable 5 --- packages/core/src/agent.ts | 43 +- packages/core/src/state/project-config.ts | 98 ++++- packages/core/test/agent.test.ts | 59 ++- packages/core/test/state.test.ts | 72 ++++ packages/server/src/api/types.ts | 43 ++ packages/server/src/app.ts | 2 + .../server/src/http/routes/chat-defaults.ts | 65 +++ packages/server/src/http/routes/models.ts | 22 +- packages/server/src/http/routes/sessions.ts | 3 +- .../src/services/project-config-service.ts | 74 ++++ .../server/test/project-chat-defaults.test.ts | 228 ++++++++++ packages/web/src/api/endpoints.ts | 21 + .../src/components/layout/project-dialogs.tsx | 274 +++++++++++- packages/web/src/components/ui/dropdown.tsx | 40 +- packages/web/src/components/ui/modal.tsx | 42 +- packages/web/src/features/chat/chat-input.tsx | 311 +------------- packages/web/src/features/chat/draft-cache.ts | 17 + packages/web/src/features/chat/draft-view.tsx | 395 +++++------------- .../web/src/features/chat/model-select.tsx | 362 ++++++++++++++++ .../web/src/features/chat/thinking-level.ts | 17 + .../src/features/chat/workspace-select.tsx | 316 ++++++++++++++ .../web/src/features/models/models-page.tsx | 6 +- packages/web/src/lib/strings-en.ts | 12 + packages/web/src/lib/strings.ts | 11 + packages/web/test/draft-cache.test.ts | 38 ++ packages/web/test/esc-layers.test.ts | 34 ++ packages/web/test/thinking-level.test.ts | 13 + 27 files changed, 1980 insertions(+), 638 deletions(-) create mode 100644 packages/server/src/http/routes/chat-defaults.ts create mode 100644 packages/server/test/project-chat-defaults.test.ts create mode 100644 packages/web/src/features/chat/model-select.tsx create mode 100644 packages/web/src/features/chat/workspace-select.tsx create mode 100644 packages/web/test/esc-layers.test.ts diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 98b35f8..4223adf 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -94,7 +94,9 @@ export interface CreateSessionOptions { /** * Thinking level for this Session — a tri-state: * - a `ThinkingLevelName` pins the level; - * - `undefined` (omitted) falls back to the Agent config's `model.thinking_level`; + * - `undefined` (omitted) falls back to the config chain: the Agent's explicit + * `model.thinking_level` > the Project's `default_chat.thinking_level` > the built-in + * `"medium"` (see `configuredThinkingLevel`); * - `null` means "no thinking level": the config fallback is suppressed entirely * (nothing goes into the LLM config; session_meta echoes `"default"`). * Subagent spawning always passes the parent Session's effective level (its level, or @@ -156,6 +158,23 @@ export class Agent { readonly projectConfig: ProjectConfig, ) {} + /** + * A Session's default thinking level when no explicit per-session level is given — the + * resolution chain (the single rule, keep both sites on it): the Agent's explicit + * `model.thinking_level` > the Project's `default_chat.thinking_level` > the built-in + * `"medium"` (the documented Agent default). Mirrored by the web draft picker's DISPLAY + * (web features/chat/thinking-level.ts `effectiveThinkingLevel`): the picker shows this + * effective value, and a pick writes through to the AGENT config — the project default + * is only ever a fallback, never overwritten from there. + */ + private configuredThinkingLevel(): ThinkingLevelName { + return ( + this.state.systemConfig.model?.thinking_level ?? + this.projectConfig.default_chat?.thinking_level ?? + "medium" + ); + } + /** * Create a Session in the specified (or a temporary) Workspace. * Docs: /docs/sessions-and-traces § "Run model". @@ -222,13 +241,14 @@ export class Agent { } const sessionId = formatSessionId(); const subagentDepth = opts.subagentDepth ?? 0; - // Effective thinking level (tri-state option): a value wins over this Agent's own - // system_config; `null` — how subagent spawning says "the parent has none" — suppresses - // the config fallback entirely; only `undefined` (no option) reads the Agent config. + // Effective thinking level (tri-state option): a value wins over every config; `null` — + // how subagent spawning says "the parent has none" — suppresses the config fallback + // entirely; only `undefined` (no option) reads the config chain (Agent explicit > + // Project default_chat > built-in "medium", see configuredThinkingLevel). const thinkingLevel = opts.thinkingLevel === null ? undefined - : (opts.thinkingLevel ?? this.state.systemConfig.model?.thinking_level); + : (opts.thinkingLevel ?? this.configuredThinkingLevel()); // Agent-level vault (agent_state/.vault.toml) and installed Skills: read the current values each time a Session is created. const vault = await loadAgentVault(this.state.root, this.state.projectId, this.state.agentId); @@ -383,12 +403,13 @@ export class Agent { // original history); the vault uses current values (it's injected into the // subprocess environment, not the history, so a resumed Session should get the // latest keys too). - // The default thinking level comes from this Agent's current config only: session_meta - // no longer records one (it became a per-turn run parameter), and a `thinking_level` - // still present in a legacy Trace's meta JSON is deliberately ignored — a resumed - // legacy subagent session falls back to this Agent's configured level instead of - // keeping the level it inherited at spawn time. - const thinkingLevel = this.state.systemConfig.model?.thinking_level; + // The default thinking level comes from the current config chain only (Agent explicit > + // Project default_chat > built-in "medium", the same configuredThinkingLevel chain + // createSession uses): session_meta no longer records one (it became a per-turn run + // parameter), and a `thinking_level` still present in a legacy Trace's meta JSON is + // deliberately ignored — a resumed legacy subagent session falls back to this Agent's + // configured level instead of keeping the level it inherited at spawn time. + const thinkingLevel = this.configuredThinkingLevel(); const rt = await this.buildRuntime({ sessionId, diff --git a/packages/core/src/state/project-config.ts b/packages/core/src/state/project-config.ts index 8394742..8588fb9 100644 --- a/packages/core/src/state/project-config.ts +++ b/packages/core/src/state/project-config.ts @@ -27,6 +27,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { parse as parseToml, stringify as stringifyToml } from "smol-toml"; +import type { ThinkingLevelName } from "../interfaces.js"; import { presetModelEntries } from "./model-catalog.js"; import { projectConfigPath } from "./paths.js"; @@ -106,6 +107,41 @@ export interface ModelEntry { created_at?: string; } +/** Approval modes storable in `[default_chat]` (mirrors the Web/CLI ApprovalMode enum). */ +export const CHAT_APPROVAL_MODES = ["allow-all", "deny-all", "read-only", "always-ask"] as const; +export type ChatApprovalMode = (typeof CHAT_APPROVAL_MODES)[number]; + +/** + * Thinking levels storable in `[default_chat]`: the four selectable tiers only — never + * `"none"` (the project default is a fallback for Agents without an explicit level, and + * "no thinking" is not offered as a default; see the web picker's SELECTABLE_THINKING_LEVELS). + */ +export type DefaultChatThinkingLevel = Exclude; +export const DEFAULT_CHAT_THINKING_LEVELS: readonly DefaultChatThinkingLevel[] = [ + "low", + "medium", + "high", + "xhigh", +]; + +/** + * New-chat defaults (`[default_chat]`): per-Project prefill for newly created chats. + * Every key is optional and independent: + * - `agent_id`: the Agent preselected on the draft page (must name an existing Agent); + * - `workspace`: the prefilled Workspace directory (absent/empty = auto temp directory); + * - `approval_mode`: the prefilled approval mode (absent = the built-in "allow-all"); + * - `thinking_level`: fallback thinking level for Agents whose config has no explicit + * `model.thinking_level` (see Agent's thinking-level resolution chain in agent.ts). + * The default Model is deliberately NOT here: it stays the top-level `default_model` + * (single-sourced with the models page — never a second key). + */ +export interface ProjectChatDefaults { + agent_id?: string; + workspace?: string; + approval_mode?: ChatApprovalMode; + thinking_level?: DefaultChatThinkingLevel; +} + /** * Project-level config. * Docs: /docs/configuration § "Project config". @@ -122,6 +158,11 @@ export interface ProjectConfig { * default — models that don't support images won't be able to read images. */ vision_model?: ModelRef; + /** + * New-chat defaults block; absent by default (`defaultProjectConfig` never includes it — + * absent = the pre-existing behavior). Loaded tolerantly: invalid values drop per key. + */ + default_chat?: ProjectChatDefaults; models: ModelEntry[]; } @@ -175,11 +216,44 @@ function assertModelEntry(file: string, entry: unknown): ModelEntry { return entry as ModelEntry; } +/** + * Leniently parses the `[default_chat]` block (new-chat defaults): each key is validated + * independently and an invalid value (wrong type / unknown enum member / `"none"` as a + * thinking level) drops that key rather than failing the load — the block only ever + * prefills new chats, so bad data must never block reading the model table. Returns + * undefined when the value is not a table or nothing valid remains (absent block = + * the pre-existing behavior). + */ +function parseDefaultChat(value: unknown): ProjectChatDefaults | undefined { + if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined; + const t = value as Record; + const out: ProjectChatDefaults = {}; + if (typeof t.agent_id === "string" && t.agent_id !== "") out.agent_id = t.agent_id; + if (typeof t.workspace === "string" && t.workspace !== "") out.workspace = t.workspace; + if ( + typeof t.approval_mode === "string" && + (CHAT_APPROVAL_MODES as readonly string[]).includes(t.approval_mode) + ) { + out.approval_mode = t.approval_mode as ChatApprovalMode; + } + if ( + typeof t.thinking_level === "string" && + (DEFAULT_CHAT_THINKING_LEVELS as readonly string[]).includes(t.thinking_level) + ) { + out.thinking_level = t.thinking_level as DefaultChatThinkingLevel; + } + return Object.keys(out).length > 0 ? out : undefined; +} + /** * Loads the Project config; returns the default config (without writing to disk) if * `.project_config.toml` doesn't exist. Returns plaintext (masking is applied at the interface * layer); reports a clear error when the old format (a string reference / an entry missing * provider) is read. + * + * The return literal below rebuilds the config from **known keys only** — any new top-level + * key must be added to `ProjectConfig` AND echoed here, or a load→save round trip (the CLI + * path) silently drops it. */ export async function loadProjectConfig(root: string, projectId: string): Promise { const file = projectConfigPath(root, projectId); @@ -194,10 +268,12 @@ export async function loadProjectConfig(root: string, projectId: string): Promis const parsed = (parseToml(raw) ?? {}) as Record; const defaultModel = parseRefField(file, "default_model", parsed.default_model); const visionModel = parseRefField(file, "vision_model", parsed.vision_model); + const defaultChat = parseDefaultChat(parsed.default_chat); return { ...(parsed.name !== undefined ? { name: parsed.name as string } : {}), ...(defaultModel !== undefined ? { default_model: defaultModel } : {}), ...(visionModel !== undefined ? { vision_model: visionModel } : {}), + ...(defaultChat !== undefined ? { default_chat: defaultChat } : {}), models: ((parsed.models as unknown[] | undefined) ?? []).map((m) => assertModelEntry(file, m)), }; } @@ -224,19 +300,29 @@ function isModelRefShape(v: unknown): v is ModelRef { * `{ provider = "...", model_id = "..." }`; `models` is always * placed last, since any table header after `[[models]]` would be read as its sub-table. Unknown * extension fields are kept as-is. + * + * Same header rule for every other table-valued key (e.g. `[default_chat]`): once a table + * header is emitted, any `key = value` line below it would be parsed back as a member of + * that table — so entries whose serialization opens with a header are collected separately + * and emitted after all top-level `key = value` lines (and still before `[[models]]`), + * regardless of the object's key insertion order. */ export function renderProjectConfigToml(data: Record): string { const head: string[] = []; + const tables: string[] = []; for (const [key, value] of Object.entries(data)) { if (value === undefined || key === "models") continue; - head.push( - isModelRefShape(value) - ? `${key} = ${tomlInlineRef(value)}` - : stringifyToml({ [key]: value }).trim(), - ); + if (isModelRefShape(value)) { + head.push(`${key} = ${tomlInlineRef(value)}`); + continue; + } + const rendered = stringifyToml({ [key]: value }).trim(); + // A rendering that opens with "[" is a table header ([key] / [[key]]) — defer it below + // every top-level key = value line; plain lines (scalars, inline arrays) stay in place. + (rendered.startsWith("[") ? tables : head).push(rendered); } const models = Array.isArray(data.models) ? data.models : []; - return [...head, stringifyToml({ models })].join("\n"); + return [...head, ...tables, stringifyToml({ models })].join("\n"); } /** diff --git a/packages/core/test/agent.test.ts b/packages/core/test/agent.test.ts index d0f5a70..05495bc 100644 --- a/packages/core/test/agent.test.ts +++ b/packages/core/test/agent.test.ts @@ -20,6 +20,8 @@ import { DEFAULT_AGENT_ID, DEFAULT_PROJECT_ID, installSkill, + loadProjectConfig, + saveProjectConfig, setVaultEntry, } from "../src/index.js"; import { effectiveMaxContextLength, metaMaxTokens } from "../src/agent.js"; @@ -298,6 +300,52 @@ describe("Agent.createSession thinking level (explicit option wins over the Agen session.dispose(); } }); + + // The full resolution chain (the single rule, comment-pinned at Agent.configuredThinkingLevel + // and mirrored by the web draft picker's display): Agent explicit `model.thinking_level` > + // Project `default_chat.thinking_level` > built-in "medium". + it("falls back to the Project's default_chat.thinking_level when the Agent config has none", async () => { + const cfg = await loadProjectConfig(tmpRoot, DEFAULT_PROJECT_ID); + cfg.default_chat = { thinking_level: "high" }; + await saveProjectConfig(tmpRoot, DEFAULT_PROJECT_ID, cfg); + const agent = await createAgent(); + // A hand-edited config without a level (the seeded default pins "medium"). + delete agent.state.systemConfig.model?.thinking_level; + const ws = path.join(tmpRoot, "ws-thinking-project"); + await fs.mkdir(ws, { recursive: true }); + const session = await agent.createSession({ workspaceDir: ws }); + try { + expect(defaultLevelOf(session)).toBe("high"); + } finally { + session.dispose(); + } + }); + + it("Agent explicit level wins over the project default; built-in medium is the last resort", async () => { + const cfg = await loadProjectConfig(tmpRoot, DEFAULT_PROJECT_ID); + cfg.default_chat = { thinking_level: "low" }; + await saveProjectConfig(tmpRoot, DEFAULT_PROJECT_ID, cfg); + const agent = await createAgent(); + const ws = path.join(tmpRoot, "ws-thinking-chain"); + await fs.mkdir(ws, { recursive: true }); + // The seeded config's explicit "medium" beats the project's "low". + const explicit = await agent.createSession({ workspaceDir: ws }); + try { + expect(defaultLevelOf(explicit)).toBe("medium"); + } finally { + explicit.dispose(); + } + // No explicit level and no project default -> the built-in "medium". + const bare = await createAgent(); + delete bare.projectConfig.default_chat; + delete bare.state.systemConfig.model?.thinking_level; + const builtin = await bare.createSession({ workspaceDir: ws }); + try { + expect(defaultLevelOf(builtin)).toBe("medium"); + } finally { + builtin.dispose(); + } + }); }); describe("run_subagent spawning follows the PARENT session (never the Project default)", () => { @@ -407,15 +455,16 @@ describe("run_subagent spawning follows the PARENT session (never the Project de }); it("passes 'no thinking level' down when the parent has none (the child config never applies)", async () => { - // helper_agent's own seeded config pins "medium"; the parent's optional key is removed - // (hand-edited config). The tri-state null must reach the child: falling back to the - // child's own config here was the fallback hole — the child must show no level at all. + // helper_agent's own seeded config pins "medium". With the config chain always resolving + // (Agent explicit > Project default_chat > built-in "medium"), a parent with NO level can + // only come from the explicit tri-state null (an SDK caller suppressing the chain). That + // null must reach the child as-is: falling back to the child's own config here was the + // fallback hole — the child must show no level at all. await createAgent({ agentId: "helper_agent" }); const agent = await createAgent(); - delete agent.state.systemConfig.model?.thinking_level; const ws = path.join(tmpRoot, "ws-inherit-none"); await fs.mkdir(ws, { recursive: true }); - const parent = await agent.createSession({ workspaceDir: ws }); + const parent = await agent.createSession({ workspaceDir: ws, thinkingLevel: null }); const parentLLM = capturedLLMConfigs.list.at(-1)!; const runner = lastSpawnedRunner(); try { diff --git a/packages/core/test/state.test.ts b/packages/core/test/state.test.ts index 7179998..d5254ca 100644 --- a/packages/core/test/state.test.ts +++ b/packages/core/test/state.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { parse as parseToml } from "smol-toml"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { AGENT_ID_PLACEHOLDER, @@ -37,8 +38,10 @@ import { scratchpadDir, projectConfigPath, removeVaultEntry, + renderProjectConfigToml, resolveModelRef, resolveRoot, + saveProjectConfig, setDefaultModel, setVaultEntry, skillsDir, @@ -1254,6 +1257,75 @@ describe("project-config round trip", () => { }); }); +describe("default_chat (new-chat defaults block)", () => { + const block = { + agent_id: "default_agent", + workspace: "/tmp/some-ws", + approval_mode: "always-ask", + thinking_level: "high", + } as const; + + it("is absent from the default config (absent = the pre-existing behavior)", () => { + expect("default_chat" in defaultProjectConfig()).toBe(false); + }); + + it("round-trips through save/load — a known key the CLI's literal rebuild keeps", async () => { + const cfg = await loadProjectConfig(tmpRoot, DEFAULT_PROJECT_ID); + cfg.default_chat = { ...block }; + await saveProjectConfig(tmpRoot, DEFAULT_PROJECT_ID, cfg); + const loaded = await loadProjectConfig(tmpRoot, DEFAULT_PROJECT_ID); + expect(loaded.default_chat).toEqual(block); + // A second unrelated save/load round trip (the load→save path that only keeps known + // keys) must not drop the block: it is echoed in loadProjectConfig's return literal. + await saveProjectConfig(tmpRoot, DEFAULT_PROJECT_ID, loaded); + expect((await loadProjectConfig(tmpRoot, DEFAULT_PROJECT_ID)).default_chat).toEqual(block); + }); + + it("loads tolerantly: an invalid value drops that key, never the load", async () => { + const file = projectConfigPath(tmpRoot, DEFAULT_PROJECT_ID); + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile( + file, + [ + "[default_chat]", + 'agent_id = "default_agent"', + "workspace = 3", // wrong type -> dropped + 'approval_mode = "yolo"', // unknown enum -> dropped + 'thinking_level = "none"', // "none" is never a project default -> dropped + ].join("\n") + "\n", + "utf8", + ); + const loaded = await loadProjectConfig(tmpRoot, DEFAULT_PROJECT_ID); + expect(loaded.default_chat).toEqual({ agent_id: "default_agent" }); + }); + + it("drops the block entirely when it is not a table or nothing valid remains", async () => { + const file = projectConfigPath(tmpRoot, DEFAULT_PROJECT_ID); + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, 'default_chat = "high"\n', "utf8"); + expect((await loadProjectConfig(tmpRoot, DEFAULT_PROJECT_ID)).default_chat).toBeUndefined(); + await fs.writeFile(file, '[default_chat]\nthinking_level = "extreme"\n', "utf8"); + expect((await loadProjectConfig(tmpRoot, DEFAULT_PROJECT_ID)).default_chat).toBeUndefined(); + }); + + it("renderProjectConfigToml emits the [default_chat] table after every top-level line", () => { + // Pathological insertion order (a read-modify-write can append scalars after the block): + // if default_chat rendered first, the scalars below it would be parsed back as ITS + // members. The renderer defers table blocks below all top-level `key = value` lines. + const text = renderProjectConfigToml({ + default_chat: { thinking_level: "low" }, + name: "after-table", + default_model: { provider: "p", model_id: "m" }, + models: [{ provider: "p", model_id: "m" }], + }); + const parsed = parseToml(text) as Record; + expect(parsed.name).toBe("after-table"); + expect(parsed.default_model).toEqual({ provider: "p", model_id: "m" }); + expect(parsed.default_chat).toEqual({ thinking_level: "low" }); + expect(parsed.models).toEqual([{ provider: "p", model_id: "m" }]); + }); +}); + describe("resolveModelRef (validates a (provider, model_id) pair against the config)", () => { const cfg: ProjectConfig = { models: [ diff --git a/packages/server/src/api/types.ts b/packages/server/src/api/types.ts index 0aeda39..38f5f87 100644 --- a/packages/server/src/api/types.ts +++ b/packages/server/src/api/types.ts @@ -360,6 +360,49 @@ export interface ModelTestResponse { message?: string; } +/** + * PUT /api/projects/:p/models/default (owner): narrow default-model switch — flips the same + * top-level `default_model` the models page's whole-table PUT writes, without resending the + * table (and thus without touching credentials). The pair must name a configured model + * entry, exactly like the whole-table route's defaultModel validation. + */ +export interface DefaultModelUpdateRequest { + provider: string; + modelId: string; +} + +/** Response mirrors what GET models reports as `defaultModel`. */ +export interface DefaultModelResponse { + defaultModel: ModelRefDto; +} + +// --------------------------------------------------------------------------- +// New-chat defaults (the `[default_chat]` block of .project_config.toml) +// --------------------------------------------------------------------------- + +/** + * Per-Project new-chat defaults: prefill for the chat draft page. Every key is optional — + * an absent key means "not set" (the pre-existing behavior). Serves as the GET response, + * the PUT request body (whole-block replace: an omitted key clears it) and the PUT + * response (the stored block). The default MODEL is deliberately not here: it stays the + * top-level `default_model` served/written via the models routes (single-sourced with the + * models page). + */ +export interface ChatDefaultsDto { + /** Preselected Agent; must reference an existing Agent of the Project (400 unknown_agent). */ + agentId?: string; + /** Prefilled Workspace directory; absent/empty = auto temp directory. */ + workspace?: string; + /** Prefilled approval mode; absent = the built-in "allow-all". */ + approvalMode?: ApprovalMode; + /** + * Fallback thinking level for Agents whose config has no explicit `model.thinking_level` + * (resolution chain: Agent explicit > this project default > built-in "medium"). Never + * "none" — only the four selectable tiers. + */ + thinkingLevel?: Exclude; +} + // --------------------------------------------------------------------------- // Vault environment variables (Agent-level: agent_state/.vault.toml) // --------------------------------------------------------------------------- diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index 542db62..340a0a9 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -38,6 +38,7 @@ import { eventsRoutes, userChannelKey } from "./http/routes/events.js"; import { projectsRoutes } from "./http/routes/projects.js"; import { membersRoutes } from "./http/routes/members.js"; import { modelsRoutes } from "./http/routes/models.js"; +import { chatDefaultsRoutes } from "./http/routes/chat-defaults.js"; import { vaultRoutes } from "./http/routes/vault.js"; import { scheduleRoutes } from "./http/routes/schedules.js"; import { benchmarksRoutes } from "./http/routes/benchmarks.js"; @@ -378,6 +379,7 @@ export function createApp(deps: AppDeps): Hono { app.route("/api/projects", projectsRoutes(deps)); app.route("/api/projects/:projectId/members", membersRoutes(deps)); app.route("/api/projects/:projectId/models", modelsRoutes(deps)); + app.route("/api/projects/:projectId/chat-defaults", chatDefaultsRoutes(deps)); app.route("/api/projects/:projectId/agents", agentsRoutes(deps)); app.route("/api/projects/:projectId/dirs", dirsRoutes(deps)); app.route("/api/projects/:projectId/agents/:agentId/config", agentConfigRoutes(deps)); diff --git a/packages/server/src/http/routes/chat-defaults.ts b/packages/server/src/http/routes/chat-defaults.ts new file mode 100644 index 0000000..1c69ddf --- /dev/null +++ b/packages/server/src/http/routes/chat-defaults.ts @@ -0,0 +1,65 @@ +/** + * New-chat defaults routes: GET|PUT /api/projects/:p/chat-defaults (the `[default_chat]` + * block of .project_config.toml). Any member can read (the draft page prefills from it); + * only the owner can replace it. PUT is declarative whole-block replace: an omitted key + * clears it, an empty body removes the block. The default MODEL is not part of this block — + * it stays the top-level `default_model` maintained via the models routes. + */ +import { Hono } from "hono"; +import { DEFAULT_CHAT_THINKING_LEVELS, isValidId } from "@prismshadow/penguin-core"; +import type { ChatDefaultsDto } from "../../api/types.js"; +import type { AppEnv } from "../../auth/middleware.js"; +import { HttpError } from "../errors.js"; +import { optionalEnum, optionalString, readJson, requireValidId } from "../validate.js"; +import { APPROVAL_MODES } from "./sessions.js"; +import type { AppDeps } from "../../app.js"; + +export function chatDefaultsRoutes(deps: AppDeps): Hono { + const app = new Hono(); + + app.get("/", async (c) => { + // Defensive id validation (FD-4). + const projectId = requireValidId(c, "projectId"); + deps.projectService.requireProjectAccess(c.var.user.userId, projectId); + return c.json(await deps.projectConfigService.getChatDefaults(projectId)); + }); + + app.put("/", async (c) => { + const projectId = requireValidId(c, "projectId"); + deps.projectService.requireProjectOwner(c.var.user.userId, projectId); + const body = await readJson(c); + const req: ChatDefaultsDto = {}; + + // agentId must reference an existing Agent of this Project (a stale default would make + // every prefilled draft 404). isValidId first: existence is checked by path + // (system_config.yaml presence, the same rule the agent routes use), so a junk id must + // never reach path construction. + const agentId = optionalString(body, "agentId", { minLen: 1, maxLen: 64, label: "agentId" }); + if (agentId !== undefined) { + if (!isValidId(agentId) || !(await deps.agentConfigService.exists(projectId, agentId))) { + throw new HttpError( + 400, + "unknown_agent", + `agentId does not reference an Agent of this Project: ${agentId}.`, + ); + } + req.agentId = agentId; + } + + // Not validated as an existing directory on purpose: the default is a prefill, and the + // directory is (re)checked when a Session is actually created. "" = clear (auto temp). + const workspace = optionalString(body, "workspace", { maxLen: 4096, label: "workspace" }); + if (workspace !== undefined && workspace !== "") req.workspace = workspace; + + const approvalMode = optionalEnum(body, "approvalMode", APPROVAL_MODES); + if (approvalMode !== undefined) req.approvalMode = approvalMode; + + // Only the four selectable tiers — "none" is rejected (never offered as a default). + const thinkingLevel = optionalEnum(body, "thinkingLevel", DEFAULT_CHAT_THINKING_LEVELS); + if (thinkingLevel !== undefined) req.thinkingLevel = thinkingLevel; + + return c.json(await deps.projectConfigService.setChatDefaults(projectId, req)); + }); + + return app; +} diff --git a/packages/server/src/http/routes/models.ts b/packages/server/src/http/routes/models.ts index 2686e0a..032945f 100644 --- a/packages/server/src/http/routes/models.ts +++ b/packages/server/src/http/routes/models.ts @@ -1,11 +1,13 @@ /** * Model & credential config routes: - * GET|PUT /api/projects/:p/models, POST /api/projects/:p/models/test (the model reference + * GET|PUT /api/projects/:p/models, PUT /api/projects/:p/models/default, + * POST /api/projects/:p/models/test (the model reference * `(provider, modelId)` is sent as a pair in the request body, avoiding URL-encoding * issues). Any member can read (api_key is masked); only the owner can modify or test. */ import { Hono } from "hono"; import type { + DefaultModelResponse, ModelRefDto, ModelsUpdateRequest, ModelTestRequest, @@ -168,6 +170,24 @@ export function modelsRoutes(deps: AppDeps): Hono { return c.json(res); }); + // Narrow default-model switch (owner): flips the same top-level `default_model` the + // whole-table PUT above maintains, without resending the table — project settings can + // change the default without carrying credentials. The pair must name a configured + // entry (400 otherwise, same rule as the whole-table route's defaultModel). No runtime + // invalidation and no credentials_updated: existing Sessions pin their model at + // creation, and no credential changes here. + app.put("/default", async (c) => { + const projectId = requireValidId(c, "projectId"); + deps.projectService.requireProjectOwner(c.var.user.userId, projectId); + const body = await readJson(c); + const ref: ModelRefDto = { + provider: requireString(body, "provider", { minLen: 1, maxLen: 64 }), + modelId: requireString(body, "modelId", { minLen: 1, maxLen: 200 }), + }; + const defaultModel = await deps.projectConfigService.setDefaultModelRef(projectId, ref); + return c.json({ defaultModel } satisfies DefaultModelResponse); + }); + // Connectivity test (owner): the model reference `(provider, modelId)` is sent as a pair // in the request body; sends one minimal request using that model's config. May include // not-yet-saved apiKey / baseUrl / clientType — when the model isn't in the config yet diff --git a/packages/server/src/http/routes/sessions.ts b/packages/server/src/http/routes/sessions.ts index 822eef2..435ed2c 100644 --- a/packages/server/src/http/routes/sessions.ts +++ b/packages/server/src/http/routes/sessions.ts @@ -61,7 +61,8 @@ const SESSION_TITLE_MAX = 120; const STAT_MAX_PATHS = 100; const STAT_MAX_PATH_LEN = 512; -const APPROVAL_MODES: readonly ApprovalMode[] = [ +/** The four approval modes (shared with the chat-defaults route's validation). */ +export const APPROVAL_MODES: readonly ApprovalMode[] = [ "allow-all", "deny-all", "read-only", diff --git a/packages/server/src/services/project-config-service.ts b/packages/server/src/services/project-config-service.ts index d0cc8e4..b22ea80 100644 --- a/packages/server/src/services/project-config-service.ts +++ b/packages/server/src/services/project-config-service.ts @@ -19,6 +19,8 @@ import fs from "node:fs/promises"; import path from "node:path"; import { parse as parseToml } from "smol-toml"; import { + CHAT_APPROVAL_MODES, + DEFAULT_CHAT_THINKING_LEVELS, GenerativeModel, catalogEntryFor, defaultProjectConfig, @@ -29,6 +31,7 @@ import { } from "@prismshadow/penguin-core"; import type { LLMOutcome, ModelRef, OmniMessage } from "@prismshadow/penguin-core"; import type { + ChatDefaultsDto, ModelInfo, ModelPricingDto, ModelRefDto, @@ -224,6 +227,77 @@ export class ProjectConfigService { return optRef(raw.default_model); } + /** + * Sets the default Model to an already-configured entry (the narrow + * PUT /models/default route): rewrites only the top-level `default_model` — the SAME key + * the models page's whole-table PUT maintains, so the two surfaces stay single-sourced — + * preserving every other field via read-modify-write. The pair must name an entry in + * `models` (the identical rule updateModels applies to `defaultModel`); a reference + * outside the config is a 400, since createSession would error on it immediately. No + * runtime invalidation: existing Sessions pin their model at creation, and this route + * never touches credentials. + */ + async setDefaultModelRef(projectId: string, ref: ModelRefDto): Promise { + const raw = await this.readRaw(projectId); + if (!asArray(raw.models).some((m) => entryMatches(m, ref.provider, ref.modelId))) { + throw badRequest( + `defaultModel must be included in models: ${showRef(ref.provider, ref.modelId)}.`, + ); + } + await this.writeRaw(projectId, { + ...raw, + default_model: { provider: ref.provider, model_id: ref.modelId }, + }); + return { provider: ref.provider, modelId: ref.modelId }; + } + + /** + * New-chat defaults (`[default_chat]`): read leniently — same tolerance as core's + * loadProjectConfig (an invalid value drops that key; a missing/malformed block reads as + * empty). Members may read; only the fields present in the file appear in the DTO. + */ + async getChatDefaults(projectId: string): Promise { + const raw = await this.readRaw(projectId); + const t = asTable(raw.default_chat); + const agentId = optStr(t.agent_id); + const workspace = optStr(t.workspace); + const approval = optStr(t.approval_mode); + const thinking = optStr(t.thinking_level); + return { + ...(agentId !== undefined ? { agentId } : {}), + ...(workspace !== undefined ? { workspace } : {}), + ...(approval !== undefined && (CHAT_APPROVAL_MODES as readonly string[]).includes(approval) + ? { approvalMode: approval as ChatDefaultsDto["approvalMode"] } + : {}), + ...(thinking !== undefined && + (DEFAULT_CHAT_THINKING_LEVELS as readonly string[]).includes(thinking) + ? { thinkingLevel: thinking as ChatDefaultsDto["thinkingLevel"] } + : {}), + }; + } + + /** + * Replaces the whole `[default_chat]` block (declarative PUT: an omitted key clears it; + * an empty request removes the block entirely, restoring the pre-existing behavior). + * Field validation (enums / agent existence) happens at the route; this is a + * read-modify-write like setName — every other field (models, credentials, name, the + * default model) is preserved. Returns the stored block as re-read from disk. + */ + async setChatDefaults(projectId: string, req: ChatDefaultsDto): Promise { + const raw = await this.readRaw(projectId); + const block: RawTable = { + ...(req.agentId !== undefined ? { agent_id: req.agentId } : {}), + ...(req.workspace !== undefined ? { workspace: req.workspace } : {}), + ...(req.approvalMode !== undefined ? { approval_mode: req.approvalMode } : {}), + ...(req.thinkingLevel !== undefined ? { thinking_level: req.thinkingLevel } : {}), + }; + const next: RawTable = { ...raw }; + if (Object.keys(block).length > 0) next.default_chat = block; + else delete next.default_chat; + await this.writeRaw(projectId, next); + return this.getChatDefaults(projectId); + } + /** Pricing lookup for usage-recorder: the current pricing for this paired reference (undefined if none -> cost is NULL). */ async getPricing( projectId: string, diff --git a/packages/server/test/project-chat-defaults.test.ts b/packages/server/test/project-chat-defaults.test.ts new file mode 100644 index 0000000..edec71d --- /dev/null +++ b/packages/server/test/project-chat-defaults.test.ts @@ -0,0 +1,228 @@ +/** + * GET|PUT /api/projects/:p/chat-defaults — per-Project new-chat defaults (the + * `[default_chat]` block of .project_config.toml) — and PUT /api/projects/:p/models/default, + * the narrow default-model switch project settings uses. + * + * Pins the contract's load-bearing corners: who may read (any member) and write (owner + * only, with a non-member unable to tell the Project exists), that PUT is a declarative + * whole-block replace (an omitted key clears it; an empty body removes the block), that a + * defaults write is a read-modify-write of the same toml — models, credentials and the + * display name must survive — and that agentId / enum values are validated (the model + * default stays the SAME top-level `default_model` the models page maintains, so the + * narrow route must reject a pair outside the models table exactly like the whole-table + * PUT does). + */ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import fs from "node:fs/promises"; +import path from "node:path"; +import type { + ChatDefaultsDto, + DefaultModelResponse, + ErrorBody, + ModelsResponse, + ProjectCreateResponse, +} from "../src/api/types.js"; +import { apiClient, createTestApp, provisionUser } from "./helpers.js"; +import type { TestApp } from "./helpers.js"; + +describe("project chat defaults", () => { + let t: TestApp; + let owner: ReturnType; + let member: ReturnType; + let outsider: ReturnType; + let projectId: string; + let url: string; + + beforeEach(async () => { + t = await createTestApp(); + const a = await provisionUser(t.app, "owner_a"); + const b = await provisionUser(t.app, "member_b"); + const c = await provisionUser(t.app, "outsider_c"); + owner = apiClient(t.app, a.cookie); + member = apiClient(t.app, b.cookie); + outsider = apiClient(t.app, c.cookie); + // createProject provisions the built-in default_agent, so a valid agentId exists. + const created = (await ( + await owner.post("/api/projects", { projectId: "owner_a-shared", name: "Shared" }) + ).json()) as ProjectCreateResponse; + projectId = created.project.projectId; + url = `/api/projects/${projectId}/chat-defaults`; + expect( + (await owner.post(`/api/projects/${projectId}/members`, { userId: "member_b" })).status, + ).toBe(201); + }); + afterEach(async () => { + await t.cleanup(); + }); + + it("any member reads; an absent block is an empty object; a non-member gets 404", async () => { + const res = await member.get(url); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({}); + expect((await outsider.get(url)).status).toBe(404); + }); + + it("owner PUT round-trips all four fields; the toml carries [default_chat]", async () => { + const body: ChatDefaultsDto = { + agentId: "default_agent", + workspace: "/tmp/anywhere", // not validated as an existing directory (a prefill only) + approvalMode: "always-ask", + thinkingLevel: "high", + }; + const put = await owner.put(url, body); + expect(put.status).toBe(200); + expect(await put.json()).toEqual(body); + expect(await (await member.get(url)).json()).toEqual(body); + + const toml = await fs.readFile(path.join(t.root, projectId, ".project_config.toml"), "utf8"); + expect(toml).toContain("[default_chat]"); + expect(toml).toContain('agent_id = "default_agent"'); + expect(toml).toContain('thinking_level = "high"'); + }); + + it("PUT is whole-block replace: an omitted key clears it; an empty body removes the block", async () => { + expect( + (await owner.put(url, { agentId: "default_agent", approvalMode: "read-only" })).status, + ).toBe(200); + // Replace with a different subset: the previous keys must be gone. + const second = await owner.put(url, { thinkingLevel: "low" }); + expect(await second.json()).toEqual({ thinkingLevel: "low" }); + expect(await (await owner.get(url)).json()).toEqual({ thinkingLevel: "low" }); + // Empty body: the block disappears from the toml entirely. + expect(await (await owner.put(url, {})).json()).toEqual({}); + const toml = await fs.readFile(path.join(t.root, projectId, ".project_config.toml"), "utf8"); + expect(toml).not.toContain("default_chat"); + }); + + it("owner only: a member PUT gets 403, a non-member 404 (existence is not leaked)", async () => { + expect((await member.put(url, { thinkingLevel: "low" })).status).toBe(403); + expect((await outsider.put(url, { thinkingLevel: "low" })).status).toBe(404); + expect(await (await owner.get(url)).json()).toEqual({}); + }); + + it("agentId must reference an existing Agent of the Project (400 unknown_agent)", async () => { + const res = await owner.put(url, { agentId: "ghost_agent" }); + expect(res.status).toBe(400); + expect(((await res.json()) as ErrorBody).error.code).toBe("unknown_agent"); + // An id that is not even shaped like one gets the same 400 before any path is built. + expect((await owner.put(url, { agentId: "../escape" })).status).toBe(400); + expect(await (await owner.get(url)).json()).toEqual({}); + }); + + it("rejects invalid enum values with 400 — including thinkingLevel 'none'", async () => { + expect((await owner.put(url, { approvalMode: "yolo" })).status).toBe(400); + // "none" is a valid per-turn wire value but never a project default. + expect((await owner.put(url, { thinkingLevel: "none" })).status).toBe(400); + expect((await owner.put(url, { thinkingLevel: 3 })).status).toBe(400); + expect(await (await owner.get(url)).json()).toEqual({}); + }); + + it("keeps models, credentials and the name — the write is read-modify-write", async () => { + expect( + ( + await owner.put(`/api/projects/${projectId}/models`, { + defaultModel: { provider: "custom", modelId: "m-1" }, + models: [{ provider: "custom", modelId: "m-1", apiKey: "sk-super-secret-key-123456" }], + }) + ).status, + ).toBe(200); + + expect((await owner.put(url, { approvalMode: "deny-all" })).status).toBe(200); + + const models = (await ( + await owner.get(`/api/projects/${projectId}/models`) + ).json()) as ModelsResponse; + expect(models.defaultModel).toEqual({ provider: "custom", modelId: "m-1" }); + expect(models.models).toHaveLength(1); + // The key is masked on read, so assert on the file: a replacing write would drop it. + const toml = await fs.readFile(path.join(t.root, projectId, ".project_config.toml"), "utf8"); + expect(toml).toContain("sk-super-secret-key-123456"); + expect(toml).toContain('name = "Shared"'); + expect(toml).toContain('approval_mode = "deny-all"'); + }); + + it("a config carrying [default_chat] still parses back whole (the table never swallows scalars)", async () => { + // Defaults written BEFORE a name rewrite: the later RMW re-renders the whole file, and + // the [default_chat] table must end up below the re-appended scalar keys. + expect((await owner.put(url, { workspace: "/srv/data" })).status).toBe(200); + expect((await owner.patch(`/api/projects/${projectId}`, { name: "Renamed" })).status).toBe(200); + expect(await (await owner.get(url)).json()).toEqual({ workspace: "/srv/data" }); + const toml = await fs.readFile(path.join(t.root, projectId, ".project_config.toml"), "utf8"); + expect(toml).toContain('name = "Renamed"'); + }); +}); + +describe("models default (narrow default-model switch)", () => { + let t: TestApp; + let owner: ReturnType; + let member: ReturnType; + let outsider: ReturnType; + let projectId: string; + let url: string; + + beforeEach(async () => { + t = await createTestApp(); + const a = await provisionUser(t.app, "owner_a"); + const b = await provisionUser(t.app, "member_b"); + const c = await provisionUser(t.app, "outsider_c"); + owner = apiClient(t.app, a.cookie); + member = apiClient(t.app, b.cookie); + outsider = apiClient(t.app, c.cookie); + const created = (await ( + await owner.post("/api/projects", { projectId: "owner_a-models", name: "Models" }) + ).json()) as ProjectCreateResponse; + projectId = created.project.projectId; + url = `/api/projects/${projectId}/models/default`; + // Two configured models, m-1 as the default, a credential to preserve. + expect( + ( + await owner.put(`/api/projects/${projectId}/models`, { + defaultModel: { provider: "custom", modelId: "m-1" }, + models: [ + { provider: "custom", modelId: "m-1", apiKey: "sk-super-secret-key-123456" }, + { provider: "custom", modelId: "m-2" }, + ], + }) + ).status, + ).toBe(200); + expect( + (await owner.post(`/api/projects/${projectId}/members`, { userId: "member_b" })).status, + ).toBe(201); + }); + afterEach(async () => { + await t.cleanup(); + }); + + it("owner flips the default; the response mirrors GET models' defaultModel; credentials survive", async () => { + const res = await owner.put(url, { provider: "custom", modelId: "m-2" }); + expect(res.status).toBe(200); + expect((await res.json()) as DefaultModelResponse).toEqual({ + defaultModel: { provider: "custom", modelId: "m-2" }, + }); + const models = (await ( + await owner.get(`/api/projects/${projectId}/models`) + ).json()) as ModelsResponse; + expect(models.defaultModel).toEqual({ provider: "custom", modelId: "m-2" }); + expect(models.models.find((m) => m.modelId === "m-2")?.isDefault).toBe(true); + // RMW: the other entry's inline credential is untouched. + const toml = await fs.readFile(path.join(t.root, projectId, ".project_config.toml"), "utf8"); + expect(toml).toContain("sk-super-secret-key-123456"); + }); + + it("400 when the pair is not in the models table (a mismatched provider included)", async () => { + expect((await owner.put(url, { provider: "custom", modelId: "nope" })).status).toBe(400); + // The bare id exists but under another provider: exact pair matching, no fuzzy fallback. + expect((await owner.put(url, { provider: "openai", modelId: "m-2" })).status).toBe(400); + // Half a reference is a shape error, not a lookup. + expect((await owner.put(url, { provider: "custom" })).status).toBe(400); + const models = (await ( + await owner.get(`/api/projects/${projectId}/models`) + ).json()) as ModelsResponse; + expect(models.defaultModel).toEqual({ provider: "custom", modelId: "m-1" }); + }); + + it("owner only: a member gets 403, a non-member 404", async () => { + expect((await member.put(url, { provider: "custom", modelId: "m-2" })).status).toBe(403); + expect((await outsider.put(url, { provider: "custom", modelId: "m-2" })).status).toBe(404); + }); +}); diff --git a/packages/web/src/api/endpoints.ts b/packages/web/src/api/endpoints.ts index bd8372e..3116ce7 100644 --- a/packages/web/src/api/endpoints.ts +++ b/packages/web/src/api/endpoints.ts @@ -24,6 +24,9 @@ import type { BenchmarkCasesResponse, BenchmarksResponse, CaseMaterial, + ChatDefaultsDto, + DefaultModelResponse, + DefaultModelUpdateRequest, DirListResponse, FilesStatRequest, FilesStatResponse, @@ -143,6 +146,17 @@ export const removeMember = (projectId: string, username: string) => { method: "DELETE" }, ); +/** New-chat defaults ([default_chat]): member-readable prefill for the draft page. */ +export const getChatDefaults = (projectId: string) => + apiFetch(`/api/projects/${encodeURIComponent(projectId)}/chat-defaults`); + +/** Whole-block replace (owner): an omitted key clears it; returns the stored block. */ +export const putChatDefaults = (projectId: string, body: ChatDefaultsDto) => + apiFetch(`/api/projects/${encodeURIComponent(projectId)}/chat-defaults`, { + method: "PUT", + body, + }); + // Model configuration ------------------------------------------------------------------- export const getModels = (projectId: string) => @@ -154,6 +168,13 @@ export const putModels = (projectId: string, body: ModelsUpdateRequest) => body, }); +/** Narrow default-model switch (owner): flips the same default_model the models page maintains, without resending the table. */ +export const putDefaultModel = (projectId: string, body: DefaultModelUpdateRequest) => + apiFetch(`/api/projects/${encodeURIComponent(projectId)}/models/default`, { + method: "PUT", + body, + }); + /** Connectivity test: model reference (provider, modelId) is passed in the request body (may include an unsaved apiKey / baseUrl). */ export const testModel = (projectId: string, body: ModelTestRequest) => apiFetch(`/api/projects/${encodeURIComponent(projectId)}/models/test`, { diff --git a/packages/web/src/components/layout/project-dialogs.tsx b/packages/web/src/components/layout/project-dialogs.tsx index a5f9697..ffcbe95 100644 --- a/packages/web/src/components/layout/project-dialogs.tsx +++ b/packages/web/src/components/layout/project-dialogs.tsx @@ -3,7 +3,13 @@ * (member management and deletion, owner only). Invoked from the sidebar's Project switcher. */ import { useEffect, useState } from "react"; -import type { MemberInfo } from "@prismshadow/penguin-server/api"; +import type { + ApprovalMode, + ChatDefaultsDto, + MemberInfo, + ModelRefDto, + ModelsResponse, +} from "@prismshadow/penguin-server/api"; import * as api from "../../api/endpoints"; import { S } from "../../lib/strings"; import { apiErrorText } from "../../lib/api-error"; @@ -12,16 +18,30 @@ import { PROJECT_SUFFIX_PATTERN, SEMANTIC_ID_PATTERN, } from "../../lib/semantic-id"; -import { projectDisplayName, useProject } from "../../state/project"; +import { agentDisplayName, projectDisplayName, useProject } from "../../state/project"; import { useAuth } from "../../state/auth"; +import { clearDraftModelRef } from "../../features/chat/draft-cache"; +import { ModelSelect } from "../../features/chat/model-select"; +import { SELECTABLE_THINKING_LEVELS } from "../../features/chat/thinking-level"; +import { WorkspaceSelect } from "../../features/chat/workspace-select"; +import { sameModelRef } from "../../features/models/model-grouping"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; +import { Select } from "../ui/select"; import { FieldError, FieldHint, FieldLabel } from "../ui/field"; import { toastError } from "../ui/toast"; import { Modal } from "../ui/modal"; import { ConfirmModal } from "../ui/confirm-modal"; import { Badge } from "../ui/badge"; +/** Approval modes offered by the new-chat-defaults select, in the composer menu's order. */ +const APPROVAL_MODES: readonly ApprovalMode[] = [ + "always-ask", + "read-only", + "allow-all", + "deny-all", +]; + export function CreateProjectDialog({ open, onClose, @@ -353,6 +373,8 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos )} + + {isOwner && (
{projectId === "default_project" ? ( @@ -382,3 +404,251 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos ); } + +/** + * "New chat defaults" section of the Project settings dialog (below Members, above the + * delete zone): the `[default_chat]` block (Agent / Workspace / approval mode / thinking + * level) plus the Project's default model, laid out as a compact responsive two-column + * grid. Workspace and model reuse the chat draft's own pickers — WorkspaceSelect (the + * dir browser) and ModelSelect (the composer's model dropdown) — with their `form` + * trigger variant, so the controls line up with the dialog's Input/Select while the + * POPOVER menus stay exactly the composer's. + * The model default is SINGLE-SOURCED with the models page — the picker renders and writes + * the same top-level `default_model` (via the narrow PUT /models/default route), never a + * second key; changing it also releases the draft-cached model pin exactly as the models + * page does (shared clearDraftModelRef helper). Owner edits with ONE explicit Save for the + * whole section (dialog convention: failures toast, success is silent — the refreshed + * values are the confirmation); members see the values read-only. Mounted per dialog open + * (the Modal unmounts its children when closed), so reopening always refetches. + */ +function ChatDefaultsSection({ projectId, isOwner }: { projectId: string; isOwner: boolean }) { + const { user } = useAuth(); + const { agents } = useProject(); + /** Saved block (null while loading); edit buffers below use "" for "not set". */ + const [saved, setSaved] = useState(null); + const [models, setModels] = useState(null); + const [loadError, setLoadError] = useState(null); + const [busy, setBusy] = useState(false); + const [agentId, setAgentId] = useState(""); + const [workspace, setWorkspace] = useState(""); + const [approval, setApproval] = useState(""); + const [thinking, setThinking] = useState(""); + /** The default-model pick (paired reference; seeded from the models response's defaultModel). */ + const [modelRef, setModelRef] = useState(null); + + useEffect(() => { + let cancelled = false; + api + .getChatDefaults(projectId) + .then((res) => { + if (cancelled) return; + setSaved(res); + setAgentId(res.agentId ?? ""); + setWorkspace(res.workspace ?? ""); + setApproval(res.approvalMode ?? ""); + setThinking(res.thinkingLevel ?? ""); + }) + .catch((e: unknown) => { + if (!cancelled) setLoadError(apiErrorText(e)); + }); + api + .getModels(projectId) + .then((res) => { + if (cancelled) return; + setModels(res); + setModelRef(res.defaultModel ?? null); + }) + .catch((e: unknown) => { + if (!cancelled) setLoadError(apiErrorText(e)); + }); + return () => { + cancelled = true; + }; + }, [projectId]); + + const blockDirty = + saved !== null && + (agentId !== (saved.agentId ?? "") || + workspace.trim() !== (saved.workspace ?? "") || + approval !== (saved.approvalMode ?? "") || + thinking !== (saved.thinkingLevel ?? "")); + const modelDirty = + models !== null && modelRef !== null && !sameModelRef(models.defaultModel, modelRef); + + /** + * One Save persists both writes: the `[default_chat]` block (whole-block PUT — a field + * left "not set" is simply omitted, which clears it) and, when changed, the default + * model via the narrow route. Failures toast and keep the edits for retry. + */ + const save = async () => { + if (busy || (!blockDirty && !modelDirty)) return; + setBusy(true); + try { + if (blockDirty) { + const body: ChatDefaultsDto = { + ...(agentId ? { agentId } : {}), + ...(workspace.trim() ? { workspace: workspace.trim() } : {}), + ...(approval ? { approvalMode: approval as ApprovalMode } : {}), + ...(thinking ? { thinkingLevel: thinking as ChatDefaultsDto["thinkingLevel"] } : {}), + }; + setSaved(await api.putChatDefaults(projectId, body)); + } + if (modelDirty && modelRef) { + const res = await api.putDefaultModel(projectId, { + provider: modelRef.provider, + modelId: modelRef.modelId, + }); + setModels((m) => (m ? { ...m, defaultModel: res.defaultModel } : m)); + // Same follow-through as the models page: drop the draft-cached model pin so open + // drafts pick up the new default. + if (user) clearDraftModelRef(user.userId, projectId); + } + } catch (e) { + toastError(apiErrorText(e)); + } finally { + setBusy(false); + } + }; + + /** Read-only display values (member view). */ + const agentText = saved?.agentId + ? (() => { + const a = agents.find((x) => x.agentId === saved.agentId); + return a ? agentDisplayName(a) : saved.agentId; + })() + : S.project.chatDefaultsNotSet; + const defaultModelInfo = models?.models.find((m) => sameModelRef(m, models.defaultModel)); + + return ( +
+

{S.project.chatDefaultsTitle}

+

{S.project.chatDefaultsHint}

+ {loadError ? ( +

{loadError}

+ ) : saved === null || models === null ? ( +

{S.common.loading}

+ ) : isOwner ? ( + <> + {/* Compact responsive grid: label + control stacked per cell, two columns from sm + up; the workspace picker spans the full row for path width. Workspace and model + are the chat draft's own pickers, not plain form controls. */} +
+ + + +
+ {S.chat.model} + {models.models.length > 0 ? ( + <> + {/* The composer's model dropdown (provider logo + name + searchable grouped + panel); the default row carries the S.models.default marker. */} + + {S.project.chatDefaultsModelHint} + + ) : ( +

{S.models.empty}

+ )} +
+
+ {S.chat.workspace} + {/* The draft page's dir-browser pill: browse server directories, edit the path + inline, or clear back to the auto temp directory. */} + + {S.project.chatDefaultsWorkspaceHint} +
+
+
+ +
+ + ) : ( + // Member view: the effective defaults, read-only (same fields, plain text). +
+ {( + [ + [S.project.chatDefaultsAgent, agentText], + [S.chat.workspace, saved.workspace ?? S.chat.workspaceAuto], + [ + S.chat.approvalMode, + saved.approvalMode + ? (S.chat.approvalModeNames[saved.approvalMode] ?? saved.approvalMode) + : S.project.chatDefaultsApprovalNotSet, + ], + [ + S.chat.thinkingLevel, + saved.thinkingLevel + ? (S.chat.thinkingLevelNames[saved.thinkingLevel] ?? saved.thinkingLevel) + : S.project.chatDefaultsThinkingNotSet, + ], + [ + S.chat.model, + defaultModelInfo + ? (defaultModelInfo.displayName ?? defaultModelInfo.modelId) + : S.project.chatDefaultsNotSet, + ], + ] as const + ).map(([label, value]) => ( +
+ {label} + {value} +
+ ))} +
+ )} +
+ ); +} diff --git a/packages/web/src/components/ui/dropdown.tsx b/packages/web/src/components/ui/dropdown.tsx index 8de53a0..b39b66b 100644 --- a/packages/web/src/components/ui/dropdown.tsx +++ b/packages/web/src/components/ui/dropdown.tsx @@ -1,7 +1,9 @@ /** * Dropdown container (controlled): clicking outside collapses it; the panel is absolutely - * positioned (z-40, per the layering convention — chrome avoids stacking contexts, menus are - * z-40, overlays are z-50). menuClass controls the docking direction and width. + * positioned (z-40, per the layering convention — chrome avoids stacking contexts, in-flow + * menus are z-40, overlays are z-50, and **portaled** popups are z-[60] so they clear an + * open Modal, matching Select's portal panel). menuClass controls the docking direction and + * width. * * `portal` switches the panel to the shared popup strategy (same idea as OptionMenu's * use-portal-panel): mounted on document.body with `position: fixed` against viewport @@ -9,10 +11,17 @@ * container (the composer toolbar scrolls horizontally on phones) makes that mandatory: * setting one axis to a non-`visible` overflow makes the other compute to `auto` too, so an * absolutely-positioned panel — an upward one especially — is clipped away entirely. + * + * Escape goes through the shared esc-layer stack (see modal.tsx): while open, the menu is + * the topmost layer, so one Escape closes only the menu even inside a Modal — the Modal's + * own handler sees itself not on top and stays; the next Escape closes the dialog. The + * event is deliberately NOT stopped, so element-level Escape handlers inside the panel + * (e.g. an input reverting its draft) still run on the same press. */ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import type { CSSProperties, ReactNode } from "react"; +import { isTopEscLayer, popEscLayer, pushEscLayer } from "./modal"; /** Gap between the trigger and the portaled panel, and the panel's minimum distance from the viewport edge (px). */ const PANEL_GAP = 4; @@ -64,26 +73,38 @@ export function Dropdown({ maxHeight: number; } | null>(null); + // Latest-callback refs: the open effect below must re-run ONLY on open/close — if it also + // re-ran on a callback identity change, its esc-layer would pop and re-push, wrongly + // jumping above any dialog opened in the meantime. + const setOpenRef = useRef(setOpen); + setOpenRef.current = setOpen; + const onEscapeRef = useRef(onEscape); + onEscapeRef.current = onEscape; + useEffect(() => { if (!open) return; const onClick = (e: MouseEvent) => { const target = e.target as Node; if (ref.current?.contains(target)) return; if (panelRef.current?.contains(target)) return; - setOpen(false); + setOpenRef.current(false); }; + // Escape closes only when this menu is the topmost esc layer (see the header comment). + const layer = pushEscLayer(); const onKey = (e: KeyboardEvent) => { - if (e.key !== "Escape") return; - if (onEscape) onEscape(); - else setOpen(false); + if (e.key !== "Escape" || !isTopEscLayer(layer)) return; + const esc = onEscapeRef.current; + if (esc) esc(); + else setOpenRef.current(false); }; window.addEventListener("mousedown", onClick); window.addEventListener("keydown", onKey); return () => { + popEscLayer(layer); window.removeEventListener("mousedown", onClick); window.removeEventListener("keydown", onKey); }; - }, [open, setOpen, onEscape]); + }, [open]); // Portal mode: place the panel against the trigger's viewport rect, clamped to stay fully // on-screen. Measured after paint so the panel's own (class-driven) size is known — the @@ -145,7 +166,10 @@ export function Dropdown({ }; }, [open, portal, place]); - const panelClass = `anim-pop z-40 overflow-y-auto rounded-md border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-900 ${ + // Portaled panels sit at z-[60], above a Modal's z-50 overlay (a body portal appended + // after the overlay would otherwise paint UNDER it and be unclickable); in-flow panels + // keep the z-40 menu tier — they stack within their host's own context. + const panelClass = `anim-pop ${portal ? "z-[60]" : "z-40"} overflow-y-auto rounded-md border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-900 ${ portal ? "" : "max-h-[70vh]" } ${menuClass ?? "left-0 top-full mt-1 w-64 max-w-[calc(100vw-2rem)] origin-top-left"}`; diff --git a/packages/web/src/components/ui/modal.tsx b/packages/web/src/components/ui/modal.tsx index af6bb67..0a0fede 100644 --- a/packages/web/src/components/ui/modal.tsx +++ b/packages/web/src/components/ui/modal.tsx @@ -30,14 +30,34 @@ export interface ModalProps { } /** - * Stack of currently open Modals: Escape only closes the **topmost** one. When - * dialogs are nested (e.g. a confirmation popped inside a settings modal), each - * Modal registers its own window keydown->onClose; without checking the top of - * the stack, a single Escape would close both layers at once and discard unsaved - * edits in the outer modal. Pushed in mount order, so the top of the stack is the - * visually topmost dialog. + * Stack of currently open Escape-consuming layers: Escape only acts on the **topmost** + * one. Modals AND popup menus (Dropdown) join the same stack. When dialogs are nested + * (e.g. a confirmation popped inside a settings modal), each Modal registers its own + * window keydown->onClose; without checking the top of the stack, a single Escape would + * close both layers at once and discard unsaved edits in the outer modal. The same rule + * gives menus-inside-dialogs the right order: a Dropdown opened inside a Modal pushes + * above it, so the first Escape closes only the menu (the Modal's handler sees itself + * not on top and stays), and the next one closes the dialog. Pushed in mount order, so + * the top of the stack is the visually topmost layer. */ -const modalStack: symbol[] = []; +const escLayers: symbol[] = []; + +/** Register an Escape-consuming layer (called when a modal/menu opens); pair with popEscLayer. */ +export function pushEscLayer(): symbol { + const id = Symbol("esc-layer"); + escLayers.push(id); + return id; +} + +export function popEscLayer(id: symbol): void { + const i = escLayers.lastIndexOf(id); + if (i !== -1) escLayers.splice(i, 1); +} + +/** Whether this layer is the topmost one — the only layer an Escape press may act on. */ +export function isTopEscLayer(id: symbol): boolean { + return escLayers[escLayers.length - 1] === id; +} export function Modal({ open, @@ -50,16 +70,14 @@ export function Modal({ }: ModalProps) { useEffect(() => { if (!open) return; - const id = Symbol("modal"); - modalStack.push(id); + const id = pushEscLayer(); const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape" && modalStack[modalStack.length - 1] === id) onClose(); + if (e.key === "Escape" && isTopEscLayer(id)) onClose(); }; window.addEventListener("keydown", onKey); return () => { window.removeEventListener("keydown", onKey); - const i = modalStack.lastIndexOf(id); - if (i !== -1) modalStack.splice(i, 1); + popEscLayer(id); }; }, [open, onClose]); diff --git a/packages/web/src/features/chat/chat-input.tsx b/packages/web/src/features/chat/chat-input.tsx index cf45cdb..eef7866 100644 --- a/packages/web/src/features/chat/chat-input.tsx +++ b/packages/web/src/features/chat/chat-input.tsx @@ -87,14 +87,9 @@ import { toastError } from "../../components/ui/toast"; import { SkillIcon } from "../skills/skill-icon-view"; import { ZoomableImage } from "../../components/ui/image-zoom"; import { ProviderLogo } from "../../components/ui/provider-logo"; -import { Badge } from "../../components/ui/badge"; -import { - hasConfiguredKey, - isFreeModel, - sameModelRef, - visibleChatModels, -} from "../models/model-grouping"; +import { sameModelRef } from "../models/model-grouping"; import { filterAgents, stagedSendRoute } from "./agent-handoff"; +import { ModelMenuList, ModelSelect, PickerList, modelLabel } from "./model-select"; import { matchSlash, removeSlashToken } from "./slash-token"; import { SELECTABLE_THINKING_LEVELS, thinkingLevelLabel } from "./thinking-level"; import { @@ -248,308 +243,6 @@ function ApprovalModeSelect({ ); } -/** Display label for a model: the display name, or falls back to the upstream id (model_id is the raw field, no prefix parsing). */ -function modelLabel(m: ModelInfo): string { - return m.displayName ?? m.modelId; -} - -/** - * "No key" marker for the model dropdown's key-less rows: a key struck through by a prohibition - * slash (24x24 line art, grayscale via currentColor, matching the approval-mode icon style). - */ -const NO_KEY_ICON = - "M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4M2 2l20 20"; - -/** - * Candidate panel shared by every picker in this file (the model dropdown / `/model` switch - * picker and the `/agent` handoff picker): the search box, the internal scroll cap, the row - * chrome, the keyboard navigation and the "current entry" marker slot all live here, so the - * two pickers can differ only in what a row *contains* (provider logo vs Agent avatar) and in - * what they hang below the list (`footer`, e.g. the model list's "show all" expander). - * - * Keyboard navigation deliberately starts with **no** row highlighted: the search box is - * autofocused, and pre-highlighting a row would repaint a panel that has looked the same since - * before this control existed. ArrowDown/ArrowUp begin the navigation, and Enter/Tab commits — - * the highlighted row if there is one, otherwise the top match, which is what makes "type a few - * letters, press Enter" work. Escape is NOT handled here: each host closes its own panel at the - * window level (an IME-safe handler for the switch pickers, Dropdown's for the model dropdown). - */ -function PickerList({ - items, - itemKey, - isCurrent, - query, - onQueryChange, - searchPlaceholder, - emptyText, - onPick, - renderRow, - footer, -}: { - items: T[]; - /** Stable React key AND identity for the highlighted row. */ - itemKey: (item: T) => string; - /** Marks the entry already in effect (the session's model / its Agent): renders the ✓ slot and the emphasized row style. */ - isCurrent?: (item: T) => boolean; - query: string; - onQueryChange: (query: string) => void; - searchPlaceholder: string; - /** Shown in place of the list when the query matches nothing. */ - emptyText: string; - onPick: (item: T) => void; - /** The row's own content, left of the ✓ slot. */ - renderRow: (item: T) => ReactNode; - /** Pinned below the scroll area (mirroring the search box above it). */ - footer?: ReactNode; -}) { - // -1 = nothing highlighted yet (see the note above); reset whenever the candidate set changes. - const [active, setActive] = useState(-1); - const activeKey = active >= 0 && active < items.length ? itemKey(items[active]!) : null; - const onKeyDown = (e: KeyboardEvent) => { - if (items.length === 0) return; - if (e.key === "ArrowDown") { - e.preventDefault(); - setActive((i) => (i + 1) % items.length); - return; - } - if (e.key === "ArrowUp") { - e.preventDefault(); - setActive((i) => (i <= 0 ? items.length - 1 : i - 1)); - return; - } - // Same guard as the composer's own Enter handling: an IME commit must not be read as a pick. - if (((e.key === "Enter" && !e.shiftKey) || e.key === "Tab") && !e.nativeEvent.isComposing) { - e.preventDefault(); - onPick(items[active >= 0 ? active : 0]!); - } - }; - return ( -
- {/* Quick search (autofocused: it also owns the keyboard while the panel is up) */} -
- { - onQueryChange(e.target.value); - setActive(-1); - }} - placeholder={searchPlaceholder} - aria-label={searchPlaceholder} - {...noAutofill} - className="w-full rounded border border-transparent bg-transparent px-1 py-0.5 text-xs text-gray-700 placeholder:text-gray-400 focus:outline-none dark:text-gray-200 dark:placeholder:text-gray-500" - /> -
-
- {items.length === 0 &&

{emptyText}

} - {items.map((item) => { - const key = itemKey(item); - const current = isCurrent?.(item) ?? false; - return ( - - ); - })} -
- {footer} -
- ); -} - -/** - * Model candidate panel (search box + grouped list + "show all" expander) shared by the - * draft-state ModelSelect dropdown and the in-session `/model` switch picker. Search and - * expanded state are internal and reset by remount (both hosts only render the panel while - * open); the list is capped by an internal scroll (max-h-56) so it never overflows the - * viewport no matter how many models there are. - * Dropdown order mirrors the model library page (visibleChatModels): a top quick-search box - * (the model page's rule — filters by id / display name / provider name); by default only - * models with a configured API key are listed (stored masked key — the same standard as the - * model page's key status; `envKey` is merely the NAME of a fallback env var and doesn't - * count), with the selected and the default model always visible even without a key; a muted - * bottom row reveals the remaining key-less models (marked by a struck-through key icon, with - * the "no key" text in its title) without closing the menu or changing the selection — when - * no model has a key at all, everything is listed directly. Rows carry the provider logo, the - * light-yellow "Free" badge for zero-cost models (same as the model library card), the - * project-default marker, and the selected checkmark. - */ -function ModelMenuList({ - models, - value, - defaultModel, - onPick, -}: { - models: ModelInfo[]; - /** Currently selected (provider, modelId) pair; null = not yet chosen. */ - value: ModelRefDto | null; - defaultModel?: ModelRefDto; - onPick: (m: ModelInfo) => void; -}) { - const [query, setQuery] = useState(""); - // Expanded "show all" state: collapses back to key-configured models on each open (remount). - const [showAll, setShowAll] = useState(false); - const visible = visibleChatModels(models, { showAll, query, selected: value, defaultModel }); - // How many models the key filter hides under the current query (0 when expanded): drives the bottom "show all" row. - const hiddenCount = showAll - ? 0 - : visibleChatModels(models, { showAll: true, query, selected: value, defaultModel }).length - - visible.length; - return ( - `${m.provider}:${m.modelId}`} - isCurrent={(m) => sameModelRef(m, value)} - query={query} - onQueryChange={setQuery} - // Quick search: supports model id / display name / provider name - searchPlaceholder={S.models.searchPlaceholder} - emptyText={S.models.noSearchResults} - onPick={onPick} - renderRow={(m) => ( - <> - - {modelLabel(m)} - {/* Zero-cost rows (all three price buckets 0): same light-yellow "Free" badge as - the model library card, so free models stand out while picking. */} - {isFreeModel(m.pricing) && ( - - {S.models.freeBadge} - - )} - {/* Key-less rows (visible via show-all / selected / default / no-key-at-all) carry a - struck-through key icon (the "no key" text lives in the title/aria-label). */} - {!hasConfiguredKey(m) && ( - - - - )} - {sameModelRef(m, defaultModel) && ( - - {S.models.default} - - )} - - )} - // Bottom expander row (pinned below the scroll area, mirroring the search box on top): - // reveals the models hidden by the configured-key filter in place — the menu stays open - // and the selection is untouched. - {...(hiddenCount > 0 - ? { - footer: ( -
- -
- ), - } - : {})} - /> - ); -} - -/** - * Model selector (draft state only; docked to the left of the send button): the button shows - * the provider logo + name (logo only when the card is narrower than @md; the title carries - * the full name), and the menu opens **downward** — the draft card is vertically centered - * with room below. The candidate list itself is the shared ModelMenuList panel (search, - * key-configured-first grouping, Free badge, "show all" expander — documented there). - */ -function ModelSelect({ - models, - value, - defaultModel, - onChange, - disabled, -}: { - models: ModelInfo[]; - /** Currently selected (provider, modelId) pair; null = not yet chosen. */ - value: ModelRefDto | null; - defaultModel?: ModelRefDto; - onChange: (ref: ModelRefDto) => void; - disabled: boolean; -}) { - const [open, setOpen] = useState(false); - const current = models.find((m) => sameModelRef(m, value)); - // Display rule matches the model page's card: display name, or falls back to the upstream id (grouping is already conveyed by the provider logo). - const label = current ? modelLabel(current) : (value?.modelId ?? "…"); - return ( - setOpen(!open)} - className="flex h-8 max-w-44 shrink-0 items-center gap-1.5 rounded-md px-2 text-xs text-gray-500 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-800 disabled:cursor-not-allowed disabled:opacity-50 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200" - > - - {/* When the card is narrower than @md, only the provider logo remains (title shows the full name). */} - {label} - - - - - } - > - { - onChange({ provider: m.provider, modelId: m.modelId }); - setOpen(false); - }} - /> - - ); -} - /** * Agent candidate panel for the `/agent` switch picker — the agent-side counterpart of * ModelMenuList, and now literally the same panel (PickerList: search, scroll cap, keyboard diff --git a/packages/web/src/features/chat/draft-cache.ts b/packages/web/src/features/chat/draft-cache.ts index e95c822..59c5c1a 100644 --- a/packages/web/src/features/chat/draft-cache.ts +++ b/packages/web/src/features/chat/draft-cache.ts @@ -132,3 +132,20 @@ export function clearDraft(key: string, storage: DraftStorage = localStorage): v /* ignore */ } } + +/** + * Drops the draft-cached model selection for this user × Project, so an open draft follows + * a just-changed Project default model instead of pinning the old pick forever. Shared by + * the models page and the project-settings default-model control (single implementation — + * both surfaces flip the SAME `default_model`, so they must release the draft pin the same + * way). Everything else in the draft is preserved; a draft with no cached pick is a no-op. + */ +export function clearDraftModelRef( + userId: string, + projectId: string, + storage: DraftStorage = localStorage, +): void { + const key = draftKey(userId, projectId); + const draft = loadDraft(key, storage); + if (draft.modelRef) saveDraft(key, { ...draft, modelRef: undefined }, storage); +} diff --git a/packages/web/src/features/chat/draft-view.tsx b/packages/web/src/features/chat/draft-view.tsx index 36314aa..3110aec 100644 --- a/packages/web/src/features/chat/draft-view.tsx +++ b/packages/web/src/features/chat/draft-view.tsx @@ -22,16 +22,18 @@ * Agent via route state (overriding the cached selection); the workspace-mode * group header "+" additionally carries a Workspace path pre-filling the * Workspace selection ("" = auto temp directory). A direct visit or refresh - * falls back to the cache. + * falls back to the cache. When neither route state nor the mount-time cache claims a + * field, the Project's new-chat defaults ([default_chat]) prefill Agent / Workspace / + * approval mode (precedence: route state > draft cache > project default > built-in + * fallback); the model default already flows through models.defaultModel. */ import { useCallback, useEffect, useRef, useState } from "react"; -import type { MouseEvent as ReactMouseEvent } from "react"; import { useLocation, useNavigate } from "react-router"; import type { AgentModelConfigDto, AgentSummary, ApprovalMode, - DirListResponse, + ChatDefaultsDto, ModelRefDto, ModelsResponse, SessionCreateRequest, @@ -49,7 +51,6 @@ import { useSessions } from "../../state/sessions"; import { AgentAvatar } from "../../components/ui/agent-avatar"; import { Chevron } from "../../components/ui/chevron"; import { Dropdown } from "../../components/ui/dropdown"; -import { noAutofill } from "../../components/ui/input"; import { PenguinLogo } from "../../components/ui/penguin-logo"; import { toastError } from "../../components/ui/toast"; import { useVersionInfo } from "../../lib/use-version-info"; @@ -59,6 +60,8 @@ import { EXAMPLE_FOLDERS } from "./example-tasks"; import type { ExampleFolderId, ExampleTask, ExampleTaskId } from "./example-tasks"; import { clearDraft, draftKey, loadDraft, saveDraft } from "./draft-cache"; import type { DraftCache } from "./draft-cache"; +import { effectiveThinkingLevel } from "./thinking-level"; +import { WorkspaceSelect, pillClass } from "./workspace-select"; import { sameModelRef } from "../models/model-grouping"; /** Coalescing window for writing body text to the cache: keystrokes are frequent, so a short batch accumulates before persisting (option changes are still written immediately). */ @@ -152,6 +155,35 @@ export function DraftView({ */ const skillsRef = useRef(cached.skills ?? []); + // —— Project new-chat defaults ([default_chat]) —— + // Fetched once per Project mount (fail-soft: an error reads as "no defaults", so the + // draft keeps working). They prefill the seams below with the precedence + // route location.state > mount-time draft cache > project default > built-in fallback; + // null = still loading (the thinking picker below stays disabled until resolved). + const [chatDefaults, setChatDefaults] = useState(null); + useEffect(() => { + let cancelled = false; + api + .getChatDefaults(projectId) + .then((res) => { + if (!cancelled) setChatDefaults(res); + }) + .catch(() => { + if (!cancelled) setChatDefaults({}); + }); + return () => { + cancelled = true; + }; + }, [projectId]); + + /** + * Fields the user already touched this mount: the project defaults arrive async (after + * mount), and an explicit pick made in the meantime must never be clobbered by them. + * The mount-time cache (`cached`) covers everything picked in PREVIOUS visits; these + * refs cover the window between mount and the defaults resolving. + */ + const touchedRef = useRef({ agent: false, workspace: false, approval: false }); + // Unified resolution of the Agent selection (a single effect, single writer): // explicit route state > current valid value (from cache / panel selection) > // default_agent > the first one. Explicit intent (sidebar group header "+" / menu @@ -165,6 +197,8 @@ export function DraftView({ const routeState = location.state as { agentId?: string; workspace?: string } | null; const stateAgentId = routeState?.agentId; const appliedStateKey = useRef(null); + /** One-shot marker for the project-default Agent (seeding precedence, see below). */ + const appliedDefaultAgent = useRef(false); useEffect(() => { if (agents.length === 0) return; // list not ready yet, nothing to validate against — wait for the next pass const valid = (id: string | null | undefined): id is string => @@ -181,9 +215,26 @@ export function DraftView({ return; } } + // Project default ([default_chat].agent_id), inserted ahead of the fallback chain: + // applied at most once per mount, and only when nothing above it claims the field — + // no route override consumed this mount, no mount-time cached selection, no panel pick + // since mount (precedence: route state > draft cache > project default > the + // currentAgent/default_agent/first fallback the initial state and the line below give). + if (chatDefaults?.agentId !== undefined && !appliedDefaultAgent.current) { + appliedDefaultAgent.current = true; + if ( + appliedStateKey.current === null && + cached.agentId === undefined && + !touchedRef.current.agent && + valid(chatDefaults.agentId) + ) { + setAgentId(chatDefaults.agentId); + return; + } + } if (valid(agentId)) return; setAgentId((agents.find((a) => a.agentId === "default_agent") ?? agents[0])?.agentId ?? null); - }, [agents, agentId, location.key, stateAgentId]); + }, [agents, agentId, location.key, stateAgentId, chatDefaults, cached.agentId]); // Explicit Workspace from route state (the workspace-mode group header "+"): applied once per // location.key, same convention as the Agent above, overriding the cached selection ("" pre-fills @@ -204,6 +255,33 @@ export function DraftView({ setWorkspace(stateWorkspace); }, [location.key, stateWorkspace]); + // Project defaults for Workspace / approval mode: the same apply-once discipline as the + // route-state effects above, deferred until the defaults resolve. A field is only seeded + // when nothing with higher precedence claims it — no route override (workspace only), no + // mount-time cached value (a cached "" workspace counts: it is an explicit "auto temp"), + // and no user edit since mount. Model is deliberately not here (models.defaultModel + // already flows through its own fallback effect below — the single-sourced default). + const appliedProjectDefaults = useRef(false); + useEffect(() => { + if (chatDefaults === null || appliedProjectDefaults.current) return; + appliedProjectDefaults.current = true; + if ( + chatDefaults.workspace !== undefined && + stateWorkspace === undefined && + cached.workspace === undefined && + !touchedRef.current.workspace + ) { + setWorkspace(chatDefaults.workspace); + } + if ( + chatDefaults.approvalMode !== undefined && + cached.approvalMode === undefined && + !touchedRef.current.approval + ) { + setApprovalMode(chatDefaults.approvalMode); + } + }, [chatDefaults, stateWorkspace, cached.workspace, cached.approvalMode]); + // Model fallback: once config is ready, if nothing is selected or the selection is no longer valid, fall back to the project default → the first model (always as a paired reference). useEffect(() => { if (!models) return; @@ -215,42 +293,51 @@ export function DraftView({ }, [models, modelRef]); // —— Conversation-time thinking level (backed by the Agent settings) —— - // Shows the selected Agent's current `model.thinking_level` ("" = no override); picking a + // The picker DISPLAYS the effective level, resolved by the same chain core applies when + // the Session is created (core agent.ts `configuredThinkingLevel`): the Agent's explicit + // `model.thinking_level` > the Project's `default_chat.thinking_level` > the built-in + // "medium" (see effectiveThinkingLevel). `agentThinkingLevel` keeps the raw agent value + // ("" = no explicit override); the derived value below waits for BOTH fetches. Picking a // level immediately persists it via the agent-config API (the PUT carries only that key — - // the server merges per-key into the YAML, so nothing else is clobbered). The session created - // on first send reads systemConfig fresh, so it runs with the picked level, which also - // becomes the Agent's new default. Refetched whenever the draft's Agent changes; while - // loading (or after a failed fetch) the picker stays disabled (null). - const [thinkingLevel, setThinkingLevel] = useState(null); + // the server merges per-key into the YAML, so nothing else is clobbered): the session + // created on first send reads systemConfig fresh, so it runs with the picked level, which + // also becomes the Agent's new default — the project default is only a fallback and is + // never written from here. Refetched whenever the draft's Agent changes; while loading + // (or after a failed fetch) the picker stays disabled (null). + const [agentThinkingLevel, setAgentThinkingLevel] = useState(null); useEffect(() => { - setThinkingLevel(null); + setAgentThinkingLevel(null); if (!agentId) return; let cancelled = false; api .getAgentConfig(projectId, agentId) .then((res) => { - if (!cancelled) setThinkingLevel(res.config.model?.thinkingLevel ?? ""); + if (!cancelled) setAgentThinkingLevel(res.config.model?.thinkingLevel ?? ""); }) .catch(() => undefined); return () => { cancelled = true; }; }, [projectId, agentId]); + const thinkingLevel = + agentThinkingLevel === null || chatDefaults === null + ? null + : effectiveThinkingLevel(agentThinkingLevel, chatDefaults.thinkingLevel); /** Live mirror for the rollback value (a stale closure would roll back to an outdated level). */ const thinkingRef = useRef(null); - thinkingRef.current = thinkingLevel; + thinkingRef.current = agentThinkingLevel; const onChangeThinkingLevel = useCallback( (level: string) => { // "" (no override) is not persistable through the config API — the picker disables that row. if (!agentId || !level) return; const rollback = thinkingRef.current; - setThinkingLevel(level); // Optimistic: the picker reflects the choice immediately. + setAgentThinkingLevel(level); // Optimistic: the derived display follows immediately. api .putAgentConfig(projectId, agentId, { config: { model: { thinkingLevel: level as AgentModelConfigDto["thinkingLevel"] } }, }) .catch((e: unknown) => { - setThinkingLevel(rollback); + setAgentThinkingLevel(rollback); toastError(apiErrorText(e)); }); }, @@ -362,11 +449,22 @@ export function DraftView({ }, [cancelPendingSave, userId, projectId, modelRef]); const selectAgent = (a: AgentSummary) => { + touchedRef.current.agent = true; // an explicit pick outranks a late-arriving project default setAgentId(a.agentId); // Follow through to the global current Agent: keeps the sidebar memory and stats convention consistent. setCurrentAgentId(a.agentId); }; + /** User edits routed through these two so a late-arriving project default cannot clobber them. */ + const changeWorkspace = useCallback((path: string) => { + touchedRef.current.workspace = true; + setWorkspace(path); + }, []); + const changeApprovalMode = useCallback((mode: ApprovalMode) => { + touchedRef.current.approval = true; + setApprovalMode(mode); + }, []); + // One in-flight guard shared by both send entry points (composer send / example task): a // second submission while one is running would create a second Session with // its own first task and a racing navigation. The ref is the synchronous guard; the state @@ -500,7 +598,7 @@ export function DraftView({ contextNow={0} vision={vision} approvalMode={approvalMode} - onChangeApprovalMode={setApprovalMode} + onChangeApprovalMode={changeApprovalMode} modeSaving={false} autoFocus agents={agents} @@ -515,7 +613,7 @@ export function DraftView({ {/* Ownership selection right below the card (small pill dropdowns, styled after ChatGPT's project picker button) */}
- +
{/* Example tasks: one-click canned builds showing off the one-sentence → app flow. @@ -668,12 +766,6 @@ function VersionLine() { ); } -/** Shared style for pill trigger buttons (ChatGPT project button style: small rounded pill + icon + short name + collapse arrow). */ -const pillClass = - "flex max-w-64 items-center gap-1.5 rounded-full border border-gray-300 bg-white py-1 pl-1.5 pr-2 " + - "text-xs text-gray-600 transition-colors duration-150 hover:bg-gray-50 hover:text-gray-900 " + - "dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-gray-100"; - /** Agent selection (pill dropdown): avatar + name, menu opens downward with an internal scroll cap. */ function AgentSelect({ agents, @@ -762,258 +854,3 @@ function AgentSelect({ ); } - -/** - * Workspace selection (pill dropdown): the button shows the selected directory name (empty = - * auto temporary directory). The menu browses server-side directories: **the current path can be - * edited directly** at the top (Enter/blur commits it, an invalid directory toasts and reverts - * to the previous path), the list omits hidden directories, and the hint text sits at the bottom - * of the menu; only loads on first expand. On narrow screens the menu docks to whichever side - * of the pill keeps it inside the viewport (measured on open — see menuDock). - */ -function WorkspaceSelect({ - projectId, - workspace, - onChange, -}: { - projectId: string; - workspace: string; - onChange: (path: string) => void; -}) { - const [open, setOpen] = useState(false); - /** - * Menu docking, measured on each open: the pill follows the agent pill in a wrapping row, so - * its left offset varies with the agent's name — a statically left-anchored 20rem panel can - * cross the viewport's right edge on phones (measured ~143px past a 390px viewport). Keep the - * desktop left anchoring whenever the panel fits; otherwise dock to whichever side of the - * pill has more room, capping the width to that room via menuStyle. On desktop the panel - * always fits, so nothing changes there. - */ - const [menuDock, setMenuDock] = useState<{ right: boolean; maxWidth?: number }>({ - right: false, - }); - const browsedRef = useRef(false); - - const [dir, setDir] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - /** Edit draft for the path row: synced with the browsing position, reverts on a failed commit. */ - const [pathDraft, setPathDraft] = useState(""); - - useEffect(() => { - setPathDraft(dir?.path ?? ""); - }, [dir]); - - /** Browses level by level (clicking a directory/parent); an empty string means the server's home directory (the default starting point). */ - const loadDir = useCallback( - (abs: string) => { - setLoading(true); - setError(null); - api - .listDirs(projectId, abs) - .then(setDir) - .catch((e: unknown) => setError(apiErrorText(e))) - .finally(() => setLoading(false)); - }, - [projectId], - ); - - const toggle = (e: ReactMouseEvent) => { - const next = !open; - if (next) { - const r = e.currentTarget.getBoundingClientRect(); - const rem = parseFloat(getComputedStyle(document.documentElement).fontSize); - const margin = 12; // breathing room against the viewport edge - // The panel's effective width: w-80 capped by its max-w-[calc(100vw-2rem)] class - // (rem-derived — the root font size is not 16px here). - const width = Math.min(20 * rem, window.innerWidth - 2 * rem); - const roomRight = window.innerWidth - margin - r.left; // room for a left-anchored panel - const roomLeft = r.right - margin; // room for a right-anchored panel - if (roomRight >= width) setMenuDock({ right: false }); - else if (roomLeft > roomRight) - setMenuDock({ right: true, ...(roomLeft < width ? { maxWidth: roomLeft } : {}) }); - else setMenuDock({ right: false, maxWidth: roomRight }); - } - setOpen(next); - // Only loads on first expand: an already-filled absolute path is used as the starting point, otherwise the server falls back to the home directory. - if (next && !browsedRef.current) { - browsedRef.current = true; - const ws = workspace.trim(); - loadDir(ws.startsWith("/") ? ws : ""); - } - }; - - /** Commits the edited path: navigates to it if it exists, otherwise toasts and reverts to the current browsing position. */ - const commitPathEdit = async () => { - const p = pathDraft.trim(); - if (!p || p === dir?.path) { - setPathDraft(dir?.path ?? ""); - return; - } - try { - setDir(await api.listDirs(projectId, p)); - } catch { - toastError(S.chat.workspaceDirInvalid); - setPathDraft(dir?.path ?? ""); - } - }; - - const trimmed = workspace.trim(); - // Pill short name: the last segment of the directory name (root gives "/"); shows "auto temp directory" when empty. - const label = trimmed ? (trimmed.split("/").filter(Boolean).pop() ?? "/") : S.chat.workspaceAuto; - const parentPath = dir?.parent ?? null; - // Hidden directories (starting with .) are excluded from the list. - const entries = (dir?.entries ?? []).filter((e) => !e.name.startsWith(".")); - return ( - - {/* Folder icon */} - - - - {label} - - - } - > -
-
- {/* Current path (editable: Enter/blur commits, Escape discards) + "Use this directory" (closes the menu once selected) */} -
- setPathDraft(e.target.value)} - onBlur={() => void commitPathEdit()} - onKeyDown={(e) => { - if (e.key === "Enter" && !e.nativeEvent.isComposing) { - e.preventDefault(); - void commitPathEdit(); - } else if (e.key === "Escape") { - // Discard the edit: only reverts the draft; Escape bubbles up to Dropdown, which closes the menu. - setPathDraft(dir?.path ?? ""); - } - }} - className="min-w-0 flex-1 rounded border border-transparent bg-transparent px-1 py-0.5 font-mono text-xs text-gray-600 focus:border-gray-300 focus:outline-none dark:text-gray-300 dark:focus:border-gray-600" - /> - -
- {/* Directory list (excludes hidden directories) */} -
    - {parentPath !== null && ( -
  • - -
  • - )} - {entries.map((entry) => ( -
  • - -
  • - ))} - {dir && entries.length === 0 && ( -
  • {S.chat.workspaceNoSubdirs}
  • - )} - {loading &&
  • {S.common.loading}
  • } - {/* Load failure (e.g. the cached starting directory was deleted): provide "retry" to fall back to the home directory, avoiding getting stuck in an error state. */} - {error && ( -
  • - - {error} - - -
  • - )} -
-
- {/* When a directory has been specified, offer a one-click way back to the auto temp directory */} - {trimmed && ( - - )} - {/* Hint text (bottom of the menu) */} -

- {S.chat.workspaceHint} -

-
-
- ); -} diff --git a/packages/web/src/features/chat/model-select.tsx b/packages/web/src/features/chat/model-select.tsx new file mode 100644 index 0000000..1f86d2c --- /dev/null +++ b/packages/web/src/features/chat/model-select.tsx @@ -0,0 +1,362 @@ +/** + * Model picker pieces, extracted from chat-input.tsx so the Project settings dialog's + * "new chat defaults" section can offer the same menu the chat composer uses (the panels + * are mechanical moves; the composer's trigger is unchanged, and a "form" trigger variant + * is added for dialog hosts — see ModelSelect): + * - PickerList: the generic candidate panel (search box, scroll cap, keyboard navigation, + * current-entry marker) shared with chat-input's `/agent` handoff picker; + * - ModelMenuList: the model candidate panel (grouped, key-configured-first, "show all" + * expander) shared by the draft dropdown and the in-session `/model` switch picker; + * - ModelSelect: the dropdown trigger (provider logo + name + chevron), pill or form style. + */ +import { useState } from "react"; +import type { KeyboardEvent, ReactNode } from "react"; +import type { ModelInfo, ModelRefDto } from "@prismshadow/penguin-server/api"; +import { S } from "../../lib/strings"; +import { Badge } from "../../components/ui/badge"; +import { Dropdown } from "../../components/ui/dropdown"; +import { controlBase } from "../../components/ui/field"; +import { GlyphIcon } from "../../components/ui/glyph-icon"; +import { ChevronDown } from "../../components/ui/icons"; +import { noAutofill, sizeClass } from "../../components/ui/input"; +import { ProviderLogo } from "../../components/ui/provider-logo"; +import { + hasConfiguredKey, + isFreeModel, + sameModelRef, + visibleChatModels, +} from "../models/model-grouping"; + +/** Display label for a model: the display name, or falls back to the upstream id (model_id is the raw field, no prefix parsing). */ +export function modelLabel(m: ModelInfo): string { + return m.displayName ?? m.modelId; +} + +/** + * "No key" marker for the model dropdown's key-less rows: a key struck through by a prohibition + * slash (24x24 line art, grayscale via currentColor, matching the approval-mode icon style). + */ +const NO_KEY_ICON = + "M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4M2 2l20 20"; + +/** + * Candidate panel shared by every picker of this family (the model dropdown / `/model` switch + * picker and the `/agent` handoff picker): the search box, the internal scroll cap, the row + * chrome, the keyboard navigation and the "current entry" marker slot all live here, so the + * two pickers can differ only in what a row *contains* (provider logo vs Agent avatar) and in + * what they hang below the list (`footer`, e.g. the model list's "show all" expander). + * + * Keyboard navigation deliberately starts with **no** row highlighted: the search box is + * autofocused, and pre-highlighting a row would repaint a panel that has looked the same since + * before this control existed. ArrowDown/ArrowUp begin the navigation, and Enter/Tab commits — + * the highlighted row if there is one, otherwise the top match, which is what makes "type a few + * letters, press Enter" work. Escape is NOT handled here: each host closes its own panel at the + * window level (an IME-safe handler for the switch pickers, Dropdown's for the model dropdown). + */ +export function PickerList({ + items, + itemKey, + isCurrent, + query, + onQueryChange, + searchPlaceholder, + emptyText, + onPick, + renderRow, + footer, +}: { + items: T[]; + /** Stable React key AND identity for the highlighted row. */ + itemKey: (item: T) => string; + /** Marks the entry already in effect (the session's model / its Agent): renders the ✓ slot and the emphasized row style. */ + isCurrent?: (item: T) => boolean; + query: string; + onQueryChange: (query: string) => void; + searchPlaceholder: string; + /** Shown in place of the list when the query matches nothing. */ + emptyText: string; + onPick: (item: T) => void; + /** The row's own content, left of the ✓ slot. */ + renderRow: (item: T) => ReactNode; + /** Pinned below the scroll area (mirroring the search box above it). */ + footer?: ReactNode; +}) { + // -1 = nothing highlighted yet (see the note above); reset whenever the candidate set changes. + const [active, setActive] = useState(-1); + const activeKey = active >= 0 && active < items.length ? itemKey(items[active]!) : null; + const onKeyDown = (e: KeyboardEvent) => { + if (items.length === 0) return; + if (e.key === "ArrowDown") { + e.preventDefault(); + setActive((i) => (i + 1) % items.length); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + setActive((i) => (i <= 0 ? items.length - 1 : i - 1)); + return; + } + // Same guard as the composer's own Enter handling: an IME commit must not be read as a pick. + if (((e.key === "Enter" && !e.shiftKey) || e.key === "Tab") && !e.nativeEvent.isComposing) { + e.preventDefault(); + onPick(items[active >= 0 ? active : 0]!); + } + }; + return ( +
+ {/* Quick search (autofocused: it also owns the keyboard while the panel is up) */} +
+ { + onQueryChange(e.target.value); + setActive(-1); + }} + placeholder={searchPlaceholder} + aria-label={searchPlaceholder} + {...noAutofill} + className="w-full rounded border border-transparent bg-transparent px-1 py-0.5 text-xs text-gray-700 placeholder:text-gray-400 focus:outline-none dark:text-gray-200 dark:placeholder:text-gray-500" + /> +
+
+ {items.length === 0 &&

{emptyText}

} + {items.map((item) => { + const key = itemKey(item); + const current = isCurrent?.(item) ?? false; + return ( + + ); + })} +
+ {footer} +
+ ); +} + +/** + * Model candidate panel (search box + grouped list + "show all" expander) shared by the + * draft-state ModelSelect dropdown and the in-session `/model` switch picker. Search and + * expanded state are internal and reset by remount (both hosts only render the panel while + * open); the list is capped by an internal scroll (max-h-56) so it never overflows the + * viewport no matter how many models there are. + * Dropdown order mirrors the model library page (visibleChatModels): a top quick-search box + * (the model page's rule — filters by id / display name / provider name); by default only + * models with a configured API key are listed (stored masked key — the same standard as the + * model page's key status; `envKey` is merely the NAME of a fallback env var and doesn't + * count), with the selected and the default model always visible even without a key; a muted + * bottom row reveals the remaining key-less models (marked by a struck-through key icon, with + * the "no key" text in its title) without closing the menu or changing the selection — when + * no model has a key at all, everything is listed directly. Rows carry the provider logo, the + * light-yellow "Free" badge for zero-cost models (same as the model library card), the + * project-default marker, and the selected checkmark. + */ +export function ModelMenuList({ + models, + value, + defaultModel, + onPick, +}: { + models: ModelInfo[]; + /** Currently selected (provider, modelId) pair; null = not yet chosen. */ + value: ModelRefDto | null; + defaultModel?: ModelRefDto; + onPick: (m: ModelInfo) => void; +}) { + const [query, setQuery] = useState(""); + // Expanded "show all" state: collapses back to key-configured models on each open (remount). + const [showAll, setShowAll] = useState(false); + const visible = visibleChatModels(models, { showAll, query, selected: value, defaultModel }); + // How many models the key filter hides under the current query (0 when expanded): drives the bottom "show all" row. + const hiddenCount = showAll + ? 0 + : visibleChatModels(models, { showAll: true, query, selected: value, defaultModel }).length - + visible.length; + return ( + `${m.provider}:${m.modelId}`} + isCurrent={(m) => sameModelRef(m, value)} + query={query} + onQueryChange={setQuery} + // Quick search: supports model id / display name / provider name + searchPlaceholder={S.models.searchPlaceholder} + emptyText={S.models.noSearchResults} + onPick={onPick} + renderRow={(m) => ( + <> + + {modelLabel(m)} + {/* Zero-cost rows (all three price buckets 0): same light-yellow "Free" badge as + the model library card, so free models stand out while picking. */} + {isFreeModel(m.pricing) && ( + + {S.models.freeBadge} + + )} + {/* Key-less rows (visible via show-all / selected / default / no-key-at-all) carry a + struck-through key icon (the "no key" text lives in the title/aria-label). */} + {!hasConfiguredKey(m) && ( + + + + )} + {sameModelRef(m, defaultModel) && ( + + {S.models.default} + + )} + + )} + // Bottom expander row (pinned below the scroll area, mirroring the search box on top): + // reveals the models hidden by the configured-key filter in place — the menu stays open + // and the selection is untouched. + {...(hiddenCount > 0 + ? { + footer: ( +
+ +
+ ), + } + : {})} + /> + ); +} + +/** + * Model selector (the chat composer's bottom-toolbar dropdown, also hosted by the Project + * settings' new-chat-defaults section): the button shows the provider logo + name, and the + * menu opens **downward** — the draft card is vertically centered with room below. The + * candidate list itself is the shared ModelMenuList panel (search, key-configured-first + * grouping, Free badge, "show all" expander — documented there). + * + * Two trigger variants, one menu: + * - "pill" (default): the composer's compact toolbar button — collapses to the logo alone + * under the card's own `@container` query, menu right-aligned; + * - "form": a full-width trigger styled like the dialog's Input/Select (controlBase + the + * sm size tier), label always visible (a container-less host's `@md:` variant would never + * match), menu left-aligned under the control. + */ +export function ModelSelect({ + models, + value, + defaultModel, + onChange, + disabled, + variant = "pill", +}: { + models: ModelInfo[]; + /** Currently selected (provider, modelId) pair; null = not yet chosen. */ + value: ModelRefDto | null; + defaultModel?: ModelRefDto; + onChange: (ref: ModelRefDto) => void; + disabled: boolean; + /** Trigger style: the composer's toolbar pill (default), or a dialog form control (see the header comment). */ + variant?: "pill" | "form"; +}) { + const [open, setOpen] = useState(false); + const current = models.find((m) => sameModelRef(m, value)); + // Display rule matches the model page's card: display name, or falls back to the upstream id (grouping is already conveyed by the provider logo). + const label = current ? modelLabel(current) : (value?.modelId ?? "…"); + const logo = ( + + ); + return ( + setOpen(!open)} + className={`flex w-full items-center gap-2 text-left ${controlBase} ${sizeClass.sm} disabled:cursor-not-allowed disabled:opacity-60`} + > + {logo} + {label} + + + ) : ( + + ) + } + > + { + onChange({ provider: m.provider, modelId: m.modelId }); + setOpen(false); + }} + /> + + ); +} diff --git a/packages/web/src/features/chat/thinking-level.ts b/packages/web/src/features/chat/thinking-level.ts index fed079d..d60798b 100644 --- a/packages/web/src/features/chat/thinking-level.ts +++ b/packages/web/src/features/chat/thinking-level.ts @@ -29,6 +29,23 @@ export const THINKING_LEVELS = ["none", "low", "medium", "high", "xhigh"] as con */ export const SELECTABLE_THINKING_LEVELS = ["low", "medium", "high", "xhigh"] as const; +/** + * Effective thinking level for the draft picker's DISPLAY — the SAME chain core resolves + * when a Session is created (core/src/agent.ts `configuredThinkingLevel`, the single rule): + * the Agent's explicit `model.thinking_level` > the Project's `default_chat.thinking_level` + * > the built-in "medium". Keep the two sites in sync. `agentLevel` is the raw agent-config + * value ("" = no explicit override); `projectDefault` is `default_chat.thinking_level` + * (undefined when the Project sets none). The picker shows this effective value; picking + * still writes through to the AGENT config only — the project default is a fallback, never + * overwritten by the picker. + */ +export function effectiveThinkingLevel( + agentLevel: string, + projectDefault: string | undefined, +): string { + return agentLevel || (projectDefault ?? "medium"); +} + /** * Short display label for a level from the localized name table (S.chat.thinkingLevelNames). * Covers all five stored levels including "none" (so a legacy value displays sanely instead diff --git a/packages/web/src/features/chat/workspace-select.tsx b/packages/web/src/features/chat/workspace-select.tsx new file mode 100644 index 0000000..d978936 --- /dev/null +++ b/packages/web/src/features/chat/workspace-select.tsx @@ -0,0 +1,316 @@ +/** + * Workspace picker, extracted from draft-view.tsx so the Project settings dialog's + * "new chat defaults" section can offer the same dir-browser popover the chat draft uses. + * Two trigger variants, one menu: + * - "pill" (default): the draft page's pill trigger with viewport-docked in-flow menu — + * moved verbatim, unchanged markup/classes/behavior; + * - "form": a full-width trigger styled like the dialog's Input/Select (controlBase + + * the sm size tier), with the SAME menu portaled to body — a dialog's overflow-y-auto + * content area would clip an in-flow panel, and the portal tier (z-[60]) clears the + * Modal overlay. + */ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { MouseEvent as ReactMouseEvent } from "react"; +import type { DirListResponse } from "@prismshadow/penguin-server/api"; +import * as api from "../../api/endpoints"; +import { S } from "../../lib/strings"; +import { apiErrorText } from "../../lib/api-error"; +import { Chevron } from "../../components/ui/chevron"; +import { Dropdown } from "../../components/ui/dropdown"; +import { controlBase } from "../../components/ui/field"; +import { ChevronDown } from "../../components/ui/icons"; +import { noAutofill, sizeClass } from "../../components/ui/input"; +import { toastError } from "../../components/ui/toast"; + +/** Shared style for pill trigger buttons (ChatGPT project button style: small rounded pill + icon + short name + collapse arrow). */ +export const pillClass = + "flex max-w-64 items-center gap-1.5 rounded-full border border-gray-300 bg-white py-1 pl-1.5 pr-2 " + + "text-xs text-gray-600 transition-colors duration-150 hover:bg-gray-50 hover:text-gray-900 " + + "dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-gray-100"; + +/** + * Workspace selection (pill dropdown): the button shows the selected directory name (empty = + * auto temporary directory). The menu browses server-side directories: **the current path can be + * edited directly** at the top (Enter/blur commits it, an invalid directory toasts and reverts + * to the previous path), the list omits hidden directories, and the hint text sits at the bottom + * of the menu; only loads on first expand. On narrow screens the menu docks to whichever side + * of the pill keeps it inside the viewport (measured on open — see menuDock). + */ +export function WorkspaceSelect({ + projectId, + workspace, + onChange, + variant = "pill", +}: { + projectId: string; + workspace: string; + onChange: (path: string) => void; + /** Trigger style: the draft page's pill (default), or a dialog form control (see the header comment). */ + variant?: "pill" | "form"; +}) { + const [open, setOpen] = useState(false); + /** + * Menu docking, measured on each open: the pill follows the agent pill in a wrapping row, so + * its left offset varies with the agent's name — a statically left-anchored 20rem panel can + * cross the viewport's right edge on phones (measured ~143px past a 390px viewport). Keep the + * desktop left anchoring whenever the panel fits; otherwise dock to whichever side of the + * pill has more room, capping the width to that room via menuStyle. On desktop the panel + * always fits, so nothing changes there. + */ + const [menuDock, setMenuDock] = useState<{ right: boolean; maxWidth?: number }>({ + right: false, + }); + const browsedRef = useRef(false); + + const [dir, setDir] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + /** Edit draft for the path row: synced with the browsing position, reverts on a failed commit. */ + const [pathDraft, setPathDraft] = useState(""); + + useEffect(() => { + setPathDraft(dir?.path ?? ""); + }, [dir]); + + /** Browses level by level (clicking a directory/parent); an empty string means the server's home directory (the default starting point). */ + const loadDir = useCallback( + (abs: string) => { + setLoading(true); + setError(null); + api + .listDirs(projectId, abs) + .then(setDir) + .catch((e: unknown) => setError(apiErrorText(e))) + .finally(() => setLoading(false)); + }, + [projectId], + ); + + const toggle = (e: ReactMouseEvent) => { + const next = !open; + // The dock measurement steers the in-flow (pill) panel only; the form variant portals, + // and Dropdown's own viewport clamping takes over. + if (next && variant === "pill") { + const r = e.currentTarget.getBoundingClientRect(); + const rem = parseFloat(getComputedStyle(document.documentElement).fontSize); + const margin = 12; // breathing room against the viewport edge + // The panel's effective width: w-80 capped by its max-w-[calc(100vw-2rem)] class + // (rem-derived — the root font size is not 16px here). + const width = Math.min(20 * rem, window.innerWidth - 2 * rem); + const roomRight = window.innerWidth - margin - r.left; // room for a left-anchored panel + const roomLeft = r.right - margin; // room for a right-anchored panel + if (roomRight >= width) setMenuDock({ right: false }); + else if (roomLeft > roomRight) + setMenuDock({ right: true, ...(roomLeft < width ? { maxWidth: roomLeft } : {}) }); + else setMenuDock({ right: false, maxWidth: roomRight }); + } + setOpen(next); + // Only loads on first expand: an already-filled absolute path is used as the starting point, otherwise the server falls back to the home directory. + if (next && !browsedRef.current) { + browsedRef.current = true; + const ws = workspace.trim(); + loadDir(ws.startsWith("/") ? ws : ""); + } + }; + + /** Commits the edited path: navigates to it if it exists, otherwise toasts and reverts to the current browsing position. */ + const commitPathEdit = async () => { + const p = pathDraft.trim(); + if (!p || p === dir?.path) { + setPathDraft(dir?.path ?? ""); + return; + } + try { + setDir(await api.listDirs(projectId, p)); + } catch { + toastError(S.chat.workspaceDirInvalid); + setPathDraft(dir?.path ?? ""); + } + }; + + const trimmed = workspace.trim(); + // Pill short name: the last segment of the directory name (root gives "/"); shows "auto temp directory" when empty. + const label = trimmed ? (trimmed.split("/").filter(Boolean).pop() ?? "/") : S.chat.workspaceAuto; + const parentPath = dir?.parent ?? null; + // Hidden directories (starting with .) are excluded from the list. + const entries = (dir?.entries ?? []).filter((e) => !e.name.startsWith(".")); + /** Folder glyph shared by both triggers. */ + const folderIcon = (extraClass: string) => ( + + + + ); + return ( + + {folderIcon("")} + {label} + + + ) : ( + + ) + } + > +
+
+ {/* Current path (editable: Enter/blur commits, Escape discards) + "Use this directory" (closes the menu once selected) */} +
+ setPathDraft(e.target.value)} + onBlur={() => void commitPathEdit()} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.nativeEvent.isComposing) { + e.preventDefault(); + void commitPathEdit(); + } else if (e.key === "Escape") { + // Discard the edit: only reverts the draft; Escape bubbles up to Dropdown, which closes the menu. + setPathDraft(dir?.path ?? ""); + } + }} + className="min-w-0 flex-1 rounded border border-transparent bg-transparent px-1 py-0.5 font-mono text-xs text-gray-600 focus:border-gray-300 focus:outline-none dark:text-gray-300 dark:focus:border-gray-600" + /> + +
+ {/* Directory list (excludes hidden directories) */} +
    + {parentPath !== null && ( +
  • + +
  • + )} + {entries.map((entry) => ( +
  • + +
  • + ))} + {dir && entries.length === 0 && ( +
  • {S.chat.workspaceNoSubdirs}
  • + )} + {loading &&
  • {S.common.loading}
  • } + {/* Load failure (e.g. the cached starting directory was deleted): provide "retry" to fall back to the home directory, avoiding getting stuck in an error state. */} + {error && ( +
  • + + {error} + + +
  • + )} +
+
+ {/* When a directory has been specified, offer a one-click way back to the auto temp directory */} + {trimmed && ( + + )} + {/* Hint text (bottom of the menu) */} +

+ {S.chat.workspaceHint} +

+
+
+ ); +} diff --git a/packages/web/src/features/models/models-page.tsx b/packages/web/src/features/models/models-page.tsx index dc7b72a..b103d5f 100644 --- a/packages/web/src/features/models/models-page.tsx +++ b/packages/web/src/features/models/models-page.tsx @@ -71,7 +71,7 @@ import { } from "@prismshadow/penguin-core/model-catalog"; import type { ModelProviderInfo } from "@prismshadow/penguin-core/model-catalog"; import { groupModelRows, isFreeModel, sameModelRef, userProviderInfo } from "./model-grouping"; -import { draftKey, loadDraft, saveDraft } from "../chat/draft-cache"; +import { clearDraftModelRef } from "../chat/draft-cache"; import { syncRowsWithCatalog } from "./catalog-sync"; import { tpsTone, ttftTone } from "./speed-test"; import type { SpeedResult, SpeedTone } from "./speed-test"; @@ -418,9 +418,7 @@ export function ModelsPage() { // Default model changed: drop the stored draft's model selection so the draft chat // follows the new default (a stored pick would otherwise pin the old model forever). if (userId && res.defaultModel && !sameModelRef(res.defaultModel, defaultModel)) { - const key = draftKey(userId, projectId); - const draft = loadDraft(key); - if (draft.modelRef) saveDraft(key, { ...draft, modelRef: undefined }); + clearDraftModelRef(userId, projectId); } toastSuccess(successText ?? S.common.saved); return true; diff --git a/packages/web/src/lib/strings-en.ts b/packages/web/src/lib/strings-en.ts index 3291afe..8662090 100644 --- a/packages/web/src/lib/strings-en.ts +++ b/packages/web/src/lib/strings-en.ts @@ -179,6 +179,17 @@ export const en: Strings = { members: "Members", addMember: "Add member", removeMember: "Remove", + /** New-chat defaults section (Project settings): prefill for every new chat. */ + chatDefaultsTitle: "New chat defaults", + chatDefaultsHint: + "Prefilled defaults for every new chat: agent, working directory, approval mode, thinking level and default model.", + chatDefaultsAgent: "Agent", + chatDefaultsNotSet: "Not set", + chatDefaultsApprovalNotSet: "Not set (defaults to allow all)", + chatDefaultsThinkingNotSet: "Not set (follow the agent's config)", + chatDefaultsWorkspaceHint: "Empty = auto temp directory", + /** The model default is single-sourced with the Models page (the same default_model); this is just another entry point. */ + chatDefaultsModelHint: "Same default model as the Models page", deleteProject: "Delete Project", deleteConfirm: "Delete this Project? Its directory will be removed recursively and cannot be recovered.", @@ -1118,6 +1129,7 @@ Scenarios: admin_required: "Only an admin can perform this operation.", not_found: "This resource does not exist, or you do not have access.", agent_not_found: "This agent no longer exists.", + unknown_agent: "That agent does not exist in this Project.", agent_exists: "This agent id is already taken.", project_exists: "This Project id is already taken.", user_exists: "This username is already taken.", diff --git a/packages/web/src/lib/strings.ts b/packages/web/src/lib/strings.ts index 68fafce..b23580d 100644 --- a/packages/web/src/lib/strings.ts +++ b/packages/web/src/lib/strings.ts @@ -173,6 +173,16 @@ export const zh = { members: "成员", addMember: "添加成员", removeMember: "移除", + /** 新对话默认值分节(Project 设置):预填每个新建对话的 Agent / 工作目录 / 审批模式 / 思考等级 / 默认模型。 */ + chatDefaultsTitle: "新对话默认值", + chatDefaultsHint: "新建对话时预填的默认值:Agent、工作目录、审批模式、思考等级与默认模型。", + chatDefaultsAgent: "Agent", + chatDefaultsNotSet: "未设置", + chatDefaultsApprovalNotSet: "未设置(默认全部放行)", + chatDefaultsThinkingNotSet: "未设置(跟随智能体配置)", + chatDefaultsWorkspaceHint: "留空表示自动临时目录", + /** 模型默认值与模型页同源(同一个 default_model),此处仅是另一处入口。 */ + chatDefaultsModelHint: "与模型页的默认模型同步", deleteProject: "删除 Project", deleteConfirm: "确认删除该 Project?项目目录将被递归删除,不可恢复。", deleteDefaultForbidden: "default_project 与 CLI 共用,不允许在 Web 端删除", @@ -1093,6 +1103,7 @@ Benchmark: admin_required: "仅管理员可执行此操作。", not_found: "资源不存在,或你没有访问权限。", agent_not_found: "该 Agent 已不存在。", + unknown_agent: "该 Agent 不存在于本 Project。", agent_exists: "该 Agent id 已被占用。", project_exists: "该 Project id 已被占用。", user_exists: "该用户名已被占用。", diff --git a/packages/web/test/draft-cache.test.ts b/packages/web/test/draft-cache.test.ts index f2ea24e..8910446 100644 --- a/packages/web/test/draft-cache.test.ts +++ b/packages/web/test/draft-cache.test.ts @@ -11,6 +11,7 @@ import { describe, expect, it } from "vitest"; import { clearDraft, + clearDraftModelRef, draftKey, loadDraft, parseDraft, @@ -183,6 +184,43 @@ describe("load / save / clear (key isolation, errors silenced)", () => { expect(s.map.size).toBe(0); }); + it("clearDraftModelRef drops only the model pin, keeping everything else (default-model change follow-through)", () => { + // Shared by the models page and the project-settings default-model control: after the + // Project default changes, the draft must follow it instead of pinning the old pick. + const s = memStorage(); + saveDraft( + draftKey("user-a1", "project-a"), + { + text: "keep me", + agentId: "default_agent", + modelRef: { provider: "deepseek", modelId: "deepseek-v4-pro" }, + skills: ["agent-creation"], + }, + s, + ); + clearDraftModelRef("user-a1", "project-a", s); + expect(loadDraft(draftKey("user-a1", "project-a"), s)).toEqual({ + text: "keep me", + agentId: "default_agent", + skills: ["agent-creation"], + }); + // No cached pick (or no draft at all): a no-op, never an errant write. + clearDraftModelRef("user-a1", "project-a", s); + clearDraftModelRef("user-b2", "project-a", s); + expect(s.map.has(draftKey("user-b2", "project-a"))).toBe(false); + // Scoped by user × Project: another user's pin survives. + saveDraft( + draftKey("user-b2", "project-a"), + { modelRef: { provider: "openai", modelId: "gpt-5" } }, + s, + ); + clearDraftModelRef("user-a1", "project-a", s); + expect(loadDraft(draftKey("user-b2", "project-a"), s).modelRef).toEqual({ + provider: "openai", + modelId: "gpt-5", + }); + }); + it("storage throwing (quota/private mode): save does not throw, load yields an empty draft", () => { const broken: DraftStorage = { getItem: () => { diff --git a/packages/web/test/esc-layers.test.ts b/packages/web/test/esc-layers.test.ts new file mode 100644 index 0000000..583a801 --- /dev/null +++ b/packages/web/test/esc-layers.test.ts @@ -0,0 +1,34 @@ +/** + * Escape-layer stack (components/ui/modal.tsx): modals AND Dropdown menus register as + * layers, and Escape only acts on the topmost one. This is what makes one Escape close a + * menu opened inside a dialog while the dialog stays up (the next Escape closes it), and + * keeps nested dialogs closing one at a time. + */ +import { describe, expect, it } from "vitest"; +import { isTopEscLayer, popEscLayer, pushEscLayer } from "../src/components/ui/modal"; + +describe("esc layer stack", () => { + it("only the topmost layer may act; popping restores the one below", () => { + const modal = pushEscLayer(); + expect(isTopEscLayer(modal)).toBe(true); + // A menu opened inside the modal stacks above it: the first Escape belongs to the menu. + const menu = pushEscLayer(); + expect(isTopEscLayer(menu)).toBe(true); + expect(isTopEscLayer(modal)).toBe(false); + popEscLayer(menu); + expect(isTopEscLayer(modal)).toBe(true); + popEscLayer(modal); + expect(isTopEscLayer(modal)).toBe(false); + }); + + it("tolerates out-of-order removal (an outer layer unmounting first)", () => { + const outer = pushEscLayer(); + const inner = pushEscLayer(); + popEscLayer(outer); // e.g. the host dialog unmounts while its menu is still open + expect(isTopEscLayer(inner)).toBe(true); + popEscLayer(inner); + // Double-pop is a no-op, never a throw (effect cleanups can race in tests/strict mode). + popEscLayer(inner); + expect(isTopEscLayer(inner)).toBe(false); + }); +}); diff --git a/packages/web/test/thinking-level.test.ts b/packages/web/test/thinking-level.test.ts index 46bba61..957dd57 100644 --- a/packages/web/test/thinking-level.test.ts +++ b/packages/web/test/thinking-level.test.ts @@ -10,6 +10,7 @@ import { describe, expect, it } from "vitest"; import { SELECTABLE_THINKING_LEVELS, THINKING_LEVELS, + effectiveThinkingLevel, thinkingLevelLabel, thinkingLevelOptionsFor, } from "../src/features/chat/thinking-level"; @@ -55,6 +56,18 @@ describe("thinkingLevelLabel", () => { }); }); +describe("effectiveThinkingLevel (draft picker display — mirrors core agent.ts)", () => { + it("resolves Agent explicit > project default_chat > built-in medium, the same chain core applies", () => { + // Agent's explicit level always wins — including over a set project default. + expect(effectiveThinkingLevel("high", "low")).toBe("high"); + expect(effectiveThinkingLevel("none", "low")).toBe("none"); // a stored legacy "none" is explicit too + // No explicit agent level ("") -> the project default. + expect(effectiveThinkingLevel("", "low")).toBe("low"); + // Neither -> the built-in "medium" (the documented Agent default). + expect(effectiveThinkingLevel("", undefined)).toBe("medium"); + }); +}); + describe("thinkingLevelOptionsFor (agent-settings dropdown assembly)", () => { /** Mirrors the shape of S.agent.thinkingLevelOptions after the none row's removal. */ const OPTIONS: ReadonlyArray = [