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:
+32
-11
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,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]);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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: "该用户名已被占用。",
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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]> = [
|
||||
|
||||
Reference in New Issue
Block a user