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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user