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
@@ -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();
}
}