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 Zheng
2026-08-06 20:13:43 +08:00
committed by GitHub
parent 75a8aa12c8
commit e8499befd4
29 changed files with 283 additions and 88 deletions
+3 -3
View File
@@ -181,7 +181,7 @@ export class Agent {
*/
async createSession(opts: CreateSessionOptions = {}): Promise<Session> {
// 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
// reference outside the config throws immediately rather than passing silently,
// 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
// 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;
if (opts.workspaceDir) {
workspaceDir = path.resolve(opts.workspaceDir);
@@ -226,7 +226,7 @@ export class Agent {
stat = await fs.stat(workspaceDir);
} catch {
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()) {
@@ -91,7 +91,7 @@ export async function createTempWorkspace(
await fs.mkdir(base, { recursive: true });
// 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
// 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++) {
const dir = path.join(base, `tmp-${randomUUID().slice(0, 8)}`);
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.
* 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);
* - `workspace`: the prefilled Workspace directory (absent/empty = a temporary workspace);
* - `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).
+2 -2
View File
@@ -5,7 +5,7 @@
* 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
* 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
* 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");
await fs.mkdir(ws, { recursive: true });
// 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(
agent.createSession({
workspaceDir: ws,
+1 -1
View File
@@ -5,7 +5,7 @@
* `<agent>/workspaces/`.
* - The id is checked for collisions within `workspaces/`: on conflict with an existing
* 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 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 |
| 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)
+1 -1
View File
@@ -135,7 +135,7 @@ Schedule 写操作仅限 Owner。新建 Session 模式的任务,`modelId` 与
| POST | /agents/:agentId/sessions | 创建 Session:`{modelId?, provider?, workspace?, approvalMode?}` → 201 |
| GET | /dirs?path= | 服务器端目录浏览(Workspace 选择器数据源) |
创建 Session 时,`modelId` 与 `provider` 要么成对给出、要么都不给:给出完整二元组即指定模型,两个都省略则取 Project 默认模型,只给一个返回 400。Workspace 默认自动创建临时目录,审批模式默认 `allow-all`。
创建 Session 时,`modelId` 与 `provider` 要么成对给出、要么都不给:给出完整二元组即指定模型,两个都省略则取 Project 默认模型,只给一个返回 400。Workspace 默认自动创建临时工作区,审批模式默认 `allow-all`。
### 用量与 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 |
| 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>` |
| 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 |
@@ -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
│ # installed once for any task; project dependencies stay
│ # in the project
├── workspaces/ # temp Workspaces (tmp-<8hex>)
├── workspaces/ # temporary Workspaces (tmp-<8hex>)
├── benchmarks/ # capability Benchmark cases and scores
└── snapshots/ # Agent State version snapshots
```
+1 -1
View File
@@ -391,7 +391,7 @@ export interface DefaultModelResponse {
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. */
/** Prefilled Workspace directory; absent/empty = a temporary workspace. */
workspace?: string;
/** Prefilled approval mode; absent = the built-in "allow-all". */
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
// 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" });
if (workspace !== undefined && workspace !== "") req.workspace = workspace;
+1 -1
View File
@@ -33,7 +33,7 @@ export interface ScheduleDefinition {
endAtMs?: number;
/** The target Session to bind to; defaults to creating a new Session each time. */
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;
/** Model for new-Session mode (upstream id, always paired with provider; omit both for the Project's default reference). */
modelId?: string;
@@ -8,7 +8,7 @@
* reachability governed by the file permissions of the OS account running the
* service.
* 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 { HttpError } from "../http/errors.js";
@@ -26,7 +26,7 @@ export async function assertWorkspaceAllowed(args: { workspace: string }): Promi
throw new HttpError(
400,
"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);
+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();
const res = await api.post(base(), {});
expect(res.status).toBe(201);
@@ -321,8 +321,8 @@ describe("session-index", () => {
expect((await list("")).workspaceCounts).toBeUndefined();
// 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
// temp directory.
// subagent Session sits alone in its path; every other row lives in its own
// temporary workspace.
const byWorkspace = full.workspaceCounts!;
expect(byWorkspace["/tmp/w-sub"]).toEqual({
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.
short_description: Build AI and RAG apps on the Penguin Harness SDK.
short_description_zh: 基于 Penguin SDK 构建 AI 与 RAG 应用。
version: 18
updated: 2026-07-30T11:10:00Z
version: 19
updated: 2026-08-06T00:00:00Z
---
# Penguin Harness SDK
@@ -97,7 +97,7 @@ rl.close();
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**.
- `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.
+9 -4
View File
@@ -4,7 +4,7 @@
* 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 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
* "+" 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);
@@ -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
// used the auto temp directory, so it lands in the merged "临时工作区" group. ——
await expect(page.getByText("临时工作区")).toBeVisible();
// used an auto-created temporary workspace, so it lands in the merged "临时工作区" group.
// 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
// 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
// persists per Project (localStorage penguin.sidebarPinnedGroups.<projectId>); order is
// 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 yOf = async (locator) => (await locator.boundingBox())?.y ?? -1;
// 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();
// Seed 21 sessions (each gets its own auto temp Workspace; the sidebar merges them into
// the single temp-workspace group, so the display cap applies to one group).
// Seed 21 sessions (each gets its own temporary Workspace; the sidebar merges them into
// the single temporary-workspace group, so the display cap applies to one group).
for (let i = 0; i < TOTAL; i++) {
const res = await page.request.post(
`${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 { S } from "../../lib/strings";
import { latestConversation } from "../../lib/session-grouping";
import { useVersionInfo } from "../../lib/use-version-info";
import { useAuth } from "../../state/auth";
import { useProject } from "../../state/project";
import { useSessions } from "../../state/sessions";
@@ -40,6 +41,14 @@ function CollapsedRail({ onExpand }: { onExpand: () => void }) {
const navigate = useNavigate();
const { agents, setCurrentAgentId } = useProject();
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;
/** 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;
@@ -130,12 +139,27 @@ function CollapsedRail({ onExpand }: { onExpand: () => void }) {
</nav>
<button
type="button"
title={`${user?.userId ?? ""} · ${S.nav.expandSidebar}`}
aria-label={user?.userId ?? S.auth.admin}
title={[user?.userId ?? "", S.nav.expandSidebar]
.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}
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()}
{/* 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>
</div>
);
@@ -619,7 +619,7 @@ function ChatDefaultsSection({ projectId, isOwner }: { projectId: string; isOwne
<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. */}
inline, or clear back to a temporary workspace. */}
<WorkspaceSelect
projectId={projectId}
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
* choice and each Project's group collapse and pin state persist in localStorage): by Workspace
* (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;
* 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
@@ -457,7 +457,7 @@ export function Sidebar({
* 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 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) => {
if (agentId) setCurrentAgentId(agentId);
@@ -909,7 +909,7 @@ export function Sidebar({
actions={
<>
<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
type="button"
title={S.chat.newSessionInWorkspace}
@@ -949,13 +949,22 @@ export function Sidebar({
<button
type="button"
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"
>
<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()}
{/* 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
avatar for every accent — the neutral accent matches the avatar fill. */}
newer release (the trigger button's tooltip/label above explains it). The
border (sidebar background color) separates it from the avatar for every
accent — the neutral accent matches the avatar fill. */}
{updateAvailable && (
<span
aria-hidden
@@ -21,7 +21,7 @@
* The sidebar group header "+" / menu "New conversation" explicitly specify an
* 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
* 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
* field, the Project's new-chat defaults ([default_chat]) prefill Agent / Workspace /
* 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
// 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.
const stateWorkspace = routeState?.workspace;
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
// 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"),
// 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
// already flows through its own fallback effect below — the single-sourced default).
const appliedProjectDefaults = useRef(false);
@@ -30,7 +30,7 @@ export const pillClass =
/**
* 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
* 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
@@ -157,7 +157,7 @@ export function WorkspaceSelect({
};
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 parentPath = dir?.parent ?? null;
// Hidden directories (starting with .) are excluded from the list.
@@ -327,7 +327,7 @@ export function WorkspaceSelect({
)}
</ul>
</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 && (
<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";
import type { ModelProviderInfo } from "@prismshadow/penguin-core/model-catalog";
import { groupModelRows, isFreeModel, sameModelRef, userProviderInfo } from "./model-grouping";
import {
defaultExpandedProviders,
isGroupExpanded,
toggleExpandedProvider,
} from "./model-group-expansion";
import { clearDraftModelRef } from "../chat/draft-cache";
import { syncRowsWithCatalog } from "./catalog-sync";
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. */
const [addingTo, setAddingTo] = useState<string | null>(null);
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. */
const [groupKeyFor, setGroupKeyFor] = useState<string | null>(null);
/** "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]);
/** 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
@@ -514,13 +525,15 @@ export function ModelsPage() {
if (!projectId) return null;
const toggleGroup = (id: string) =>
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
/**
* Header toggles are inert while searching: every rendered group is force-opened (see
* isGroupExpanded), so a flip would change nothing visibly and only silently mutate the
* state restored once the query clears.
*/
const toggleGroup = (id: string) => {
if (searching) return;
setExpanded((prev) => toggleExpandedProvider(prev, id));
};
return (
<div className="h-full overflow-y-auto p-4 md:p-6">
@@ -571,7 +584,7 @@ export function ModelsPage() {
) : (
<div className="space-y-3">
{groups.map((group) => {
const open = !collapsed.has(group.provider.id);
const open = isGroupExpanded(expanded, group.provider.id, searching);
return (
<section
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
* filesystem path locked in at creation (SessionInfo.workspace), so grouping works
* 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
* 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 {
SessionCategory,
@@ -15,16 +15,16 @@ import type {
SessionInfo,
} 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";
/** 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}$/;
/**
* Whether a Session's Workspace is an auto-created temp directory. An empty path
* also counts as "auto temp": the server always backfills the resolved path, so
* this is defensive only.
* Whether a Session's Workspace is an auto-created temporary workspace. An empty
* path also counts as one: the server always backfills the resolved path, so this
* is defensive only.
*/
export function isTempWorkspace(workspace: string): boolean {
const p = workspace.trim();
@@ -114,10 +114,10 @@ export interface GroupCounts {
/**
* Folds the per-Agent per-Workspace-path category counts (SessionsResponse.workspaceCounts)
* into workspace-mode groups, keyed like groupSessionsByWorkspace (exact path; auto-temp
* paths merged into the temp group). The sidebar labels a group's folders and decides its
* "More" from its own share — never from an Agent's other Workspaces, which would
* advertise folders whose content lives in other groups.
* into workspace-mode groups, keyed like groupSessionsByWorkspace (exact path;
* temporary-workspace paths merged into the temp group). The sidebar labels a group's
* folders and decides its "More" from its own share — never from an Agent's other
* Workspaces, which would advertise folders whose content lives in other groups.
*/
export function aggregateWorkspaceCounts(
byAgent: ReadonlyMap<string, Readonly<Record<string, SessionCategoryCounts>>>,
@@ -179,7 +179,7 @@ export interface WorkspaceGroup<T = SessionInfo> {
label: string;
/** Full path for tooltips; null for the merged temp group (its members' paths all differ). */
fullPath: string | null;
/** True for the merged auto-temp group. */
/** True for the merged temporary-workspace group. */
temp: boolean;
/** Member Sessions, newest first (createdAt desc). */
sessions: T[];
+6 -6
View File
@@ -187,7 +187,7 @@ export const en: Strings = {
chatDefaultsNotSet: "Not set",
chatDefaultsApprovalNotSet: "Not set (defaults to allow all)",
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. */
chatDefaultsModelHint: "Same default model as the Models page",
deleteProject: "Delete Project",
@@ -512,7 +512,7 @@ export const en: Strings = {
targetNew: "New session each time",
targetSession: "Bound Session",
sessionId: "Session id",
workspace: "Workspace (optional, auto-created when empty)",
workspace: "Workspace (optional; a temporary workspace is created when empty)",
model: "Model",
modelDefault: "Project default",
deleteTitle: "Delete scheduled task",
@@ -608,13 +608,13 @@ export const en: Strings = {
workspaceUseThis: "Use this dir",
workspaceUp: "Parent dir",
workspaceNoSubdirs: "No subdirectories",
workspaceAuto: "Auto temp directory",
workspaceClear: "Use auto temp directory instead",
workspaceAuto: "Temporary workspace",
workspaceClear: "Use a temporary workspace instead",
workspaceDirInvalid: "Directory does not exist or is inaccessible; reverted",
/** Sidebar conversation-list grouping toggle (workspace is the default) + workspace groups. */
groupByWorkspace: "Group by workspace",
groupByAgent: "Group by agent",
tempWorkspaces: "Temp workspaces",
tempWorkspaces: "Temporary workspaces",
newSessionInWorkspace: "New chat in this workspace",
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). */
@@ -740,7 +740,7 @@ Scenarios:
model: "Model",
workspace: "Workspace",
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",
approvalModeNames: {
"allow-all": "Approve everything",
+5 -5
View File
@@ -180,7 +180,7 @@ export const zh = {
chatDefaultsNotSet: "未设置",
chatDefaultsApprovalNotSet: "未设置(默认全部放行)",
chatDefaultsThinkingNotSet: "未设置(跟随智能体配置)",
chatDefaultsWorkspaceHint: "留空表示自动临时目录",
chatDefaultsWorkspaceHint: "留空表示使用临时工作区",
/** 模型默认值与模型页同源(同一个 default_model),此处仅是另一处入口。 */
chatDefaultsModelHint: "与模型页的默认模型同步",
deleteProject: "删除 Project",
@@ -490,7 +490,7 @@ export const zh = {
targetNew: "每次新建会话",
targetSession: "绑定 Session",
sessionId: "Session id",
workspace: "Workspace(可选,留空自动创建)",
workspace: "Workspace(可选,留空自动创建临时工作区)",
model: "Model",
modelDefault: "Project 默认",
deleteTitle: "删除定时任务",
@@ -585,8 +585,8 @@ export const zh = {
workspaceUseThis: "使用此目录",
workspaceUp: "上级目录",
workspaceNoSubdirs: "无子目录",
workspaceAuto: "自动临时目录",
workspaceClear: "改用自动临时目录",
workspaceAuto: "临时工作区",
workspaceClear: "改用临时工作区",
workspaceDirInvalid: "目录不存在或无法访问,已回退",
/** 侧栏对话列表的分组切换(默认按工作区)与工作区分组。 */
groupByWorkspace: "按工作区分组",
@@ -717,7 +717,7 @@ Benchmark:
defaultSessionTitle: "新对话",
model: "Model",
workspace: "Workspace",
workspaceHint: "留空自动创建临时目录;指定时必须是服务器上已存在的目录",
workspaceHint: "留空自动创建临时工作区;指定时必须是服务器上已存在的目录",
approvalMode: "审批模式",
/** Short description (the trigger button shows only the description, not the mode id). */
approvalModeNames: {
+15 -6
View File
@@ -20,14 +20,21 @@ let updateCache: UpdateCheckResponse | null = null;
let updatePromise: Promise<UpdateCheckResponse> | null = null;
/**
* Mounted hooks subscribe here so forceUpdateCheck (the sidebar's manual "check for
* updates" action) can push its fresh result to every consumer at once — the footer,
* the update dot, the reminder rows, and the draft page's version line all react
* without a remount. The lazy fetch path doesn't need this (each hook awaits the
* shared promise itself); only an out-of-band refresh does.
* Mounted hooks subscribe here so any refresh of the module cache reaches every
* consumer at once — the footer, the update dots, the reminder rows, and the draft
* page's version line all react without a remount. Two paths push: forceUpdateCheck
* (the sidebar's manual "check for updates" action) and the lazy fetch resolving.
* 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>();
/** 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. */
export type UpdateCheckOutcome =
| { kind: "disabled" }
@@ -79,6 +86,7 @@ export function useVersionInfo(active: boolean): VersionInfo {
versionPromise ??= api.getVersion().then((res) => {
versionCache = res;
notifyAll();
return res;
});
versionPromise
@@ -91,6 +99,7 @@ export function useVersionInfo(active: boolean): VersionInfo {
updatePromise ??= api.checkUpdate().then((res) => {
updateCache = res;
notifyAll();
return res;
});
updatePromise
@@ -130,6 +139,6 @@ export async function forceUpdateCheck(): Promise<UpdateCheckResponse> {
if (updatePromise === promise) updatePromise = null;
throw e;
} 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):
* - named Workspaces group by exact path, labeled by basename (full path kept for
* 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
* 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
@@ -58,7 +58,7 @@ function session(
const TEMP_A = "/data/proj/agents/default_agent/workspaces/tmp-1a2b3c4d";
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", () => {
expect(isTempWorkspace(TEMP_A)).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-1a2b3c4")).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);
});
});
@@ -115,7 +115,7 @@ describe("groupSessionsByWorkspace", () => {
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 newAlpha = session("/srv/alpha", "2026-07-06T10: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);
});
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([
[
"agent_a",