feat(web,core,server,docs,skills): avatar update tooltip, DeepSeek-first model catalog, temporary-workspace wording (#224)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei ZhengandClaude Fable 5 authored and GitHub committed 2026-08-06 20:13:43 +08:00
1 parent 75a8aa12c8
commit e8499befd4
29 files changed
+283 -88

No files matched your search

+3 -3
View File
@@ -181,7 +181,7 @@ export class Agent {
*/ */
async createSession(opts: CreateSessionOptions = {}): Promise<Session> { async createSession(opts: CreateSessionOptions = {}): Promise<Session> {
// Model is validated first (before creating the Workspace, so failure leaves no // Model is validated first (before creating the Workspace, so failure leaves no
// temp directory behind): the reference must be the complete (provider, model_id) // temporary workspace behind): the reference must be the complete (provider, model_id)
// pair — the config's unique key — and must name an entry in the Project config; a // pair — the config's unique key — and must name an entry in the Project config; a
// reference outside the config throws immediately rather than passing silently, // reference outside the config throws immediately rather than passing silently,
// otherwise credentials, pricing, and the context window would all be unavailable. // otherwise credentials, pricing, and the context window would all be unavailable.
@@ -217,7 +217,7 @@ export class Agent {
// An explicit Workspace must already exist as a directory: if it // An explicit Workspace must already exist as a directory: if it
// doesn't, throw rather than auto-create (to avoid a typo silently working in // doesn't, throw rather than auto-create (to avoid a typo silently working in
// the wrong location); a temp Workspace is only created when unspecified. // the wrong location); a temporary workspace is only created when unspecified.
let workspaceDir: string; let workspaceDir: string;
if (opts.workspaceDir) { if (opts.workspaceDir) {
workspaceDir = path.resolve(opts.workspaceDir); workspaceDir = path.resolve(opts.workspaceDir);
@@ -226,7 +226,7 @@ export class Agent {
stat = await fs.stat(workspaceDir); stat = await fs.stat(workspaceDir);
} catch { } catch {
throw new Error( throw new Error(
`Workspace does not exist: ${workspaceDir}. Specify an existing directory, or omit the Workspace to use a temporary directory.`, `Workspace does not exist: ${workspaceDir}. Specify an existing directory, or omit the Workspace to use a temporary workspace.`,
); );
} }
if (!stat.isDirectory()) { if (!stat.isDirectory()) {
@@ -91,7 +91,7 @@ export async function createTempWorkspace(
await fs.mkdir(base, { recursive: true }); await fs.mkdir(base, { recursive: true });
// The final directory must use a non-recursive mkdir: recursive mkdir succeeds // The final directory must use a non-recursive mkdir: recursive mkdir succeeds
// silently when the directory already exists, which would put a new Session into // silently when the directory already exists, which would put a new Session into
// an existing temp Workspace; EEXIST means an id collision, so retry with a new id. // an existing temporary workspace; EEXIST means an id collision, so retry with a new id.
for (let attempt = 0; attempt < MAX_TMP_ID_ATTEMPTS; attempt++) { for (let attempt = 0; attempt < MAX_TMP_ID_ATTEMPTS; attempt++) {
const dir = path.join(base, `tmp-${randomUUID().slice(0, 8)}`); const dir = path.join(base, `tmp-${randomUUID().slice(0, 8)}`);
try { try {
+1 -1
View File
@@ -128,7 +128,7 @@ export const DEFAULT_CHAT_THINKING_LEVELS: readonly DefaultChatThinkingLevel[] =
* New-chat defaults (`[default_chat]`): per-Project prefill for newly created chats. * New-chat defaults (`[default_chat]`): per-Project prefill for newly created chats.
* Every key is optional and independent: * Every key is optional and independent:
* - `agent_id`: the Agent preselected on the draft page (must name an existing Agent); * - `agent_id`: the Agent preselected on the draft page (must name an existing Agent);
* - `workspace`: the prefilled Workspace directory (absent/empty = auto temp directory); * - `workspace`: the prefilled Workspace directory (absent/empty = a temporary workspace);
* - `approval_mode`: the prefilled approval mode (absent = the built-in "allow-all"); * - `approval_mode`: the prefilled approval mode (absent = the built-in "allow-all");
* - `thinking_level`: fallback thinking level for Agents whose config has no explicit * - `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). * `model.thinking_level` (see Agent's thinking-level resolution chain in agent.ts).
+2 -2
View File
@@ -5,7 +5,7 @@
* Regression: an explicitly given Workspace must be an existing directory. When it * Regression: an explicitly given Workspace must be an existing directory. When it
* does not exist, a clear error must be thrown rather than auto-creating it, and bash must not * does not exist, a clear error must be thrown rather than auto-creating it, and bash must not
* be started with an invalid cwd after Session creation, which would throw a misleading * be started with an invalid cwd after Session creation, which would throw a misleading
* `spawn bash ENOENT`. A temp directory is only created when no Workspace is specified. * `spawn bash ENOENT`. A temporary workspace is only created when no Workspace is specified.
* *
* vault: the Agent vault's (agent_state/.vault.toml) **key names** are * vault: the Agent vault's (agent_state/.vault.toml) **key names** are
* injected into the assembled system prompt; values are never injected. * injected into the assembled system prompt; values are never injected.
@@ -131,7 +131,7 @@ describe("Agent.createSession workspace handling", () => {
const ws = path.join(tmpRoot, "ws-bad-model"); const ws = path.join(tmpRoot, "ws-bad-model");
await fs.mkdir(ws, { recursive: true }); await fs.mkdir(ws, { recursive: true });
// A reference outside the config is not silently allowed (the unique key is provider + // A reference outside the config is not silently allowed (the unique key is provider +
// model_id); the error is thrown before creating the temp Workspace. // model_id); the error is thrown before creating the temporary workspace.
await expect( await expect(
agent.createSession({ agent.createSession({
workspaceDir: ws, workspaceDir: ws,
+1 -1
View File
@@ -5,7 +5,7 @@
* `<agent>/workspaces/`. * `<agent>/workspaces/`.
* - The id is checked for collisions within `workspaces/`: on conflict with an existing * - The id is checked for collisions within `workspaces/`: on conflict with an existing
* directory (EEXIST), it regenerates rather than reusing the existing directory; once * directory (EEXIST), it regenerates rather than reusing the existing directory; once
* retries are exhausted, it throws instead of silently falling back to an old temp Workspace. * retries are exhausted, it throws instead of silently falling back to an old temporary workspace.
*/ */
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
+1 -1
View File
@@ -135,7 +135,7 @@ Schedule writes are owner-only. A task in new-Session mode carries `modelId` and
| POST | /agents/:agentId/sessions | Create a Session: `{modelId?, provider?, workspace?, approvalMode?}` → 201 | | POST | /agents/:agentId/sessions | Create a Session: `{modelId?, provider?, workspace?, approvalMode?}` → 201 |
| GET | /dirs?path= | Server-side directory browser (backs the Workspace picker) | | GET | /dirs?path= | Server-side directory browser (backs the Workspace picker) |
On Session creation, `modelId` and `provider` are both-or-neither: send the complete pair to pick a model, or omit both to take the Project's default model — one without the other is a 400. The Workspace defaults to an auto-created temporary directory, and the approval mode defaults to `allow-all`. On Session creation, `modelId` and `provider` are both-or-neither: send the complete pair to pick a model, or omit both to take the Project's default model — one without the other is a 400. The Workspace defaults to an auto-created temporary workspace, and the approval mode defaults to `allow-all`.
### Usage and Traces (Agent Level) ### Usage and Traces (Agent Level)
+1 -1
View File
@@ -135,7 +135,7 @@ Schedule 写操作仅限 Owner。新建 Session 模式的任务,`modelId` 与
| POST | /agents/:agentId/sessions | 创建 Session:`{modelId?, provider?, workspace?, approvalMode?}` → 201 | | POST | /agents/:agentId/sessions | 创建 Session:`{modelId?, provider?, workspace?, approvalMode?}` → 201 |
| GET | /dirs?path= | 服务器端目录浏览(Workspace 选择器数据源) | | GET | /dirs?path= | 服务器端目录浏览(Workspace 选择器数据源) |
创建 Session 时,`modelId` 与 `provider` 要么成对给出、要么都不给:给出完整二元组即指定模型,两个都省略则取 Project 默认模型,只给一个返回 400。Workspace 默认自动创建临时目录,审批模式默认 `allow-all`。 创建 Session 时,`modelId` 与 `provider` 要么成对给出、要么都不给:给出完整二元组即指定模型,两个都省略则取 Project 默认模型,只给一个返回 400。Workspace 默认自动创建临时工作区,审批模式默认 `allow-all`。
### 用量与 Trace(Agent 级) ### 用量与 Trace(Agent 级)
@@ -13,7 +13,7 @@ Six levels: Project → Agent → Workspace → Session → Task → Request.
| --- | --- | | --- | --- |
| Project | Top-level unit organizing Agents; owns the model and credential configuration; in the multi-user Web setup, users and Projects are many-to-many | | Project | Top-level unit organizing Agents; owns the model and credential configuration; in the multi-user Web setup, users and Projects are many-to-many |
| Agent | The executing subject; has exactly one Agent State (a persistent directory); one Agent can serve many Workspaces | | Agent | The executing subject; has exactly one Agent State (a persistent directory); one Agent can serve many Workspaces |
| Workspace | The working directory of one run — the only file scope the model sees; an explicit `workspaceDir` must already exist, otherwise a temp Workspace `workspaces/tmp-<8hex>` is created | | Workspace | The working directory of one run — the only file scope the model sees; an explicit `workspaceDir` must already exist, otherwise a temporary Workspace `workspaces/tmp-<8hex>` is created |
| Session | A continuous conversation under one (Agent, Workspace); model and Workspace are locked at Session creation; ids look like `session-YYYY-MM-DD-HH-mm-ss-<8hex>` | | Session | A continuous conversation under one (Agent, Workspace); model and Workspace are locked at Session creation; ids look like `session-YYYY-MM-DD-HH-mm-ss-<8hex>` |
| Task | One execution goal started by one Prompt; consists of one or more consecutive Requests | | Task | One execution goal started by one Prompt; consists of one or more consecutive Requests |
| Request | One LLM API call: context and tool definitions in, streamed output out | | Request | One LLM API call: context and tool definitions in, streamed output out |
@@ -39,7 +39,7 @@ The data root is the `PENGUIN_HOME` environment variable, defaulting to `~/.peng
│ # convention, not a path the code creates, so tooling is │ # convention, not a path the code creates, so tooling is
│ # installed once for any task; project dependencies stay │ # installed once for any task; project dependencies stay
│ # in the project │ # in the project
├── workspaces/ # temp Workspaces (tmp-<8hex>) ├── workspaces/ # temporary Workspaces (tmp-<8hex>)
├── benchmarks/ # capability Benchmark cases and scores ├── benchmarks/ # capability Benchmark cases and scores
└── snapshots/ # Agent State version snapshots └── snapshots/ # Agent State version snapshots
``` ```
+1 -1
View File
@@ -391,7 +391,7 @@ export interface DefaultModelResponse {
export interface ChatDefaultsDto { export interface ChatDefaultsDto {
/** Preselected Agent; must reference an existing Agent of the Project (400 unknown_agent). */ /** Preselected Agent; must reference an existing Agent of the Project (400 unknown_agent). */
agentId?: string; agentId?: string;
/** Prefilled Workspace directory; absent/empty = auto temp directory. */ /** Prefilled Workspace directory; absent/empty = a temporary workspace. */
workspace?: string; workspace?: string;
/** Prefilled approval mode; absent = the built-in "allow-all". */ /** Prefilled approval mode; absent = the built-in "allow-all". */
approvalMode?: ApprovalMode; approvalMode?: ApprovalMode;
@@ -47,7 +47,7 @@ export function chatDefaultsRoutes(deps: AppDeps): Hono<AppEnv> {
} }
// Not validated as an existing directory on purpose: the default is a prefill, and the // 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). // directory is (re)checked when a Session is actually created. "" = clear (temporary workspace).
const workspace = optionalString(body, "workspace", { maxLen: 4096, label: "workspace" }); const workspace = optionalString(body, "workspace", { maxLen: 4096, label: "workspace" });
if (workspace !== undefined && workspace !== "") req.workspace = workspace; if (workspace !== undefined && workspace !== "") req.workspace = workspace;
+1 -1
View File
@@ -33,7 +33,7 @@ export interface ScheduleDefinition {
endAtMs?: number; endAtMs?: number;
/** The target Session to bind to; defaults to creating a new Session each time. */ /** The target Session to bind to; defaults to creating a new Session each time. */
sessionId?: string; sessionId?: string;
/** Workspace for new-Session mode (same semantics as manually starting a session; auto-creates a temp directory if unspecified). */ /** Workspace for new-Session mode (same semantics as manually starting a session; a temporary workspace is auto-created if unspecified). */
workspace?: string; workspace?: string;
/** Model for new-Session mode (upstream id, always paired with provider; omit both for the Project's default reference). */ /** Model for new-Session mode (upstream id, always paired with provider; omit both for the Project's default reference). */
modelId?: string; modelId?: string;
@@ -8,7 +8,7 @@
* reachability governed by the file permissions of the OS account running the * reachability governed by the file permissions of the OS account running the
* service. * service.
* When no Workspace is specified, this module isn't involved (the SDK creates its * When no Workspace is specified, this module isn't involved (the SDK creates its
* own temporary directory). * own temporary workspace).
*/ */
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import { HttpError } from "../http/errors.js"; import { HttpError } from "../http/errors.js";
@@ -26,7 +26,7 @@ export async function assertWorkspaceAllowed(args: { workspace: string }): Promi
throw new HttpError( throw new HttpError(
400, 400,
"workspace_not_found", "workspace_not_found",
`Workspace does not exist or is inaccessible: ${args.workspace}. Specify an existing directory, or leave it empty to use a temporary directory.`, `Workspace does not exist or is inaccessible: ${args.workspace}. Specify an existing directory, or leave it empty to use a temporary workspace.`,
); );
} }
const stat = await fs.stat(ws); const stat = await fs.stat(ws);
+3 -3
View File
@@ -84,7 +84,7 @@ describe("session-index", () => {
} }
}); });
it("creating a Session: auto temp Workspace by default, allow-all default, shows in the list", async () => { it("creating a Session: temporary Workspace by default, allow-all default, shows in the list", async () => {
await configureModels(); await configureModels();
const res = await api.post(base(), {}); const res = await api.post(base(), {});
expect(res.status).toBe(201); expect(res.status).toBe(201);
@@ -321,8 +321,8 @@ describe("session-index", () => {
expect((await list("")).workspaceCounts).toBeUndefined(); expect((await list("")).workspaceCounts).toBeUndefined();
// The per-Workspace breakdown accompanies the totals and sums back to them: the // The per-Workspace breakdown accompanies the totals and sums back to them: the
// subagent Session sits alone in its path; every other row lives in its own auto // subagent Session sits alone in its path; every other row lives in its own
// temp directory. // temporary workspace.
const byWorkspace = full.workspaceCounts!; const byWorkspace = full.workspaceCounts!;
expect(byWorkspace["/tmp/w-sub"]).toEqual({ expect(byWorkspace["/tmp/w-sub"]).toEqual({
active: 0, active: 0,
+3 -3
View File
@@ -3,8 +3,8 @@ name: penguin-sdk
description: Build AI apps on the Penguin Harness SDK — self-contained projects, the createSession/run streaming loop with thinking and image messages, and a complete RAG recipe that ingests documents into a knowledge base and answers with citations behind a web UI. description: Build AI apps on the Penguin Harness SDK — self-contained projects, the createSession/run streaming loop with thinking and image messages, and a complete RAG recipe that ingests documents into a knowledge base and answers with citations behind a web UI.
short_description: Build AI and RAG apps on the Penguin Harness SDK. short_description: Build AI and RAG apps on the Penguin Harness SDK.
short_description_zh: 基于 Penguin SDK 构建 AI 与 RAG 应用。 short_description_zh: 基于 Penguin SDK 构建 AI 与 RAG 应用。
version: 18 version: 19
updated: 2026-07-30T11:10:00Z updated: 2026-08-06T00:00:00Z
--- ---
# Penguin Harness SDK # Penguin Harness SDK
@@ -97,7 +97,7 @@ rl.close();
session.dispose(); session.dispose();
``` ```
- `createSession({ workspaceDir, provider, modelId })` — `workspaceDir` must already exist (omit for an auto temp dir); the model reference is the `(provider, modelId)` pair, so pass both to pick a configured model or neither for the project default — passing one alone throws. - `createSession({ workspaceDir, provider, modelId })` — `workspaceDir` must already exist (omit for a temporary workspace); the model reference is the `(provider, modelId)` pair, so pass both to pick a configured model or neither for the project default — passing one alone throws.
- The `approve` callback gates every tool call; **omitting it denies everything**. - The `approve` callback gates every tool call; **omitting it denies everything**.
- `opts.thinkingLevel` (`"none" | "low" | "medium" | "high" | "xhigh"`) overrides the agent's default (`model.thinking_level` in `system_config.yaml`) for this turn only — raise it for hard questions, drop it for latency-sensitive calls like titling or classification. - `opts.thinkingLevel` (`"none" | "low" | "medium" | "high" | "xhigh"`) overrides the agent's default (`model.thinking_level` in `system_config.yaml`) for this turn only — raise it for hard questions, drop it for latency-sensitive calls like titling or classification.
- Session lifetime is the app's memory model: reuse one Session for a stateful chat (context accumulates, as above), create one per request for stateless QA (the RAG recipe below); either way call `session.dispose()` when done to release background processes. - Session lifetime is the app's memory model: reuse one Session for a stateful chat (context accumulates, as above), create one per request for stateless QA (the RAG recipe below); either way call `session.dispose()` when done to release background processes.
+9 -4
View File
@@ -4,7 +4,7 @@
* the first message is sent, and all four selections land faithfully in its meta; * the first message is sent, and all four selections land faithfully in its meta;
* - the draft auto-caches (body persisted via debounce): after a page reload, both the body and * - the draft auto-caches (body persisted via debounce): after a page reload, both the body and
* the selections are restored, and the cache clears once sending succeeds; * the selections are restored, and the cache clears once sending succeeds;
* - the sidebar defaults to grouping by Workspace: auto temp directories merge into one * - the sidebar defaults to grouping by Workspace: temporary workspaces merge into one
* "临时工作区" group, a named directory groups under its basename, and that group header's * "临时工作区" group, a named directory groups under its basename, and that group header's
* "+" pre-fills the draft's Workspace (via router state, applied once per navigation — a * "+" pre-fills the draft's Workspace (via router state, applied once per navigation — a
* manual change made afterwards survives a reload instead of being re-overridden); * manual change made afterwards survives a reload instead of being re-overridden);
@@ -171,8 +171,11 @@ test("draft: pick model/approval -> reload restores them -> send creates the ses
); );
// —— Default grouping: the sidebar groups Sessions by Workspace — the session just created // —— Default grouping: the sidebar groups Sessions by Workspace — the session just created
// used the auto temp directory, so it lands in the merged "临时工作区" group. —— // used an auto-created temporary workspace, so it lands in the merged "临时工作区" group.
await expect(page.getByText("临时工作区")).toBeVisible(); // Scoped to the sidebar (<aside>): the draft page's Workspace pill reads 临时工作区 too
// (S.chat.workspaceAuto === S.chat.tempWorkspaces), so a page-wide text lookup would be
// ambiguous whenever both are on screen. ——
await expect(page.locator("aside").getByText("临时工作区", { exact: true })).toBeVisible();
// A session in a named Workspace groups under that directory's basename, and its group // A session in a named Workspace groups under that directory's basename, and its group
// header's "+" pre-fills the draft's Workspace selection with the group's path. // header's "+" pre-fills the draft's Workspace selection with the group's path.
@@ -242,7 +245,9 @@ test("draft: pick model/approval -> reload restores them -> send creates the ses
// —— Pinning: the header's hover pin toggle lifts a group above the others in its mode and // —— Pinning: the header's hover pin toggle lifts a group above the others in its mode and
// persists per Project (localStorage penguin.sidebarPinnedGroups.<projectId>); order is // persists per Project (localStorage penguin.sidebarPinnedGroups.<projectId>); order is
// asserted geometrically, like layout.spec does for the login language buttons. —— // asserted geometrically, like layout.spec does for the login language buttons. ——
const tempLabel = page.getByText("临时工作区", { exact: true }); // Sidebar-scoped for the same reason as the group assertion above: the draft's Workspace
// pill would also read 临时工作区 whenever its selection is empty.
const tempLabel = page.locator("aside").getByText("临时工作区", { exact: true });
const namedLabel = page.getByText(wsLabel, { exact: true }); const namedLabel = page.getByText(wsLabel, { exact: true });
const yOf = async (locator) => (await locator.boundingBox())?.y ?? -1; const yOf = async (locator) => (await locator.boundingBox())?.y ?? -1;
// Unpinned baseline: the merged temp group sits below the named group. // Unpinned baseline: the merged temp group sits below the named group.
+2 -2
View File
@@ -46,8 +46,8 @@ test("sidebar shows 20 sessions plus a More row; More loads the 21st and then di
}); });
expect(put.ok(), "put models").toBeTruthy(); expect(put.ok(), "put models").toBeTruthy();
// Seed 21 sessions (each gets its own auto temp Workspace; the sidebar merges them into // Seed 21 sessions (each gets its own temporary Workspace; the sidebar merges them into
// the single temp-workspace group, so the display cap applies to one group). // the single temporary-workspace group, so the display cap applies to one group).
for (let i = 0; i < TOTAL; i++) { for (let i = 0; i < TOTAL; i++) {
const res = await page.request.post( const res = await page.request.post(
`${BASE}/api/projects/${projectId}/agents/default_agent/sessions`, `${BASE}/api/projects/${projectId}/agents/default_agent/sessions`,
@@ -8,6 +8,7 @@ import { useMemo, useState } from "react";
import { NavLink, Outlet, useMatch, useNavigate } from "react-router"; import { NavLink, Outlet, useMatch, useNavigate } from "react-router";
import { S } from "../../lib/strings"; import { S } from "../../lib/strings";
import { latestConversation } from "../../lib/session-grouping"; import { latestConversation } from "../../lib/session-grouping";
import { useVersionInfo } from "../../lib/use-version-info";
import { useAuth } from "../../state/auth"; import { useAuth } from "../../state/auth";
import { useProject } from "../../state/project"; import { useProject } from "../../state/project";
import { useSessions } from "../../state/sessions"; import { useSessions } from "../../state/sessions";
@@ -40,6 +41,14 @@ function CollapsedRail({ onExpand }: { onExpand: () => void }) {
const navigate = useNavigate(); const navigate = useNavigate();
const { agents, setCurrentAgentId } = useProject(); const { agents, setCurrentAgentId } = useProject();
const { sessions, loading } = useSessions(); const { sessions, loading } = useSessions();
/**
* Passive (active=false): never triggers a fetch — the rail mirrors whatever the lazy
* check has already learned (the pinned sidebar's dropdown or the draft page started it),
* matching the pinned sidebar's avatar dot. Cache pushes keep it live while mounted.
*/
const { update } = useVersionInfo(false);
/** Same "named release only" gate as the pinned sidebar's update row (see sidebar.tsx). */
const newVersion = update?.updateAvailable === true ? (update.latestVersion ?? null) : null;
const activeSessionId = useMatch("/chat/:sessionId")?.params.sessionId ?? null; const activeSessionId = useMatch("/chat/:sessionId")?.params.sessionId ?? null;
/** On some conversation (any non-draft /chat/:id): the "you are here" state of the last-conversation entry. */ /** On some conversation (any non-draft /chat/:id): the "you are here" state of the last-conversation entry. */
const onConversation = activeSessionId !== null && activeSessionId !== DRAFT_SESSION_ID; const onConversation = activeSessionId !== null && activeSessionId !== DRAFT_SESSION_ID;
@@ -130,12 +139,27 @@ function CollapsedRail({ onExpand }: { onExpand: () => void }) {
</nav> </nav>
<button <button
type="button" type="button"
title={`${user?.userId ?? ""} · ${S.nav.expandSidebar}`} title={[user?.userId ?? "", S.nav.expandSidebar]
aria-label={user?.userId ?? S.auth.admin} .concat(newVersion !== null ? [S.update.newVersion(newVersion)] : [])
.join(" · ")}
aria-label={
newVersion !== null
? `${user?.userId ?? ""} · ${S.update.newVersion(newVersion)}`
: (user?.userId ?? S.auth.admin)
}
onClick={onExpand} onClick={onExpand}
className="mt-auto flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gray-900 text-xs font-bold text-white dark:bg-gray-200 dark:text-gray-900" className="relative mt-auto flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gray-900 text-xs font-bold text-white dark:bg-gray-200 dark:text-gray-900"
> >
{(user?.userId ?? "?").slice(0, 1).toUpperCase()} {(user?.userId ?? "?").slice(0, 1).toUpperCase()}
{/* Update reminder dot, mirroring the pinned sidebar's avatar (same look, same
border trick against the rail background); the title/aria-label above name the
release, since the rail has no update row of its own. */}
{newVersion !== null && (
<span
aria-hidden
className="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full border-2 border-gray-50 bg-[var(--accent-bg)] dark:border-gray-900"
/>
)}
</button> </button>
</div> </div>
); );
@@ -619,7 +619,7 @@ function ChatDefaultsSection({ projectId, isOwner }: { projectId: string; isOwne
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<FieldLabel>{S.chat.workspace}</FieldLabel> <FieldLabel>{S.chat.workspace}</FieldLabel>
{/* The draft page's dir-browser pill: browse server directories, edit the path {/* The draft page's dir-browser pill: browse server directories, edit the path
inline, or clear back to the auto temp directory. */} inline, or clear back to a temporary workspace. */}
<WorkspaceSelect <WorkspaceSelect
projectId={projectId} projectId={projectId}
workspace={workspace} workspace={workspace}
+14 -5
View File
@@ -4,7 +4,7 @@
* Trace) -> Session area with two grouping modes (a small toggle in the section header; the * Trace) -> Session area with two grouping modes (a small toggle in the section header; the
* choice and each Project's group collapse and pin state persist in localStorage): by Workspace * choice and each Project's group collapse and pin state persist in localStorage): by Workspace
* (the default; groups loaded Sessions by their * (the default; groups loaded Sessions by their
* Workspace path, auto temp directories merged into one trailing group, header "+" starts a * Workspace path, temporary workspaces merged into one trailing group, header "+" starts a
* draft in that Workspace) or by Agent (group header = Agent name + new chat + Agent settings; * draft in that Workspace) or by Agent (group header = Agent name + new chat + Agent settings;
* shows all Agents, including empty groups). Groups can be pinned via the header's hover pin * shows all Agents, including empty groups). Groups can be pinned via the header's hover pin
* toggle: pinned groups sort before unpinned within their mode, keeping each partition's own * toggle: pinned groups sort before unpinned within their mode, keeping each partition's own
@@ -457,7 +457,7 @@ export function Sidebar({
* chat" uses default_agent; this explicit intent overrides the previously selected Agent in * chat" uses default_agent; this explicit intent overrides the previously selected Agent in
* the draft cache (the rest of the draft content, such as the message body, is preserved). * the draft cache (the rest of the draft content, such as the message body, is preserved).
* The workspace-mode group header's "+" additionally carries that group's Workspace path * The workspace-mode group header's "+" additionally carries that group's Workspace path
* ("" = the auto temp directory), pre-filling the draft's Workspace selection the same way. * ("" = a temporary workspace), pre-filling the draft's Workspace selection the same way.
*/ */
const newChat = (agentId?: string, workspace?: string) => { const newChat = (agentId?: string, workspace?: string) => {
if (agentId) setCurrentAgentId(agentId); if (agentId) setCurrentAgentId(agentId);
@@ -909,7 +909,7 @@ export function Sidebar({
actions={ actions={
<> <>
<GroupPinButton pinned={pinned} onToggle={() => togglePin(group.key)} /> <GroupPinButton pinned={pinned} onToggle={() => togglePin(group.key)} />
{/* New chat in this Workspace: pre-fills the group's path in the draft ("" = auto temp directory); the Agent is the current one, falling back to default_agent */} {/* New chat in this Workspace: pre-fills the group's path in the draft ("" = temporary workspace); the Agent is the current one, falling back to default_agent */}
<button <button
type="button" type="button"
title={S.chat.newSessionInWorkspace} title={S.chat.newSessionInWorkspace}
@@ -949,13 +949,22 @@ export function Sidebar({
<button <button
type="button" type="button"
onClick={() => setUserOpen(!userOpen)} onClick={() => setUserOpen(!userOpen)}
{...(newVersion !== null
? {
// The dot alone is mysterious: name the release on the trigger (hover
// tooltip + accessible name), in the update row's exact wording.
title: S.update.newVersion(newVersion),
"aria-label": `${user?.userId ?? ""} · ${S.update.newVersion(newVersion)}`,
}
: {})}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-150 hover:bg-gray-200/70 dark:hover:bg-gray-800" className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-150 hover:bg-gray-200/70 dark:hover:bg-gray-800"
> >
<span className="relative flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-gray-900 text-xs font-bold text-white dark:bg-gray-200 dark:text-gray-900"> <span className="relative flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-gray-900 text-xs font-bold text-white dark:bg-gray-200 dark:text-gray-900">
{(user?.userId ?? "?").slice(0, 1).toUpperCase()} {(user?.userId ?? "?").slice(0, 1).toUpperCase()}
{/* Update reminder dot: only once the lazy check has actually run and found a {/* Update reminder dot: only once the lazy check has actually run and found a
newer release. The border (sidebar background color) separates it from the newer release (the trigger button's tooltip/label above explains it). The
avatar for every accent — the neutral accent matches the avatar fill. */} border (sidebar background color) separates it from the avatar for every
accent — the neutral accent matches the avatar fill. */}
{updateAvailable && ( {updateAvailable && (
<span <span
aria-hidden aria-hidden
@@ -21,7 +21,7 @@
* The sidebar group header "+" / menu "New conversation" explicitly specify an * The sidebar group header "+" / menu "New conversation" explicitly specify an
* Agent via route state (overriding the cached selection); the workspace-mode * Agent via route state (overriding the cached selection); the workspace-mode
* group header "+" additionally carries a Workspace path pre-filling the * group header "+" additionally carries a Workspace path pre-filling the
* Workspace selection ("" = auto temp directory). A direct visit or refresh * Workspace selection ("" = temporary workspace). A direct visit or refresh
* falls back to the cache. When neither route state nor the mount-time cache claims a * 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 / * field, the Project's new-chat defaults ([default_chat]) prefill Agent / Workspace /
* approval mode (precedence: route state > draft cache > project default > built-in * approval mode (precedence: route state > draft cache > project default > built-in
@@ -256,7 +256,7 @@ export function DraftView({
// Explicit Workspace from route state (the workspace-mode group header "+"): applied once per // 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 // location.key, same convention as the Agent above, overriding the cached selection ("" pre-fills
// the auto temp directory). Unlike the Agent there's no list to validate against, so this is a // the temporary workspace). Unlike the Agent there's no list to validate against, so this is a
// separate effect that never has to wait for a load. // separate effect that never has to wait for a load.
const stateWorkspace = routeState?.workspace; const stateWorkspace = routeState?.workspace;
const appliedWorkspaceKey = useRef<string | null>(null); const appliedWorkspaceKey = useRef<string | null>(null);
@@ -276,7 +276,7 @@ export function DraftView({
// Project defaults for Workspace / approval mode: the same apply-once discipline as the // 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 // 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 // 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"), // mount-time cached value (a cached "" workspace counts: it is an explicit temporary workspace),
// and no user edit since mount. Model is deliberately not here (models.defaultModel // 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). // already flows through its own fallback effect below — the single-sourced default).
const appliedProjectDefaults = useRef(false); const appliedProjectDefaults = useRef(false);
@@ -30,7 +30,7 @@ export const pillClass =
/** /**
* Workspace selection (pill dropdown): the button shows the selected directory name (empty = * 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 * a temporary workspace). 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 * 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 * 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 menu; only loads on first expand. On narrow screens the menu docks to whichever side
@@ -157,7 +157,7 @@ export function WorkspaceSelect({
}; };
const trimmed = workspace.trim(); const trimmed = workspace.trim();
// Pill short name: the last segment of the directory name (root gives "/"); shows "auto temp directory" when empty. // Pill short name: the last segment of the directory name (root gives "/"); shows "temporary workspace" when empty.
const label = trimmed ? (trimmed.split("/").filter(Boolean).pop() ?? "/") : S.chat.workspaceAuto; const label = trimmed ? (trimmed.split("/").filter(Boolean).pop() ?? "/") : S.chat.workspaceAuto;
const parentPath = dir?.parent ?? null; const parentPath = dir?.parent ?? null;
// Hidden directories (starting with .) are excluded from the list. // Hidden directories (starting with .) are excluded from the list.
@@ -327,7 +327,7 @@ export function WorkspaceSelect({
)} )}
</ul> </ul>
</div> </div>
{/* When a directory has been specified, offer a one-click way back to the auto temp directory */} {/* When a directory has been specified, offer a one-click way back to a temporary workspace */}
{trimmed && ( {trimmed && (
<button <button
type="button" type="button"
@@ -0,0 +1,46 @@
/**
* Expansion state for the models page's vendor groups (pure decisions, unit tested).
*
* The page stores the set of EXPANDED provider ids rather than collapsed ones: groups
* are derived only after the model rows load (user-defined groups arrive with that
* async response), so a collapsed-set default cannot express "everything collapsed
* except DeepSeek" without knowing every group id up front. An expanded set survives
* late-arriving groups — anything not in it simply renders collapsed. Not persisted,
* matching the previous collapse behavior.
*/
/**
* Provider ids expanded on first paint: DeepSeek only — the default model's provider and
* the first group in MODEL_PROVIDERS, so the page opens with exactly its top group
* unfolded. Returns a fresh Set per call (React state must never share a module-level
* mutable instance).
*/
export function defaultExpandedProviders(): Set<string> {
return new Set(["deepseek"]);
}
/**
* Whether a vendor group renders expanded. While a search query is active every group
* still rendered holds at least one match (groupModelRows drops matchless groups when
* searching), and a match hidden inside a collapsed group would look like a missing
* result — so searching forces groups open. Derived only: the stored set is untouched,
* and clearing the query restores the user's own expand/collapse choices.
*/
export function isGroupExpanded(
expanded: ReadonlySet<string>,
providerId: string,
searching: boolean,
): boolean {
return searching || expanded.has(providerId);
}
/** Immutable toggle of one provider id in the expanded set (state-updater shape; the input set is never mutated). */
export function toggleExpandedProvider(
expanded: ReadonlySet<string>,
providerId: string,
): Set<string> {
const next = new Set(expanded);
if (next.has(providerId)) next.delete(providerId);
else next.add(providerId);
return next;
}
@@ -71,6 +71,11 @@ import {
} from "@prismshadow/penguin-core/model-catalog"; } from "@prismshadow/penguin-core/model-catalog";
import type { ModelProviderInfo } from "@prismshadow/penguin-core/model-catalog"; import type { ModelProviderInfo } from "@prismshadow/penguin-core/model-catalog";
import { groupModelRows, isFreeModel, sameModelRef, userProviderInfo } from "./model-grouping"; import { groupModelRows, isFreeModel, sameModelRef, userProviderInfo } from "./model-grouping";
import {
defaultExpandedProviders,
isGroupExpanded,
toggleExpandedProvider,
} from "./model-group-expansion";
import { clearDraftModelRef } from "../chat/draft-cache"; import { clearDraftModelRef } from "../chat/draft-cache";
import { syncRowsWithCatalog } from "./catalog-sync"; import { syncRowsWithCatalog } from "./catalog-sync";
import { tpsTone, ttftTone } from "./speed-test"; import { tpsTone, ttftTone } from "./speed-test";
@@ -352,8 +357,12 @@ export function ModelsPage() {
/** Target group (provider id) for adding a model: taken from the group header entry point, falling back to custom when empty. */ /** Target group (provider id) for adding a model: taken from the group header entry point, falling back to custom when empty. */
const [addingTo, setAddingTo] = useState<string | null>(null); const [addingTo, setAddingTo] = useState<string | null>(null);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
/** Collapsed vendor groups (all expanded by default). */ /**
const [collapsed, setCollapsed] = useState<Set<string>>(new Set()); * Expanded vendor groups — only DeepSeek on first paint; every other group (including
* user-defined ones, which arrive with the async row load) starts collapsed. Searching
* force-opens the rendered groups without touching this set (see model-group-expansion.ts).
*/
const [expanded, setExpanded] = useState<Set<string>>(defaultExpandedProviders);
/** Vendor group (provider id) currently having its API key configured in bulk. */ /** Vendor group (provider id) currently having its API key configured in bulk. */
const [groupKeyFor, setGroupKeyFor] = useState<string | null>(null); const [groupKeyFor, setGroupKeyFor] = useState<string | null>(null);
/** "Add group" popup (user-defined group): a valid name proceeds to that group's add-model dialog. */ /** "Add group" popup (user-defined group): a valid name proceeds to that group's add-model dialog. */
@@ -434,6 +443,8 @@ export function ModelsPage() {
}; };
const groups = useMemo(() => (rows ? groupModelRows(rows, query) : []), [rows, query]); const groups = useMemo(() => (rows ? groupModelRows(rows, query) : []), [rows, query]);
/** Non-empty search query: groups are filtered to matches and force-opened while it lasts. */
const searching = query.trim() !== "";
/** /**
* "Sync presets": merge the built-in catalog into the current table (union; the catalog * "Sync presets": merge the built-in catalog into the current table (union; the catalog
@@ -514,13 +525,15 @@ export function ModelsPage() {
if (!projectId) return null; if (!projectId) return null;
const toggleGroup = (id: string) => /**
setCollapsed((prev) => { * Header toggles are inert while searching: every rendered group is force-opened (see
const next = new Set(prev); * isGroupExpanded), so a flip would change nothing visibly and only silently mutate the
if (next.has(id)) next.delete(id); * state restored once the query clears.
else next.add(id); */
return next; const toggleGroup = (id: string) => {
}); if (searching) return;
setExpanded((prev) => toggleExpandedProvider(prev, id));
};
return ( return (
<div className="h-full overflow-y-auto p-4 md:p-6"> <div className="h-full overflow-y-auto p-4 md:p-6">
@@ -571,7 +584,7 @@ export function ModelsPage() {
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{groups.map((group) => { {groups.map((group) => {
const open = !collapsed.has(group.provider.id); const open = isGroupExpanded(expanded, group.provider.id, searching);
return ( return (
<section <section
key={group.provider.id} key={group.provider.id}
+12 -12
View File
@@ -4,10 +4,10 @@
* There is no Workspace entity on the server: a Session only carries the plain * There is no Workspace entity on the server: a Session only carries the plain
* filesystem path locked in at creation (SessionInfo.workspace), so grouping works * filesystem path locked in at creation (SessionInfo.workspace), so grouping works
* on those path strings. Sessions created without an explicit Workspace get an * on those path strings. Sessions created without an explicit Workspace get an
* auto-created temp directory shaped like `<agentDir>/workspaces/tmp-<8hex>` * auto-created temporary workspace shaped like `<agentDir>/workspaces/tmp-<8hex>`
* (packages/core/src/internal/session-support.ts, createTempWorkspace); each of * (packages/core/src/internal/session-support.ts, createTempWorkspace); each of
* those is single-use, so per-path groups would be one-session noise — they are all * those is single-use, so per-path groups would be one-session noise — they are all
* merged into ONE trailing "temp workspaces" group instead. * merged into ONE trailing "temporary workspaces" group instead.
*/ */
import type { import type {
SessionCategory, SessionCategory,
@@ -15,16 +15,16 @@ import type {
SessionInfo, SessionInfo,
} from "@prismshadow/penguin-server/api"; } from "@prismshadow/penguin-server/api";
/** Group key of the merged auto-temp group ("\0" can never appear in a filesystem path, so it never collides with a real Workspace). */ /** Group key of the merged temporary-workspace group ("\0" can never appear in a filesystem path, so it never collides with a real Workspace). */
export const TEMP_WORKSPACE_GROUP_KEY = "\0temp-workspaces"; export const TEMP_WORKSPACE_GROUP_KEY = "\0temp-workspaces";
/** Auto-created temp Workspace tail: `workspaces/tmp-<8hex>` (either path separator; core supports win32). */ /** Auto-created temporary Workspace tail: `workspaces/tmp-<8hex>` (either path separator; core supports win32). */
const TEMP_WORKSPACE_RE = /[/\\]workspaces[/\\]tmp-[0-9a-f]{8}$/; const TEMP_WORKSPACE_RE = /[/\\]workspaces[/\\]tmp-[0-9a-f]{8}$/;
/** /**
* Whether a Session's Workspace is an auto-created temp directory. An empty path * Whether a Session's Workspace is an auto-created temporary workspace. An empty
* also counts as "auto temp": the server always backfills the resolved path, so * path also counts as one: the server always backfills the resolved path, so this
* this is defensive only. * is defensive only.
*/ */
export function isTempWorkspace(workspace: string): boolean { export function isTempWorkspace(workspace: string): boolean {
const p = workspace.trim(); const p = workspace.trim();
@@ -114,10 +114,10 @@ export interface GroupCounts {
/** /**
* Folds the per-Agent per-Workspace-path category counts (SessionsResponse.workspaceCounts) * Folds the per-Agent per-Workspace-path category counts (SessionsResponse.workspaceCounts)
* into workspace-mode groups, keyed like groupSessionsByWorkspace (exact path; auto-temp * into workspace-mode groups, keyed like groupSessionsByWorkspace (exact path;
* paths merged into the temp group). The sidebar labels a group's folders and decides its * temporary-workspace paths merged into the temp group). The sidebar labels a group's
* "More" from its own share — never from an Agent's other Workspaces, which would * folders and decides its "More" from its own share — never from an Agent's other
* advertise folders whose content lives in other groups. * Workspaces, which would advertise folders whose content lives in other groups.
*/ */
export function aggregateWorkspaceCounts( export function aggregateWorkspaceCounts(
byAgent: ReadonlyMap<string, Readonly<Record<string, SessionCategoryCounts>>>, byAgent: ReadonlyMap<string, Readonly<Record<string, SessionCategoryCounts>>>,
@@ -179,7 +179,7 @@ export interface WorkspaceGroup<T = SessionInfo> {
label: string; label: string;
/** Full path for tooltips; null for the merged temp group (its members' paths all differ). */ /** Full path for tooltips; null for the merged temp group (its members' paths all differ). */
fullPath: string | null; fullPath: string | null;
/** True for the merged auto-temp group. */ /** True for the merged temporary-workspace group. */
temp: boolean; temp: boolean;
/** Member Sessions, newest first (createdAt desc). */ /** Member Sessions, newest first (createdAt desc). */
sessions: T[]; sessions: T[];
+6 -6
View File
@@ -187,7 +187,7 @@ export const en: Strings = {
chatDefaultsNotSet: "Not set", chatDefaultsNotSet: "Not set",
chatDefaultsApprovalNotSet: "Not set (defaults to allow all)", chatDefaultsApprovalNotSet: "Not set (defaults to allow all)",
chatDefaultsThinkingNotSet: "Not set (follow the agent's config)", chatDefaultsThinkingNotSet: "Not set (follow the agent's config)",
chatDefaultsWorkspaceHint: "Empty = auto temp directory", chatDefaultsWorkspaceHint: "Empty = temporary workspace",
/** The model default is single-sourced with the Models page (the same default_model); this is just another entry point. */ /** 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", chatDefaultsModelHint: "Same default model as the Models page",
deleteProject: "Delete Project", deleteProject: "Delete Project",
@@ -512,7 +512,7 @@ export const en: Strings = {
targetNew: "New session each time", targetNew: "New session each time",
targetSession: "Bound Session", targetSession: "Bound Session",
sessionId: "Session id", sessionId: "Session id",
workspace: "Workspace (optional, auto-created when empty)", workspace: "Workspace (optional; a temporary workspace is created when empty)",
model: "Model", model: "Model",
modelDefault: "Project default", modelDefault: "Project default",
deleteTitle: "Delete scheduled task", deleteTitle: "Delete scheduled task",
@@ -608,13 +608,13 @@ export const en: Strings = {
workspaceUseThis: "Use this dir", workspaceUseThis: "Use this dir",
workspaceUp: "Parent dir", workspaceUp: "Parent dir",
workspaceNoSubdirs: "No subdirectories", workspaceNoSubdirs: "No subdirectories",
workspaceAuto: "Auto temp directory", workspaceAuto: "Temporary workspace",
workspaceClear: "Use auto temp directory instead", workspaceClear: "Use a temporary workspace instead",
workspaceDirInvalid: "Directory does not exist or is inaccessible; reverted", workspaceDirInvalid: "Directory does not exist or is inaccessible; reverted",
/** Sidebar conversation-list grouping toggle (workspace is the default) + workspace groups. */ /** Sidebar conversation-list grouping toggle (workspace is the default) + workspace groups. */
groupByWorkspace: "Group by workspace", groupByWorkspace: "Group by workspace",
groupByAgent: "Group by agent", groupByAgent: "Group by agent",
tempWorkspaces: "Temp workspaces", tempWorkspaces: "Temporary workspaces",
newSessionInWorkspace: "New chat in this workspace", newSessionInWorkspace: "New chat in this workspace",
draftSubtitle: "The self-evolving agent that excels at AI development tasks", draftSubtitle: "The self-evolving agent that excels at AI development tasks",
/** Folder names for the draft page's collapsible examples (bookmark-style: exactly one open at a time). */ /** Folder names for the draft page's collapsible examples (bookmark-style: exactly one open at a time). */
@@ -740,7 +740,7 @@ Scenarios:
model: "Model", model: "Model",
workspace: "Workspace", workspace: "Workspace",
workspaceHint: workspaceHint:
"Leave empty for an auto-created temp directory; if set, it must be an existing directory on the server", "Leave empty for an auto-created temporary workspace; if set, it must be an existing directory on the server",
approvalMode: "Approval mode", approvalMode: "Approval mode",
approvalModeNames: { approvalModeNames: {
"allow-all": "Approve everything", "allow-all": "Approve everything",
+5 -5
View File
@@ -180,7 +180,7 @@ export const zh = {
chatDefaultsNotSet: "未设置", chatDefaultsNotSet: "未设置",
chatDefaultsApprovalNotSet: "未设置(默认全部放行)", chatDefaultsApprovalNotSet: "未设置(默认全部放行)",
chatDefaultsThinkingNotSet: "未设置(跟随智能体配置)", chatDefaultsThinkingNotSet: "未设置(跟随智能体配置)",
chatDefaultsWorkspaceHint: "留空表示自动临时目录", chatDefaultsWorkspaceHint: "留空表示使用临时工作区",
/** 模型默认值与模型页同源(同一个 default_model),此处仅是另一处入口。 */ /** 模型默认值与模型页同源(同一个 default_model),此处仅是另一处入口。 */
chatDefaultsModelHint: "与模型页的默认模型同步", chatDefaultsModelHint: "与模型页的默认模型同步",
deleteProject: "删除 Project", deleteProject: "删除 Project",
@@ -490,7 +490,7 @@ export const zh = {
targetNew: "每次新建会话", targetNew: "每次新建会话",
targetSession: "绑定 Session", targetSession: "绑定 Session",
sessionId: "Session id", sessionId: "Session id",
workspace: "Workspace(可选,留空自动创建)", workspace: "Workspace(可选,留空自动创建临时工作区)",
model: "Model", model: "Model",
modelDefault: "Project 默认", modelDefault: "Project 默认",
deleteTitle: "删除定时任务", deleteTitle: "删除定时任务",
@@ -585,8 +585,8 @@ export const zh = {
workspaceUseThis: "使用此目录", workspaceUseThis: "使用此目录",
workspaceUp: "上级目录", workspaceUp: "上级目录",
workspaceNoSubdirs: "无子目录", workspaceNoSubdirs: "无子目录",
workspaceAuto: "自动临时目录", workspaceAuto: "临时工作区",
workspaceClear: "改用自动临时目录", workspaceClear: "改用临时工作区",
workspaceDirInvalid: "目录不存在或无法访问,已回退", workspaceDirInvalid: "目录不存在或无法访问,已回退",
/** 侧栏对话列表的分组切换(默认按工作区)与工作区分组。 */ /** 侧栏对话列表的分组切换(默认按工作区)与工作区分组。 */
groupByWorkspace: "按工作区分组", groupByWorkspace: "按工作区分组",
@@ -717,7 +717,7 @@ Benchmark:
defaultSessionTitle: "新对话", defaultSessionTitle: "新对话",
model: "Model", model: "Model",
workspace: "Workspace", workspace: "Workspace",
workspaceHint: "留空自动创建临时目录;指定时必须是服务器上已存在的目录", workspaceHint: "留空自动创建临时工作区;指定时必须是服务器上已存在的目录",
approvalMode: "审批模式", approvalMode: "审批模式",
/** Short description (the trigger button shows only the description, not the mode id). */ /** Short description (the trigger button shows only the description, not the mode id). */
approvalModeNames: { approvalModeNames: {
+15 -6
View File
@@ -20,14 +20,21 @@ let updateCache: UpdateCheckResponse | null = null;
let updatePromise: Promise<UpdateCheckResponse> | null = null; let updatePromise: Promise<UpdateCheckResponse> | null = null;
/** /**
* Mounted hooks subscribe here so forceUpdateCheck (the sidebar's manual "check for * Mounted hooks subscribe here so any refresh of the module cache reaches every
* updates" action) can push its fresh result to every consumer at once — the footer, * consumer at once — the footer, the update dots, the reminder rows, and the draft
* the update dot, the reminder rows, and the draft page's version line all react * page's version line all react without a remount. Two paths push: forceUpdateCheck
* without a remount. The lazy fetch path doesn't need this (each hook awaits the * (the sidebar's manual "check for updates" action) and the lazy fetch resolving.
* shared promise itself); only an out-of-band refresh does. * Active hooks await the shared promise themselves, but passive ones (active=false,
* e.g. the collapsed rail's avatar dot) only ever read the cache — without the lazy
* push they would miss a result that lands while they are mounted.
*/ */
const listeners = new Set<() => void>(); const listeners = new Set<() => void>();
/** Pushes the current module cache to every mounted hook (see the listeners comment). */
function notifyAll(): void {
for (const notify of listeners) notify();
}
/** How one manual update check ended, for user feedback — exactly one notice per outcome. */ /** How one manual update check ended, for user feedback — exactly one notice per outcome. */
export type UpdateCheckOutcome = export type UpdateCheckOutcome =
| { kind: "disabled" } | { kind: "disabled" }
@@ -79,6 +86,7 @@ export function useVersionInfo(active: boolean): VersionInfo {
versionPromise ??= api.getVersion().then((res) => { versionPromise ??= api.getVersion().then((res) => {
versionCache = res; versionCache = res;
notifyAll();
return res; return res;
}); });
versionPromise versionPromise
@@ -91,6 +99,7 @@ export function useVersionInfo(active: boolean): VersionInfo {
updatePromise ??= api.checkUpdate().then((res) => { updatePromise ??= api.checkUpdate().then((res) => {
updateCache = res; updateCache = res;
notifyAll();
return res; return res;
}); });
updatePromise updatePromise
@@ -130,6 +139,6 @@ export async function forceUpdateCheck(): Promise<UpdateCheckResponse> {
if (updatePromise === promise) updatePromise = null; if (updatePromise === promise) updatePromise = null;
throw e; throw e;
} finally { } finally {
for (const notify of listeners) notify(); notifyAll();
} }
} }
@@ -0,0 +1,89 @@
/**
* model-group-expansion.ts unit tests: the models page's default-collapsed vendor groups.
* Only DeepSeek (the catalog's first provider) is expanded on first paint; the state is a
* set of EXPANDED ids so groups arriving late (user-defined groups load with the rows)
* default to collapsed without being known up front. While a search query is active every
* rendered group is force-opened — groupModelRows only returns match-holding groups when
* searching, and a match hidden inside a collapsed group would look like a missing result —
* without touching the stored set.
*/
import { describe, expect, it } from "vitest";
import { MODEL_PROVIDERS } from "@prismshadow/penguin-core/model-catalog";
import {
defaultExpandedProviders,
isGroupExpanded,
toggleExpandedProvider,
} from "../src/features/models/model-group-expansion";
import { groupModelRows } from "../src/features/models/model-grouping";
import type { ModelRowLike } from "../src/features/models/model-grouping";
describe("defaultExpandedProviders", () => {
it("contains exactly deepseek, the first provider of the catalog", () => {
expect([...defaultExpandedProviders()]).toEqual(["deepseek"]);
// The default leans on the catalog ordering promise (DeepSeek first): pin it here so a
// reordering shows up as this failure instead of a silently odd default.
expect(MODEL_PROVIDERS[0]?.id).toBe("deepseek");
});
it("returns a fresh set per call (React state must not share one mutable instance)", () => {
const a = defaultExpandedProviders();
const b = defaultExpandedProviders();
expect(a).not.toBe(b);
a.add("openai");
expect(b.has("openai")).toBe(false);
});
});
describe("isGroupExpanded", () => {
const expanded = defaultExpandedProviders();
it("without a search query, only members of the expanded set are open", () => {
expect(isGroupExpanded(expanded, "deepseek", false)).toBe(true);
expect(isGroupExpanded(expanded, "anthropic", false)).toBe(false);
expect(isGroupExpanded(expanded, "custom", false)).toBe(false);
// User-defined groups are decided by the same membership rule — collapsed until toggled.
expect(isGroupExpanded(expanded, "my-proxy", false)).toBe(false);
});
it("while searching, every group is open regardless of the stored set", () => {
expect(isGroupExpanded(expanded, "anthropic", true)).toBe(true);
expect(isGroupExpanded(new Set(), "deepseek", true)).toBe(true);
});
it("force-open covers each group a search actually renders (the hidden-results regression)", () => {
const rows: ModelRowLike[] = [
{ provider: "deepseek", modelId: "deepseek-chat" },
{ provider: "anthropic", modelId: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6" },
{ provider: "moonshot", modelId: "kimi-k2.6", displayName: "Kimi K2.6" },
{ provider: "my-proxy", modelId: "kimi-mirror" },
];
// "kimi" matches inside groups that default to collapsed (moonshot + a user-defined one):
// with the query active each rendered group must derive as open.
const groups = groupModelRows(rows, "kimi");
expect(groups.length).toBeGreaterThan(0);
for (const g of groups) {
expect(isGroupExpanded(defaultExpandedProviders(), g.provider.id, true)).toBe(true);
}
// Clearing the query falls back to the stored set: the same groups collapse again.
for (const g of groups) {
expect(isGroupExpanded(defaultExpandedProviders(), g.provider.id, false)).toBe(
g.provider.id === "deepseek",
);
}
});
});
describe("toggleExpandedProvider", () => {
it("adds an absent id and removes a present one, without mutating the input", () => {
const initial = defaultExpandedProviders();
const withAnthropic = toggleExpandedProvider(initial, "anthropic");
expect(withAnthropic.has("anthropic")).toBe(true);
expect(withAnthropic.has("deepseek")).toBe(true);
expect(initial.has("anthropic")).toBe(false); // input untouched (React state discipline)
const withoutDeepseek = toggleExpandedProvider(withAnthropic, "deepseek");
expect(withoutDeepseek.has("deepseek")).toBe(false);
expect(withoutDeepseek.has("anthropic")).toBe(true);
expect(withAnthropic.has("deepseek")).toBe(true);
});
});
+5 -5
View File
@@ -2,7 +2,7 @@
* Workspace grouping for the chat sidebar (pure logic): * Workspace grouping for the chat sidebar (pure logic):
* - named Workspaces group by exact path, labeled by basename (full path kept for * - named Workspaces group by exact path, labeled by basename (full path kept for
* tooltips), newest group first; * tooltips), newest group first;
* - auto temp Workspaces (`<agentDir>/workspaces/tmp-<8hex>`, the shape produced by * - temporary Workspaces (`<agentDir>/workspaces/tmp-<8hex>`, the shape produced by
* core's createTempWorkspace) are all merged into ONE trailing temp group — an * core's createTempWorkspace) are all merged into ONE trailing temp group — an
* empty path (defensive; the server always backfills the resolved dir) counts too; * empty path (defensive; the server always backfills the resolved dir) counts too;
* - sessions inside every group are re-sorted newest first: the flat store list * - sessions inside every group are re-sorted newest first: the flat store list
@@ -58,7 +58,7 @@ function session(
const TEMP_A = "/data/proj/agents/default_agent/workspaces/tmp-1a2b3c4d"; const TEMP_A = "/data/proj/agents/default_agent/workspaces/tmp-1a2b3c4d";
const TEMP_B = "/data/proj/agents/agent_helper/workspaces/tmp-00ff00aa"; const TEMP_B = "/data/proj/agents/agent_helper/workspaces/tmp-00ff00aa";
describe("isTempWorkspace (auto temp directory pattern from core's createTempWorkspace)", () => { describe("isTempWorkspace (temporary-workspace pattern from core's createTempWorkspace)", () => {
it("matches <...>/workspaces/tmp-<8hex> with either path separator, and the empty path", () => { it("matches <...>/workspaces/tmp-<8hex> with either path separator, and the empty path", () => {
expect(isTempWorkspace(TEMP_A)).toBe(true); expect(isTempWorkspace(TEMP_A)).toBe(true);
expect(isTempWorkspace("C:\\pg\\data\\proj\\agents\\a\\workspaces\\tmp-00ff00aa")).toBe(true); expect(isTempWorkspace("C:\\pg\\data\\proj\\agents\\a\\workspaces\\tmp-00ff00aa")).toBe(true);
@@ -74,7 +74,7 @@ describe("isTempWorkspace (auto temp directory pattern from core's createTempWor
expect(isTempWorkspace("/x/workspaces/tmp-XYZWQPRS")).toBe(false); expect(isTempWorkspace("/x/workspaces/tmp-XYZWQPRS")).toBe(false);
expect(isTempWorkspace("/x/workspaces/tmp-1a2b3c4")).toBe(false); expect(isTempWorkspace("/x/workspaces/tmp-1a2b3c4")).toBe(false);
expect(isTempWorkspace("/x/workspaces/tmp-1a2b3c4d5")).toBe(false); expect(isTempWorkspace("/x/workspaces/tmp-1a2b3c4d5")).toBe(false);
// a subdirectory below a temp Workspace is not itself the temp Workspace // a subdirectory below a temporary workspace is not itself the temporary workspace
expect(isTempWorkspace(`${TEMP_A}/nested`)).toBe(false); expect(isTempWorkspace(`${TEMP_A}/nested`)).toBe(false);
}); });
}); });
@@ -115,7 +115,7 @@ describe("groupSessionsByWorkspace", () => {
expect(groups[0]!.sessions.map((s) => s.sessionId)).toEqual([a2.sessionId, a1.sessionId]); expect(groups[0]!.sessions.map((s) => s.sessionId)).toEqual([a2.sessionId, a1.sessionId]);
}); });
it("merges every temp Workspace into one trailing group, after named groups sorted by newest session", () => { it("merges every temporary Workspace into one trailing group, after named groups sorted by newest session", () => {
const oldAlpha = session("/srv/alpha", "2026-07-01T10:00:00.000Z"); const oldAlpha = session("/srv/alpha", "2026-07-01T10:00:00.000Z");
const newAlpha = session("/srv/alpha", "2026-07-06T10:00:00.000Z"); const newAlpha = session("/srv/alpha", "2026-07-06T10:00:00.000Z");
const beta = session("/srv/beta", "2026-07-05T10:00:00.000Z"); const beta = session("/srv/beta", "2026-07-05T10:00:00.000Z");
@@ -276,7 +276,7 @@ describe("aggregateWorkspaceCounts (per-group exact server share)", () => {
expect(aggregateWorkspaceCounts(new Map()).size).toBe(0); expect(aggregateWorkspaceCounts(new Map()).size).toBe(0);
}); });
it("folds every auto-temp path into the merged temp group, deduplicating agents", () => { it("folds every temporary-workspace path into the merged temp group, deduplicating agents", () => {
const byAgent = new Map([ const byAgent = new Map([
[ [
"agent_a", "agent_a",