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:
@@ -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 {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user