feat(project): per-project new-chat defaults (agent, workspace, approval, thinking, model) (#191)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-08-04 22:52:34 +08:00
committed by GitHub
parent 7cc5c30e71
commit 89435bf62a
27 changed files with 1980 additions and 638 deletions
+32 -11
View File
@@ -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,
+92 -6
View File
@@ -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<ThinkingLevelName, "none">;
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<string, unknown>;
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<ProjectConfig> {
const file = projectConfigPath(root, projectId);
@@ -194,10 +268,12 @@ export async function loadProjectConfig(root: string, projectId: string): Promis
const parsed = (parseToml(raw) ?? {}) as Record<string, unknown>;
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, unknown>): 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");
}
/**
+54 -5
View File
@@ -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 {
+72
View File
@@ -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<string, unknown>;
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: [
+43
View File
@@ -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<ThinkingLevelName, "none">;
}
// ---------------------------------------------------------------------------
// Vault environment variables (Agent-level: agent_state/.vault.toml)
// ---------------------------------------------------------------------------
+2
View File
@@ -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<AppEnv> {
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));
@@ -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<AppEnv> {
const app = new Hono<AppEnv>();
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;
}
+21 -1
View File
@@ -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<AppEnv> {
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
+2 -1
View File
@@ -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",
@@ -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<ModelRefDto> {
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<ChatDefaultsDto> {
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<ChatDefaultsDto> {
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,
@@ -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<typeof apiClient>;
let member: ReturnType<typeof apiClient>;
let outsider: ReturnType<typeof apiClient>;
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<typeof apiClient>;
let member: ReturnType<typeof apiClient>;
let outsider: ReturnType<typeof apiClient>;
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);
});
});
+21
View File
@@ -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<ChatDefaultsDto>(`/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<ChatDefaultsDto>(`/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<DefaultModelResponse>(`/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<ModelTestResponse>(`/api/projects/${encodeURIComponent(projectId)}/models/test`, {
@@ -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
)}
</div>
<ChatDefaultsSection projectId={projectId} isOwner={isOwner} />
{isOwner && (
<div className="border-t border-gray-100 pt-3 dark:border-gray-800">
{projectId === "default_project" ? (
@@ -382,3 +404,251 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos
</Modal>
);
}
/**
* "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<ChatDefaultsDto | null>(null);
const [models, setModels] = useState<ModelsResponse | null>(null);
const [loadError, setLoadError] = useState<string | null>(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<ModelRefDto | null>(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 (
<div className="border-t border-gray-100 pt-3 dark:border-gray-800">
<p className="text-xs font-medium text-gray-500">{S.project.chatDefaultsTitle}</p>
<p className="mb-2 mt-0.5 text-xs text-gray-400">{S.project.chatDefaultsHint}</p>
{loadError ? (
<p className="text-xs text-red-600 dark:text-red-400">{loadError}</p>
) : saved === null || models === null ? (
<p className="text-xs text-gray-400">{S.common.loading}</p>
) : 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. */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Select
label={S.project.chatDefaultsAgent}
size="sm"
value={agentId}
onChange={(e) => setAgentId(e.target.value)}
>
<option value="">{S.project.chatDefaultsNotSet}</option>
{agents.map((a) => (
<option key={a.agentId} value={a.agentId}>
{agentDisplayName(a)}
</option>
))}
</Select>
<Select
label={S.chat.approvalMode}
size="sm"
value={approval}
onChange={(e) => setApproval(e.target.value)}
>
<option value="">{S.project.chatDefaultsApprovalNotSet}</option>
{APPROVAL_MODES.map((m) => (
<option key={m} value={m}>
{S.chat.approvalModeNames[m] ?? m}
</option>
))}
</Select>
<Select
label={S.chat.thinkingLevel}
size="sm"
value={thinking}
onChange={(e) => setThinking(e.target.value)}
>
<option value="">{S.project.chatDefaultsThinkingNotSet}</option>
{SELECTABLE_THINKING_LEVELS.map((l) => (
<option key={l} value={l}>
{S.chat.thinkingLevelNames[l] ?? l}
</option>
))}
</Select>
<div>
<FieldLabel>{S.chat.model}</FieldLabel>
{models.models.length > 0 ? (
<>
{/* The composer's model dropdown (provider logo + name + searchable grouped
panel); the default row carries the S.models.default marker. */}
<ModelSelect
models={models.models}
value={modelRef}
defaultModel={models.defaultModel}
onChange={setModelRef}
disabled={busy}
variant="form"
/>
<FieldHint>{S.project.chatDefaultsModelHint}</FieldHint>
</>
) : (
<p className="text-xs text-gray-400">{S.models.empty}</p>
)}
</div>
<div className="sm:col-span-2">
<FieldLabel>{S.chat.workspace}</FieldLabel>
{/* The draft page's dir-browser pill: browse server directories, edit the path
inline, or clear back to the auto temp directory. */}
<WorkspaceSelect
projectId={projectId}
workspace={workspace}
onChange={setWorkspace}
variant="form"
/>
<FieldHint>{S.project.chatDefaultsWorkspaceHint}</FieldHint>
</div>
</div>
<div className="mt-3 flex justify-end">
<Button
size="sm"
disabled={busy || (!blockDirty && !modelDirty)}
onClick={() => void save()}
>
{S.common.save}
</Button>
</div>
</>
) : (
// Member view: the effective defaults, read-only (same fields, plain text).
<div className="space-y-1 text-sm">
{(
[
[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]) => (
<div key={label} className="flex items-baseline gap-2">
<span className="w-24 shrink-0 text-xs text-gray-500">{label}</span>
<span className="min-w-0 flex-1 break-all">{value}</span>
</div>
))}
</div>
)}
</div>
);
}
+32 -8
View File
@@ -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"}`;
+30 -12
View File
@@ -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]);
+2 -309
View File
@@ -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<T>({
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<HTMLDivElement>) => {
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 (
<div className="contents" onKeyDown={onKeyDown}>
{/* Quick search (autofocused: it also owns the keyboard while the panel is up) */}
<div className="border-b border-gray-100 px-2 pb-1.5 pt-0.5 dark:border-gray-800">
<input
autoFocus
value={query}
onChange={(e) => {
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"
/>
</div>
<div className="max-h-56 overflow-y-auto">
{items.length === 0 && <p className="px-3 py-1.5 text-xs text-gray-400">{emptyText}</p>}
{items.map((item) => {
const key = itemKey(item);
const current = isCurrent?.(item) ?? false;
return (
<button
key={key}
type="button"
ref={key === activeKey ? (el) => el?.scrollIntoView({ block: "nearest" }) : undefined}
onClick={() => onPick(item)}
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors duration-150 hover:bg-gray-100 dark:hover:bg-gray-800 ${
current
? "font-medium text-gray-900 dark:text-gray-100"
: "text-gray-600 dark:text-gray-400"
}${key === activeKey ? " bg-gray-100 dark:bg-gray-800" : ""}`}
>
{renderRow(item)}
<span className="w-3 shrink-0 text-center text-xs">{current ? "✓" : ""}</span>
</button>
);
})}
</div>
{footer}
</div>
);
}
/**
* 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 (
<PickerList
items={visible}
itemKey={(m) => `${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) => (
<>
<ProviderLogo provider={m.provider} className="h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1 truncate">{modelLabel(m)}</span>
{/* 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) && (
<span className="shrink-0">
<Badge tone="yellow">{S.models.freeBadge}</Badge>
</span>
)}
{/* 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) && (
<span
role="img"
title={S.models.noKey}
aria-label={S.models.noKey}
className="shrink-0 text-gray-400 dark:text-gray-500"
>
<GlyphIcon d={NO_KEY_ICON} size={13} />
</span>
)}
{sameModelRef(m, defaultModel) && (
<span className="shrink-0 text-xs text-gray-400 dark:text-gray-500">
{S.models.default}
</span>
)}
</>
)}
// 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: (
<div className="border-t border-gray-100 dark:border-gray-800">
<button
type="button"
onClick={() => setShowAll(true)}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-gray-400 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-600 dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-gray-300"
>
{S.models.showModelsWithoutKey(hiddenCount)}
</button>
</div>
),
}
: {})}
/>
);
}
/**
* 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 (
<Dropdown
open={open}
setOpen={setOpen}
// The panel's right edge docks to the button; portal placement then clamps both edges
// inside the viewport, so a w-max panel can no longer run off-screen on phones (it used
// to need a hand-tuned width clamp reserving the anchor offset).
menuClass="w-max min-w-56 origin-top-right"
portal={{ direction: "down", align: "right" }}
button={
<button
type="button"
title={`${S.chat.chooseModel}:${label}`}
aria-label={S.chat.chooseModel}
disabled={disabled || models.length === 0}
onClick={() => 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"
>
<ProviderLogo
provider={current?.provider ?? value?.provider ?? "custom"}
className="h-4 w-4 shrink-0"
/>
{/* When the card is narrower than @md, only the provider logo remains (title shows the full name). */}
<span className="hidden min-w-0 truncate @md:block">{label}</span>
<svg
width="10"
height="10"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
className="shrink-0"
aria-hidden
>
<path
d="M3 4.5l3 3 3-3"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
}
>
<ModelMenuList
models={models}
value={value}
{...(defaultModel !== undefined ? { defaultModel } : {})}
onPick={(m) => {
onChange({ provider: m.provider, modelId: m.modelId });
setOpen(false);
}}
/>
</Dropdown>
);
}
/**
* 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
@@ -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);
}
+116 -279
View File
@@ -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<string[]>(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<ChatDefaultsDto | null>(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<string | null>(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<string | null>(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<string | null>(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<string | null>(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) */}
<div className="mt-2 flex flex-wrap items-center gap-2">
<AgentSelect agents={agents} selected={selectedAgent} onSelect={selectAgent} />
<WorkspaceSelect projectId={projectId} workspace={workspace} onChange={setWorkspace} />
<WorkspaceSelect projectId={projectId} workspace={workspace} onChange={changeWorkspace} />
</div>
{/* 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({
</Dropdown>
);
}
/**
* 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<DirListResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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<HTMLButtonElement>) => {
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 (
<Dropdown
open={open}
setOpen={setOpen}
menuClass={`top-full mt-1 w-80 max-w-[calc(100vw-2rem)] ${
menuDock.right ? "right-0 origin-top-right" : "left-0 origin-top-left"
}`}
{...(menuDock.maxWidth !== undefined ? { menuStyle: { maxWidth: menuDock.maxWidth } } : {})}
button={
<button
type="button"
title={trimmed ? `${S.chat.workspace}:${trimmed}` : S.chat.workspaceHint}
aria-label={S.chat.workspace}
onClick={toggle}
className={pillClass}
>
{/* Folder icon */}
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
className="ml-0.5 shrink-0 text-gray-400"
aria-hidden
>
<path
d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"
strokeWidth="1.6"
strokeLinejoin="round"
/>
</svg>
<span className={`min-w-0 truncate ${trimmed ? "font-mono" : ""}`}>{label}</span>
<Chevron open={open} size={12} className="shrink-0 text-gray-400" />
</button>
}
>
<div className="space-y-1.5 px-2.5 pb-2.5 pt-2">
<div className="rounded-md border border-gray-200 dark:border-gray-800">
{/* Current path (editable: Enter/blur commits, Escape discards) + "Use this directory" (closes the menu once selected) */}
<div className="flex items-center gap-1.5 border-b border-gray-100 px-1.5 py-1 dark:border-gray-800">
<input
value={pathDraft}
placeholder="…"
aria-label={S.chat.workspace}
{...noAutofill}
onChange={(e) => 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"
/>
<button
type="button"
disabled={!dir}
onClick={() => {
if (!dir) return;
onChange(dir.path);
setOpen(false);
}}
className="shrink-0 rounded border border-gray-300 px-1.5 py-0.5 text-xs text-gray-700 transition-colors duration-150 hover:bg-gray-100 disabled:opacity-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
>
{S.chat.workspaceUseThis}
</button>
</div>
{/* Directory list (excludes hidden directories) */}
<ul className="max-h-40 overflow-y-auto py-1">
{parentPath !== null && (
<li>
<button
type="button"
onClick={() => loadDir(parentPath)}
className="flex w-full items-center gap-2 px-2.5 py-1.5 text-left font-mono text-xs text-gray-500 transition-colors duration-150 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
>
↰ {S.chat.workspaceUp}
</button>
</li>
)}
{entries.map((entry) => (
<li key={entry.path}>
<button
type="button"
onClick={() => loadDir(entry.path)}
className="flex w-full items-center gap-2 px-2.5 py-1.5 text-left font-mono text-xs text-gray-700 transition-colors duration-150 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
className="shrink-0 text-gray-400"
aria-hidden
>
<path
d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"
strokeWidth="1.6"
strokeLinejoin="round"
/>
</svg>
<span className="min-w-0 flex-1 truncate">{entry.name}</span>
</button>
</li>
))}
{dir && entries.length === 0 && (
<li className="px-2.5 py-1.5 text-xs text-gray-400">{S.chat.workspaceNoSubdirs}</li>
)}
{loading && <li className="px-2.5 py-1.5 text-xs text-gray-400">{S.common.loading}</li>}
{/* 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 && (
<li className="flex items-center justify-between gap-2 px-2.5 py-1.5 text-xs text-red-500">
<span className="min-w-0 flex-1 truncate" title={error}>
{error}
</span>
<button
type="button"
onClick={() => loadDir("")}
className="shrink-0 rounded border border-gray-300 px-1.5 py-0.5 text-xs text-gray-700 transition-colors duration-150 hover:bg-gray-100 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
>
{S.common.retry}
</button>
</li>
)}
</ul>
</div>
{/* When a directory has been specified, offer a one-click way back to the auto temp directory */}
{trimmed && (
<button
type="button"
onClick={() => {
onChange("");
setOpen(false);
}}
className="text-xs text-gray-500 underline decoration-gray-300 underline-offset-2 transition-colors duration-150 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
>
{S.chat.workspaceClear}
</button>
)}
{/* Hint text (bottom of the menu) */}
<p className="px-0.5 text-xs leading-5 text-gray-400 dark:text-gray-500">
{S.chat.workspaceHint}
</p>
</div>
</Dropdown>
);
}
@@ -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<T>({
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<HTMLDivElement>) => {
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 (
<div className="contents" onKeyDown={onKeyDown}>
{/* Quick search (autofocused: it also owns the keyboard while the panel is up) */}
<div className="border-b border-gray-100 px-2 pb-1.5 pt-0.5 dark:border-gray-800">
<input
autoFocus
value={query}
onChange={(e) => {
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"
/>
</div>
<div className="max-h-56 overflow-y-auto">
{items.length === 0 && <p className="px-3 py-1.5 text-xs text-gray-400">{emptyText}</p>}
{items.map((item) => {
const key = itemKey(item);
const current = isCurrent?.(item) ?? false;
return (
<button
key={key}
type="button"
ref={key === activeKey ? (el) => el?.scrollIntoView({ block: "nearest" }) : undefined}
onClick={() => onPick(item)}
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors duration-150 hover:bg-gray-100 dark:hover:bg-gray-800 ${
current
? "font-medium text-gray-900 dark:text-gray-100"
: "text-gray-600 dark:text-gray-400"
}${key === activeKey ? " bg-gray-100 dark:bg-gray-800" : ""}`}
>
{renderRow(item)}
<span className="w-3 shrink-0 text-center text-xs">{current ? "✓" : ""}</span>
</button>
);
})}
</div>
{footer}
</div>
);
}
/**
* 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 (
<PickerList
items={visible}
itemKey={(m) => `${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) => (
<>
<ProviderLogo provider={m.provider} className="h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1 truncate">{modelLabel(m)}</span>
{/* 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) && (
<span className="shrink-0">
<Badge tone="yellow">{S.models.freeBadge}</Badge>
</span>
)}
{/* 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) && (
<span
role="img"
title={S.models.noKey}
aria-label={S.models.noKey}
className="shrink-0 text-gray-400 dark:text-gray-500"
>
<GlyphIcon d={NO_KEY_ICON} size={13} />
</span>
)}
{sameModelRef(m, defaultModel) && (
<span className="shrink-0 text-xs text-gray-400 dark:text-gray-500">
{S.models.default}
</span>
)}
</>
)}
// 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: (
<div className="border-t border-gray-100 dark:border-gray-800">
<button
type="button"
onClick={() => setShowAll(true)}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-gray-400 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-600 dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-gray-300"
>
{S.models.showModelsWithoutKey(hiddenCount)}
</button>
</div>
),
}
: {})}
/>
);
}
/**
* 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 = (
<ProviderLogo
provider={current?.provider ?? value?.provider ?? "custom"}
className="h-4 w-4 shrink-0"
/>
);
return (
<Dropdown
open={open}
setOpen={setOpen}
// Pill: the panel's right edge docks to the button; portal placement then clamps both
// edges inside the viewport, so a w-max panel can no longer run off-screen on phones
// (it used to need a hand-tuned width clamp reserving the anchor offset). Form: the
// panel hangs under the full-width control's left edge, like the dialog's Select.
menuClass={`w-max min-w-56 ${variant === "form" ? "origin-top-left" : "origin-top-right"}`}
portal={{ direction: "down", align: variant === "form" ? "left" : "right" }}
button={
variant === "form" ? (
// Dialog form control: full width, same border/height/typography as Input/Select (sm tier).
<button
type="button"
title={`${S.chat.chooseModel}:${label}`}
aria-label={S.chat.chooseModel}
aria-haspopup="listbox"
aria-expanded={open}
disabled={disabled || models.length === 0}
onClick={() => setOpen(!open)}
className={`flex w-full items-center gap-2 text-left ${controlBase} ${sizeClass.sm} disabled:cursor-not-allowed disabled:opacity-60`}
>
{logo}
<span className="min-w-0 flex-1 truncate">{label}</span>
<ChevronDown className="text-gray-400" />
</button>
) : (
<button
type="button"
title={`${S.chat.chooseModel}:${label}`}
aria-label={S.chat.chooseModel}
disabled={disabled || models.length === 0}
onClick={() => 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"
>
{logo}
{/* When the card is narrower than @md, only the provider logo remains (title shows the full name). */}
<span className="hidden min-w-0 truncate @md:block">{label}</span>
<svg
width="10"
height="10"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
className="shrink-0"
aria-hidden
>
<path
d="M3 4.5l3 3 3-3"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
)
}
>
<ModelMenuList
models={models}
value={value}
{...(defaultModel !== undefined ? { defaultModel } : {})}
onPick={(m) => {
onChange({ provider: m.provider, modelId: m.modelId });
setOpen(false);
}}
/>
</Dropdown>
);
}
@@ -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
@@ -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<DirListResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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<HTMLButtonElement>) => {
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) => (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
className={`shrink-0 text-gray-400 ${extraClass}`}
aria-hidden
>
<path
d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"
strokeWidth="1.6"
strokeLinejoin="round"
/>
</svg>
);
return (
<Dropdown
open={open}
setOpen={setOpen}
{...(variant === "form"
? // Portaled menu (size classes only; placement is measured from the trigger).
{ menuClass: "w-80", portal: { direction: "down" as const, align: "left" as const } }
: {
menuClass: `top-full mt-1 w-80 max-w-[calc(100vw-2rem)] ${
menuDock.right ? "right-0 origin-top-right" : "left-0 origin-top-left"
}`,
...(menuDock.maxWidth !== undefined
? { menuStyle: { maxWidth: menuDock.maxWidth } }
: {}),
})}
button={
variant === "form" ? (
// Dialog form control: full width, same border/height/typography as Input/Select (sm tier).
<button
type="button"
title={trimmed ? `${S.chat.workspace}:${trimmed}` : S.chat.workspaceHint}
aria-label={S.chat.workspace}
aria-haspopup="dialog"
aria-expanded={open}
onClick={toggle}
className={`flex w-full items-center gap-2 text-left ${controlBase} ${sizeClass.sm}`}
>
{folderIcon("")}
<span className={`min-w-0 flex-1 truncate ${trimmed ? "font-mono" : ""}`}>{label}</span>
<ChevronDown className="text-gray-400" />
</button>
) : (
<button
type="button"
title={trimmed ? `${S.chat.workspace}:${trimmed}` : S.chat.workspaceHint}
aria-label={S.chat.workspace}
onClick={toggle}
className={pillClass}
>
{folderIcon("ml-0.5")}
<span className={`min-w-0 truncate ${trimmed ? "font-mono" : ""}`}>{label}</span>
<Chevron open={open} size={12} className="shrink-0 text-gray-400" />
</button>
)
}
>
<div className="space-y-1.5 px-2.5 pb-2.5 pt-2">
<div className="rounded-md border border-gray-200 dark:border-gray-800">
{/* Current path (editable: Enter/blur commits, Escape discards) + "Use this directory" (closes the menu once selected) */}
<div className="flex items-center gap-1.5 border-b border-gray-100 px-1.5 py-1 dark:border-gray-800">
<input
value={pathDraft}
placeholder="…"
aria-label={S.chat.workspace}
{...noAutofill}
onChange={(e) => 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"
/>
<button
type="button"
disabled={!dir}
onClick={() => {
if (!dir) return;
onChange(dir.path);
setOpen(false);
}}
className="shrink-0 rounded border border-gray-300 px-1.5 py-0.5 text-xs text-gray-700 transition-colors duration-150 hover:bg-gray-100 disabled:opacity-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
>
{S.chat.workspaceUseThis}
</button>
</div>
{/* Directory list (excludes hidden directories) */}
<ul className="max-h-40 overflow-y-auto py-1">
{parentPath !== null && (
<li>
<button
type="button"
onClick={() => loadDir(parentPath)}
className="flex w-full items-center gap-2 px-2.5 py-1.5 text-left font-mono text-xs text-gray-500 transition-colors duration-150 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
>
↰ {S.chat.workspaceUp}
</button>
</li>
)}
{entries.map((entry) => (
<li key={entry.path}>
<button
type="button"
onClick={() => loadDir(entry.path)}
className="flex w-full items-center gap-2 px-2.5 py-1.5 text-left font-mono text-xs text-gray-700 transition-colors duration-150 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
className="shrink-0 text-gray-400"
aria-hidden
>
<path
d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"
strokeWidth="1.6"
strokeLinejoin="round"
/>
</svg>
<span className="min-w-0 flex-1 truncate">{entry.name}</span>
</button>
</li>
))}
{dir && entries.length === 0 && (
<li className="px-2.5 py-1.5 text-xs text-gray-400">{S.chat.workspaceNoSubdirs}</li>
)}
{loading && <li className="px-2.5 py-1.5 text-xs text-gray-400">{S.common.loading}</li>}
{/* 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 && (
<li className="flex items-center justify-between gap-2 px-2.5 py-1.5 text-xs text-red-500">
<span className="min-w-0 flex-1 truncate" title={error}>
{error}
</span>
<button
type="button"
onClick={() => loadDir("")}
className="shrink-0 rounded border border-gray-300 px-1.5 py-0.5 text-xs text-gray-700 transition-colors duration-150 hover:bg-gray-100 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
>
{S.common.retry}
</button>
</li>
)}
</ul>
</div>
{/* When a directory has been specified, offer a one-click way back to the auto temp directory */}
{trimmed && (
<button
type="button"
onClick={() => {
onChange("");
setOpen(false);
}}
className="text-xs text-gray-500 underline decoration-gray-300 underline-offset-2 transition-colors duration-150 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
>
{S.chat.workspaceClear}
</button>
)}
{/* Hint text (bottom of the menu) */}
<p className="px-0.5 text-xs leading-5 text-gray-400 dark:text-gray-500">
{S.chat.workspaceHint}
</p>
</div>
</Dropdown>
);
}
@@ -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;
+12
View File
@@ -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.",
+11
View File
@@ -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: "该用户名已被占用。",
+38
View File
@@ -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: () => {
+34
View File
@@ -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);
});
});
+13
View File
@@ -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<readonly [string, string]> = [