feat(traces): mirror sidebar grouping (workspace mode, category folders, shared components), paginate sessions, title fallback to first prompt (#183)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-08-04 23:07:06 +08:00
committed by GitHub
parent 89435bf62a
commit ab388b4f8c
30 changed files with 3144 additions and 718 deletions
+21 -2
View File
@@ -228,9 +228,28 @@ export const resetAgentConfig = (projectId: string, agentId: string) =>
{ method: "POST" },
);
export const getAgentTraces = (projectId: string, agentId: string) =>
/**
* Optional paging (absent = the legacy full date-grouped response): pages Session groups
* newest-first; a paged response answers with `sessions` (titles + category/workspace),
* `totalSessions`, and per-category `counts` / `workspaceCounts`. `category` filters to
* one sidebar bucket (paging applies within it, mirroring the sessions list); `cli`
* includes CLI-origin Sessions (the "show CLI sessions" preference, default off — same
* parameter convention as listSessions). The Trace page requests `limit+1` per page to
* detect "has more" (splitPage).
*/
export const getAgentTraces = (
projectId: string,
agentId: string,
paging?: { offset: number; limit: number; category?: SessionCategory; cli?: boolean },
) =>
apiFetch<AgentTracesResponse>(
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}/traces`,
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}/traces${
paging
? `?limit=${paging.limit}&offset=${paging.offset}` +
(paging.category ? `&category=${paging.category}` : "") +
(paging.cli ? "&cli=1" : "")
: ""
}`,
);
// Session ---------------------------------------------------------------------
@@ -13,7 +13,8 @@ import { useProject } from "../../state/project";
import { useSessions } from "../../state/sessions";
import { Drawer } from "../ui/drawer";
import { GlyphIcon } from "../ui/glyph-icon";
import { NAV_ICONS, NEW_CHAT_ICON, Sidebar } from "./sidebar";
import { NAV_ICONS } from "../ui/icons";
import { NEW_CHAT_ICON, Sidebar } from "./sidebar";
import { DRAFT_SESSION_ID } from "../../features/chat/chat-page";
import { ChangePasswordDialog } from "../account/change-password-dialog";
+101 -196
View File
@@ -49,8 +49,19 @@ import type { FolderCategory, SessionPartition } from "../../lib/session-groupin
import { Switch } from "../ui/switch";
import { Dropdown } from "../ui/dropdown";
import { AgentAvatar } from "../ui/agent-avatar";
import { Chevron } from "../ui/chevron";
import { ChevronDown } from "../ui/icons";
import { ChevronDown, NAV_ICONS } from "../ui/icons";
import {
FOLDER_ICON,
FOLDER_OPEN_ICON,
FolderSection,
GroupHeader,
GroupModeToggle,
Icon,
MoreRow,
initialGroupMode,
storeGroupMode,
} from "../ui/group-list";
import type { GroupMode } from "../ui/group-list";
import { toastError, toastInfo, toastSuccess } from "../ui/toast";
import { Truncated } from "../ui/truncated";
import { Badge } from "../ui/badge";
@@ -67,37 +78,6 @@ import { ChangePasswordDialog } from "../account/change-password-dialog";
import { UpdateDialog } from "../account/update-dialog";
import { forceUpdateCheck, updateCheckOutcome, useVersionInfo } from "../../lib/use-version-info";
function Icon({ d, size = 16 }: { d: string; size?: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d={d} />
</svg>
);
}
/** Page-nav glyphs (shared with the collapsed rail in app-layout.tsx). */
export const NAV_ICONS = {
agents: "M12 3v3m-6 4a6 6 0 0 1 12 0v5a3 3 0 0 1-3 3H9a3 3 0 0 1-3-3v-5zm3 3h.01M15 13h.01",
/** Skill library (an open book: two pages + spine). */
skills: "M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2zM22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z",
models: "M7 7h10v10H7zM4 10h3m10 0h3M4 14h3m10 0h3M10 4v3m4-3v3m-4 10v3m4-3v3",
usage: "M4 20V10m6 10V4m6 16v-7m4 7H2",
traces: "M4 6h16M4 12h10M4 18h13",
/** Benchmark center (a trophy: cup + two handles + base). */
benchmark:
"M7 4h10v5a5 5 0 0 1-10 0V4zM7 5H4v1a3 3 0 0 0 3 3m10-4h3v1a3 3 0 0 1-3 3M12 14v4m-4 0h8",
} as const;
/** New-chat pencil (the pinned "New chat" button and the collapsed rail share it). */
export const NEW_CHAT_ICON = "M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z";
@@ -105,13 +85,6 @@ export const NEW_CHAT_ICON = "M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4L
const GEAR_ICON =
"M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2zM15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0z";
/** Folder outline, closed (same glyph as the draft page's Workspace pill); collapsed workspace groups and the grouping toggle use it. */
const FOLDER_ICON = "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z";
/** Folder outline, open (lucide folder-open: back panel + tilted front flap); expanded workspace groups use it. */
const FOLDER_OPEN_ICON =
"m6 14 1.45-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.55 6a2 2 0 0 1-1.94 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2";
/** Pushpin (lucide pin: head + body + stem), the group-header pin toggle / pinned indicator. */
const PIN_ICON =
"M12 17v5M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1z";
@@ -119,13 +92,6 @@ const PIN_ICON =
const menuItemClass =
"block w-full px-3.5 py-2 text-left text-sm transition-colors duration-150 hover:bg-gray-100 dark:hover:bg-gray-800";
/** Grouping mode of the Session list (persisted; Workspace is the default). */
type GroupMode = "workspace" | "agent";
const GROUP_MODE_KEY = "penguin.sidebarGroupMode";
function initialGroupMode(): GroupMode {
return localStorage.getItem(GROUP_MODE_KEY) === "agent" ? "agent" : "workspace";
}
/**
* Collapsed-group and pinned-group persistence (survives a refresh), one storage key
* per Project and concern — group keys are Agent ids / Workspace paths, which are
@@ -305,7 +271,7 @@ export function Sidebar({
const [renameError, setRenameError] = useState<string | null>(null);
const setGroupMode = (mode: GroupMode) => {
localStorage.setItem(GROUP_MODE_KEY, mode);
storeGroupMode(mode);
setGroupModeState(mode);
// The two modes have unrelated group lists: restart the reveal window.
setGroupCap(SIDEBAR_GROUP_PAGE_SIZE);
@@ -543,9 +509,6 @@ export function Sidebar({
</ul>
);
const folderClass =
"flex w-full items-center gap-1 rounded px-1.5 py-1 text-left text-[11px] font-medium text-gray-400 transition-colors duration-150 hover:bg-gray-200/50 dark:text-gray-500 dark:hover:bg-gray-800/50";
/**
* Collapsed-by-default lazy folder (subagent / scheduled / archived): nothing is
* fetched until the first expand, and once open the folder pages independently with
@@ -553,7 +516,11 @@ export function Sidebar({
* (`totals` — the Agent's counts in agent mode, the per-Workspace fold in workspace
* mode): the folder exists only while its share is non-zero, the label shows that
* share, and "More" shows only while loaded rows fall short of it — an Agent's
* content in *other* Workspaces can never surface a folder here.
* content in *other* Workspaces can never surface a folder here. The folder's "More"
* pages independently of the active list's; in workspace mode a fetched page can land
* rows in other groups' folders too, so one click may grow this folder by fewer than
* a full page — the row shows a loading state while the fetch runs and stays until
* this group's share is fully loaded.
*/
const renderFolder = (
groupKey: string,
@@ -568,39 +535,21 @@ export function Sidebar({
// Loaded rows win a disagreement with the totals (counts refresh only on reload).
const total = Math.max(totals?.[category] ?? 0, rows.length);
if (total === 0) return null;
const open = openFolders.has(folderKey(groupKey, category));
// More while the group's share isn't fully loaded AND somewhere is left to fetch from
// (counts drifting above reality would otherwise leave a dead button until reload).
const more = rows.length < total && agentIds.some((id) => hasMoreFor(id, category));
const pending = pendingLoads.has(loadKey(groupKey, category));
return (
<div key={category} className="mt-1">
<button
type="button"
onClick={() => toggleFolder(groupKey, category, agentIds)}
className={folderClass}
>
<Chevron open={open} size={12} />
{S.chat.folderGroups[category](total)}
</button>
{open && renderRows(rows, withAgentHint)}
{/* The folder's own paging, independent of the active list's "More". In workspace
mode a fetched page can land rows in other groups' folders too, so one click may
grow this folder by fewer than a full page — the row shows a loading state while
the fetch runs and stays until this group's share is fully loaded. */}
{open && more && (
<button
type="button"
aria-label={S.chat.loadMore}
disabled={pending}
onClick={() => trackedLoadMore(groupKey, category, agentIds)}
className={`${folderClass} disabled:opacity-60`}
>
<span className="w-3" aria-hidden />
{pending ? S.common.loading : S.chat.loadMore}
</button>
)}
</div>
<FolderSection
key={category}
label={S.chat.folderGroups[category](total)}
open={openFolders.has(folderKey(groupKey, category))}
onToggle={() => toggleFolder(groupKey, category, agentIds)}
more={more}
pending={pendingLoads.has(loadKey(groupKey, category))}
onMore={() => trackedLoadMore(groupKey, category, agentIds)}
>
{renderRows(rows, withAgentHint)}
</FolderSection>
);
};
@@ -654,16 +603,12 @@ export function Sidebar({
{/* Load/reveal more (kept adjacent to the active list it extends, above the folders) */}
{hasMore && (
<button
type="button"
aria-label={S.chat.loadMore}
disabled={activePending}
<MoreRow
label={S.chat.loadMore}
pending={activePending}
onClick={() => showMore(groupKey, activeAgents)}
className={`${folderClass} mt-0.5 disabled:opacity-60`}
>
<span className="w-3" aria-hidden />
{activePending ? S.common.loading : S.chat.loadMore}
</button>
className="mt-0.5"
/>
)}
{/* Folders (collapsed by default): subagent first — spawned from the conversations
@@ -676,14 +621,11 @@ export function Sidebar({
/** Reveal-next-page-of-groups row (render cap only — data loading is untouched). */
const moreGroupsRow = (total: number) => (
<button
type="button"
<MoreRow
label={S.chat.moreGroups(total - groupCap)}
onClick={() => setGroupCap((c) => c + SIDEBAR_GROUP_PAGE_SIZE)}
className={`${folderClass} mt-1`}
>
<span className="w-3" aria-hidden />
{S.chat.moreGroups(total - groupCap)}
</button>
className="mt-1"
/>
);
const navItems: Array<{ to: string; label: string; icon: string }> = [
@@ -860,30 +802,7 @@ export function Sidebar({
<span className="px-1 text-[11px] font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">
{S.chat.sessionList}
</span>
<div className="flex items-center gap-0.5">
{(
[
{ value: "workspace", icon: FOLDER_ICON, label: S.chat.groupByWorkspace },
{ value: "agent", icon: NAV_ICONS.agents, label: S.chat.groupByAgent },
] as const
).map((opt) => (
<button
key={opt.value}
type="button"
title={opt.label}
aria-label={opt.label}
aria-pressed={groupMode === opt.value}
onClick={() => setGroupMode(opt.value)}
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-md transition-colors duration-150 ${
groupMode === opt.value
? "bg-gray-200/70 text-gray-700 dark:bg-gray-800 dark:text-gray-200"
: "text-gray-400 hover:bg-gray-200/50 hover:text-gray-700 dark:text-gray-500 dark:hover:bg-gray-800/70 dark:hover:text-gray-300"
}`}
>
<Icon d={opt.icon} size={14} />
</button>
))}
</div>
<GroupModeToggle value={groupMode} onChange={setGroupMode} />
</div>
{groupMode === "agent" ? (
@@ -896,50 +815,44 @@ export function Sidebar({
const pinned = pinnedGroups.has(agent.agentId);
return (
<div key={agent.agentId} className="pt-2.5">
{/* Group header: collapse toggle (Agent name) + pin + new chat + Agent settings.
self-stretch makes the collapse toggle's hover pill span the full row height
set by the h-7 action buttons (one consistent hover geometry). */}
<div className="group/header flex items-center gap-0.5 px-1 pb-0.5">
<button
type="button"
onClick={() => toggleGroup(agent.agentId)}
aria-expanded={!collapsed}
aria-label={collapsed ? S.nav.expandGroup : S.nav.collapseGroup}
className="flex min-w-0 flex-1 items-center gap-1 self-stretch rounded px-1 py-0.5 text-left transition-colors duration-150 hover:bg-gray-200/50 dark:hover:bg-gray-800/50"
>
{/* Group header: collapse toggle (Agent name) + pin + new chat + Agent settings. */}
<GroupHeader
open={!collapsed}
onToggle={() => toggleGroup(agent.agentId)}
icon={
<AgentAvatar
id={agent.agentId}
name={agentDisplayName(agent)}
size={18}
className="shrink-0 rounded"
/>
<span className="min-w-0 truncate text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{agentDisplayName(agent)}
</span>
{/* Expand/collapse indicator sits right after the Agent name */}
<Chevron open={!collapsed} size={12} className="text-gray-400" />
<span className="min-w-0 flex-1" />
</button>
<GroupPinButton pinned={pinned} onToggle={() => togglePin(agent.agentId)} />
{/* New chat: enters draft state directly with this group's Agent (all options live on the draft input card) */}
<button
type="button"
title={S.chat.newSessionMenu}
aria-label={S.chat.newSessionMenu}
onClick={() => newChat(agent.agentId)}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-gray-400 transition-colors duration-150 hover:bg-gray-200/70 hover:text-gray-800 dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-gray-200"
>
<Icon d="M12 5v14M5 12h14" size={18} />
</button>
<button
type="button"
title={S.agent.settings}
onClick={() => go(`/agents/${agent.agentId}`)}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-gray-400 transition-colors duration-150 hover:bg-gray-200/70 hover:text-gray-800 dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-gray-200"
>
<Icon d={GEAR_ICON} size={16} />
</button>
</div>
}
label={agentDisplayName(agent)}
uppercase
actions={
<>
<GroupPinButton pinned={pinned} onToggle={() => togglePin(agent.agentId)} />
{/* New chat: enters draft state directly with this group's Agent (all options live on the draft input card) */}
<button
type="button"
title={S.chat.newSessionMenu}
aria-label={S.chat.newSessionMenu}
onClick={() => newChat(agent.agentId)}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-gray-400 transition-colors duration-150 hover:bg-gray-200/70 hover:text-gray-800 dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-gray-200"
>
<Icon d="M12 5v14M5 12h14" size={18} />
</button>
<button
type="button"
title={S.agent.settings}
onClick={() => go(`/agents/${agent.agentId}`)}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-gray-400 transition-colors duration-150 hover:bg-gray-200/70 hover:text-gray-800 dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-gray-200"
>
<Icon d={GEAR_ICON} size={16} />
</button>
</>
}
/>
{collapsed
? null
@@ -977,46 +890,38 @@ export function Sidebar({
];
return (
<div key={group.key} className="pt-2.5">
{/* Group header: collapse toggle (folder icon + directory basename + count, full path in the tooltip) + pin + new chat in this Workspace.
self-stretch: without it the collapse toggle's hover pill is content-sized
(~20px) and sits visibly shorter than the h-7 action buttons beside it. */}
<div className="group/header flex items-center gap-0.5 px-1 pb-0.5">
<button
type="button"
onClick={() => toggleGroup(group.key)}
aria-expanded={!collapsed}
aria-label={collapsed ? S.nav.expandGroup : S.nav.collapseGroup}
{...(group.fullPath !== null ? { title: group.fullPath } : {})}
className="flex min-w-0 flex-1 items-center gap-1 self-stretch rounded px-1 py-0.5 text-left transition-colors duration-150 hover:bg-gray-200/50 dark:hover:bg-gray-800/50"
>
{/* Folder opens and closes with the group */}
{/* Group header: collapse toggle (folder icon + directory basename + count, full
path in the tooltip; the count = the group's active conversations only, exact
server share, loaded rows win a disagreement — the folders never feed it) +
pin + new chat in this Workspace. */}
<GroupHeader
open={!collapsed}
onToggle={() => toggleGroup(group.key)}
icon={
/* Folder opens and closes with the group */
<span className="shrink-0 text-gray-400 dark:text-gray-500">
<Icon d={collapsed ? FOLDER_ICON : FOLDER_OPEN_ICON} size={15} />
</span>
{/* No uppercase transform: a directory basename's casing is meaningful */}
<span className="min-w-0 truncate text-xs font-semibold text-gray-500 dark:text-gray-400">
{group.temp ? S.chat.tempWorkspaces : group.label}
</span>
{/* Header count = the group's active conversations only (exact server share;
loaded rows win a disagreement) — the folders never feed it. */}
<span className="shrink-0 text-[11px] text-gray-400 dark:text-gray-500">
{Math.max(counts?.totals.active ?? 0, parts.active.length)}
</span>
<Chevron open={!collapsed} size={12} className="text-gray-400" />
<span className="min-w-0 flex-1" />
</button>
<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 */}
<button
type="button"
title={S.chat.newSessionInWorkspace}
aria-label={S.chat.newSessionInWorkspace}
onClick={() => newChat(workspaceNewChatAgentId, group.fullPath ?? "")}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-gray-400 transition-colors duration-150 hover:bg-gray-200/70 hover:text-gray-800 dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-gray-200"
>
<Icon d="M12 5v14M5 12h14" size={18} />
</button>
</div>
}
label={group.temp ? S.chat.tempWorkspaces : group.label}
count={Math.max(counts?.totals.active ?? 0, parts.active.length)}
{...(group.fullPath !== null ? { title: group.fullPath } : {})}
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 */}
<button
type="button"
title={S.chat.newSessionInWorkspace}
aria-label={S.chat.newSessionInWorkspace}
onClick={() => newChat(workspaceNewChatAgentId, group.fullPath ?? "")}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-gray-400 transition-colors duration-150 hover:bg-gray-200/70 hover:text-gray-800 dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-gray-200"
>
<Icon d="M12 5v14M5 12h14" size={18} />
</button>
</>
}
/>
{/* A workspace group can span Agents: the group body fans folder loads and "More"
out per category to the Agents whose share of THIS group is non-zero (plus the
@@ -0,0 +1,230 @@
/**
* Shared building blocks of the grouped Session lists (the chat sidebar and the Trace
* page's directory tree render the same structure — group header, collapsed lazy
* folders, "More" rows, the Workspace/Agent grouping toggle). Extracted verbatim from
* sidebar.tsx's inner closures so the two surfaces cannot drift apart visually: the
* markup and classes here ARE the sidebar's; callers pass the state the closures used
* to capture.
*/
import type { ReactNode } from "react";
import { S } from "../../lib/strings";
import { Chevron } from "./chevron";
/** Minimal stroke-icon wrapper shared by the grouped lists (moved from sidebar.tsx). */
export function Icon({ d, size = 16 }: { d: string; size?: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d={d} />
</svg>
);
}
/** Folder outline, closed (same glyph as the draft page's Workspace pill); collapsed workspace groups and the grouping toggle use it. */
export const FOLDER_ICON =
"M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z";
/** Folder outline, open (lucide folder-open: back panel + tilted front flap); expanded workspace groups use it. */
export const FOLDER_OPEN_ICON =
"m6 14 1.45-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.55 6a2 2 0 0 1-1.94 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2";
/** Agent glyph (the grouping toggle's "by Agent" option; also NAV_ICONS.agents in the sidebar nav). */
export const AGENT_GROUP_ICON =
"M12 3v3m-6 4a6 6 0 0 1 12 0v5a3 3 0 0 1-3 3H9a3 3 0 0 1-3-3v-5zm3 3h.01M15 13h.01";
/** Grouping mode of a Session list (persisted; Workspace is the default). */
export type GroupMode = "workspace" | "agent";
/**
* One storage key for every grouped-list surface (sidebar + Trace page): the grouping
* choice is a single user preference, not a per-page one — switching it anywhere
* switches it everywhere.
*/
const GROUP_MODE_KEY = "penguin.sidebarGroupMode";
export function initialGroupMode(): GroupMode {
return localStorage.getItem(GROUP_MODE_KEY) === "agent" ? "agent" : "workspace";
}
export function storeGroupMode(mode: GroupMode): void {
localStorage.setItem(GROUP_MODE_KEY, mode);
}
/** The two-icon Workspace/Agent grouping toggle (the section header's mode switch). */
export function GroupModeToggle({
value,
onChange,
}: {
value: GroupMode;
onChange: (mode: GroupMode) => void;
}) {
return (
<div className="flex items-center gap-0.5">
{(
[
{ value: "workspace", icon: FOLDER_ICON, label: S.chat.groupByWorkspace },
{ value: "agent", icon: AGENT_GROUP_ICON, label: S.chat.groupByAgent },
] as const
).map((opt) => (
<button
key={opt.value}
type="button"
title={opt.label}
aria-label={opt.label}
aria-pressed={value === opt.value}
onClick={() => onChange(opt.value)}
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-md transition-colors duration-150 ${
value === opt.value
? "bg-gray-200/70 text-gray-700 dark:bg-gray-800 dark:text-gray-200"
: "text-gray-400 hover:bg-gray-200/50 hover:text-gray-700 dark:text-gray-500 dark:hover:bg-gray-800/70 dark:hover:text-gray-300"
}`}
>
<Icon d={opt.icon} size={14} />
</button>
))}
</div>
);
}
/** Row class of folder toggles and "More" rows (the sidebar's folderClass). */
export const FOLDER_ROW_CLASS =
"flex w-full items-center gap-1 rounded px-1.5 py-1 text-left text-[11px] font-medium text-gray-400 transition-colors duration-150 hover:bg-gray-200/50 dark:text-gray-500 dark:hover:bg-gray-800/50";
/**
* "More"-style row (a group's load-next-page, a folder's paging, the reveal-more-groups
* cap): folder-row styling with the chevron column left blank. While `pending` the row
* disables and reads the shared loading label.
*/
export function MoreRow({
label,
pending = false,
onClick,
className,
}: {
label: string;
/** A fetch is in flight: disable and show the loading label. */
pending?: boolean;
onClick: () => void;
/** Extra spacing classes (the active list's row adds mt-0.5, the groups row mt-1). */
className?: string;
}) {
return (
<button
type="button"
aria-label={S.chat.loadMore}
disabled={pending}
onClick={onClick}
className={`${FOLDER_ROW_CLASS}${className ? ` ${className}` : ""} disabled:opacity-60`}
>
<span className="w-3" aria-hidden />
{pending ? S.common.loading : label}
</button>
);
}
/**
* Collapsed-by-default lazy folder (subagent / scheduled / archived): the toggle row
* shows the label (typically with the group's exact server share), the body renders only
* while open, and an optional "More" row pages the folder independently.
*/
export function FolderSection({
label,
open,
onToggle,
more = false,
pending = false,
onMore,
children,
}: {
label: string;
open: boolean;
onToggle: () => void;
/** Show the folder's own "More" row (its share isn't fully loaded and somewhere is left to fetch from). */
more?: boolean;
/** The folder's "More" fetch in flight. */
pending?: boolean;
onMore?: () => void;
children?: ReactNode;
}) {
return (
<div className="mt-1">
<button type="button" onClick={onToggle} className={FOLDER_ROW_CLASS}>
<Chevron open={open} size={12} />
{label}
</button>
{open && children}
{open && more && (
<MoreRow label={S.chat.loadMore} pending={pending} onClick={() => onMore?.()} />
)}
</div>
);
}
/**
* Group header row: the collapse toggle (leading icon + label + optional count +
* chevron) stretching across, with optional action buttons trailing outside it. The
* toggle's hover pill spans the full row height set by any h-7 actions (self-stretch —
* see the sidebar's header comments for where this first bit).
*/
export function GroupHeader({
open,
onToggle,
icon,
label,
uppercase = false,
count,
title,
actions,
}: {
open: boolean;
onToggle: () => void;
/** Leading visual (Agent avatar / folder icon), sized by the caller. */
icon: ReactNode;
label: string;
/** Agent names render uppercase-tracked (sidebar convention); a directory basename's casing is meaningful, so workspace groups don't. */
uppercase?: boolean;
/** Optional trailing count (workspace groups: the group's active total). */
count?: number;
/** Optional tooltip (workspace groups: the full path). */
title?: string;
/** Trailing header actions (pin / new chat / settings / import). */
actions?: ReactNode;
}) {
return (
<div className="group/header flex items-center gap-0.5 px-1 pb-0.5">
<button
type="button"
onClick={onToggle}
aria-expanded={open}
aria-label={open ? S.nav.collapseGroup : S.nav.expandGroup}
{...(title !== undefined ? { title } : {})}
className="flex min-w-0 flex-1 items-center gap-1 self-stretch rounded px-1 py-0.5 text-left transition-colors duration-150 hover:bg-gray-200/50 dark:hover:bg-gray-800/50"
>
{icon}
<span
className={`min-w-0 truncate text-xs font-semibold ${
uppercase ? "uppercase tracking-wide " : ""
}text-gray-500 dark:text-gray-400`}
>
{label}
</span>
{count !== undefined && (
<span className="shrink-0 text-[11px] text-gray-400 dark:text-gray-500">{count}</span>
)}
{/* Expand/collapse indicator sits right after the label */}
<Chevron open={open} size={12} className="text-gray-400" />
<span className="min-w-0 flex-1" />
</button>
{actions}
</div>
);
}
+19
View File
@@ -5,6 +5,7 @@
*/
import type { ButtonHTMLAttributes } from "react";
import { S } from "../../lib/strings";
import { AGENT_GROUP_ICON } from "./group-list";
/** Downward caret on Select / OptionMenu / composer dropdown triggers. Color follows currentColor (callers add text-gray-400). */
export function ChevronDown({ size = 12, className = "" }: { size?: number; className?: string }) {
@@ -141,3 +142,21 @@ export function CloseButton({
</button>
);
}
/**
* Page-nav glyphs (moved from sidebar.tsx: the sidebar nav, the collapsed rail in
* app-layout.tsx, and cross-page jump actions — e.g. the chat info dropdown's "view
* trace" — share them; living here keeps chat-page free of a sidebar import cycle,
* sidebar.tsx importing DRAFT_SESSION_ID from chat-page).
*/
export const NAV_ICONS = {
agents: AGENT_GROUP_ICON,
/** Skill library (an open book: two pages + spine). */
skills: "M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2zM22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z",
models: "M7 7h10v10H7zM4 10h3m10 0h3M4 14h3m10 0h3M10 4v3m4-3v3m-4 10v3m4-3v3",
usage: "M4 20V10m6 10V4m6 16v-7m4 7H2",
traces: "M4 6h16M4 12h10M4 18h13",
/** Benchmark center (a trophy: cup + two handles + base). */
benchmark:
"M7 4h10v5a5 5 0 0 1-10 0V4zM7 5H4v1a3 3 0 0 0 3 3m10-4h3v1a3 3 0 0 1-3 3M12 14v4m-4 0h8",
} as const;
@@ -51,6 +51,7 @@ import { Skeleton } from "../../components/ui/skeleton";
import { Truncated } from "../../components/ui/truncated";
import { Dropdown } from "../../components/ui/dropdown";
import { EmptyState } from "../../components/ui/empty-state";
import { NAV_ICONS } from "../../components/ui/icons";
import { toastError } from "../../components/ui/toast";
import { MessageStream } from "./message-stream";
import type { StreamRenderContext } from "./message-stream";
@@ -1157,6 +1158,39 @@ export function ChatPage() {
</p>
</div>
</div>
{/* Jump to this Session's Trace: SPA-navigates to the Trace page deep-linked to
the owning Agent AND this Session (?agentId= focuses/expands the Agent group,
?sessionId= auto-selects — a Session beyond the first loaded page resolves via
the Trace page's full-fetch fallback). Only reachable for a real Session: this
whole header renders behind the `selected` guard, so a draft never shows it. */}
<div className="border-t border-gray-100 py-1 dark:border-gray-800">
<button
type="button"
onClick={() => {
setInfoOpen(false);
navigate(
`/traces?agentId=${encodeURIComponent(selected.agentId)}&sessionId=${encodeURIComponent(selected.sessionId)}`,
);
}}
className="flex w-full items-center gap-2 px-3.5 py-2 text-left text-sm transition-colors duration-150 hover:bg-gray-100 dark:hover:bg-gray-800"
>
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
className="shrink-0 text-gray-400 dark:text-gray-500"
>
<path d={NAV_ICONS.traces} />
</svg>
{S.chat.viewTrace}
</button>
</div>
</Dropdown>
</div>
)}
@@ -0,0 +1,104 @@
/**
* Pure Session-list logic for the Trace page (kept out of the components so it is unit
* testable): mapping either server response shape to the page's Session groups, and
* partitioning rows by their server-classified category.
*/
import type { AgentTracesResponse, SessionCategory } from "@prismshadow/penguin-server/api";
/**
* Trace page sessions fetched per (group, category) page, and the per-group display cap
* step for active rows — the Trace-tree counterpart of the sidebar's SIDEBAR_PAGE_SIZE,
* deliberately larger: the sidebar is a narrow column, while the Trace tree is a
* full-height page column, so 10-row pages felt stingy there. Fetches use
* limit = TRACES_PAGE_SIZE + 1 (splitPage) exactly like the sidebar; folder pages use
* the same size, so every "More" on this page steps uniformly.
*/
export const TRACES_PAGE_SIZE = 20;
/**
* Groups (Agents / Workspace groups) rendered per Trace page "page" (the sidebar's
* SIDEBAR_GROUP_PAGE_SIZE counterpart, modestly larger for the same reason as above);
* "more groups" raises it by one page — a pure display cap, data loading untouched.
*/
export const TRACES_GROUP_PAGE_SIZE = 15;
export interface TraceFileRef {
index: number;
date: string;
sizeBytes: number;
}
export interface TraceSessionGroup {
sessionId: string;
/** Server-resolved display title (DB title or first-prompt fallback); absent for legacy responses / untitled Sessions. */
title?: string;
/**
* Server-classified sidebar category (the paged endpoint's bounded classification —
* the same value its filter and counts used, so rows and buckets can't disagree).
* Legacy responses carry no classification; the mapper defaults to "active".
*/
category: SessionCategory;
/** Workspace path ("" = unknown → the merged temp group, same defensive rule as the sidebar's isTempWorkspace). */
workspace: string;
/** Sorted newest first (a higher index is newer) — the page's display order. */
files: TraceFileRef[];
}
/** A pooled row of the Trace tree: the group plus its owning Agent (the fetch that produced it). */
export interface TraceSessionRow extends TraceSessionGroup {
agentId: string;
}
/**
* Maps a listing response to Session groups, newest Session first. A paged response
* (`sessions` present) is already session-centric and server-ordered; a legacy full
* response is flattened from its date grouping (merging one Session's files across
* dates) and sorted by sessionId descending — ids embed a timestamp, so that is
* reverse chronological, matching the server's paged ordering.
*/
export function toSessionGroups(data: AgentTracesResponse): TraceSessionGroup[] {
if (data.sessions) {
return data.sessions.map((s) => ({
sessionId: s.sessionId,
...(s.title !== undefined ? { title: s.title } : {}),
category: s.category,
workspace: s.workspace,
files: [...s.files].sort((a, b) => b.index - a.index),
}));
}
const bySession = new Map<string, TraceFileRef[]>();
for (const d of data.dates) {
for (const s of d.sessions) {
const list = bySession.get(s.sessionId) ?? [];
for (const f of s.files) list.push({ index: f.index, date: d.date, sizeBytes: f.sizeBytes });
bySession.set(s.sessionId, list);
}
}
return [...bySession.entries()]
.sort((a, b) => b[0].localeCompare(a[0]))
.map(([sessionId, files]) => ({
sessionId,
category: "active" as const,
workspace: "",
files: files.sort((a, b) => b.index - a.index),
}));
}
/**
* Partition of one group's rows by their server-classified category — the Trace-side
* twin of session-grouping's partitionSessions (which derives the category client-side
* from SessionInfo; here the server field is authoritative, because it is what the
* per-category fetches and counts were computed from).
*/
export function partitionTraceRows<T extends { category: SessionCategory }>(
rows: readonly T[],
): Record<SessionCategory, T[]> {
const parts: Record<SessionCategory, T[]> = {
active: [],
subagent: [],
schedule: [],
archived: [],
};
for (const r of rows) parts[r.category].push(r);
return parts;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,324 @@
/**
* Data layer of the Trace page's directory tree — the traces-endpoint twin of the
* sessions store (state/sessions.tsx): paged per (Agent, category) against
* GET .../traces with limit+1 pages (splitPage), pooling rows by sessionId. Later
* fetches overwrite pooled rows: the server's bounded classification refines as its
* head-read caches warm up, and an overwrite moves a row into its true category
* bucket. Per-category totals and per-Workspace counts ride along on every paged
* response (no separate counts request).
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { SessionCategory, SessionCategoryCounts } from "@prismshadow/penguin-server/api";
import * as api from "../../api/endpoints";
import { apiErrorText } from "../../lib/api-error";
import { FOLDER_CATEGORIES, splitPage } from "../../lib/session-grouping";
import { TRACES_PAGE_SIZE, toSessionGroups } from "./trace-sessions";
import type { TraceSessionRow } from "./trace-sessions";
/** Page-state key of one (Agent, category) pair ("\0" never appears in Agent ids). */
const pairKey = (agentId: string, category: SessionCategory) => `${agentId}\0${category}`;
/** One pair's paging cursor (same convention as the sessions store: `fetched` counts rows consumed from the server's category stream). */
interface PagePosition {
hasMore: boolean;
fetched: number;
}
/** One fetched page, ready to merge. */
interface FetchedPage {
agentId: string;
category: SessionCategory;
items: TraceSessionRow[];
hasMore: boolean;
counts?: SessionCategoryCounts;
workspaceCounts?: Readonly<Record<string, SessionCategoryCounts>>;
}
export interface TraceTree {
/** agentId → its pooled rows (every fetched category), newest first by sessionId. */
rowsByAgent: ReadonlyMap<string, TraceSessionRow[]>;
/** All pooled rows (workspace-mode grouping input). */
allRows: TraceSessionRow[];
/** agentId → per-category totals over ALL of the Agent's Trace session groups. */
countsByAgent: ReadonlyMap<string, SessionCategoryCounts>;
/** agentId → the same totals broken down by Workspace path (workspace-mode group headers). */
workspaceCountsByAgent: ReadonlyMap<string, Readonly<Record<string, SessionCategoryCounts>>>;
/** agentId → active-first-page load failure (inline tree error, mirroring the load-state-with-content rule). */
errorByAgent: ReadonlyMap<string, string>;
isLoadedFor: (agentId: string, category: SessionCategory) => boolean;
/** Whether the pair's active-first-page fetch is in flight (skeleton/loading states). */
isPendingFor: (agentId: string, category: SessionCategory) => boolean;
/** Whether the server still holds unfetched groups of a category — an unloaded pair answers from the counts. */
hasMoreFor: (agentId: string, category: SessionCategory) => boolean;
/** Fetches the active first page (+ counts) for each given Agent that has none yet (idempotent; safe to call from render effects). */
ensureFirstFor: (agentIds: string[]) => void;
/** Fetches a category's first page for each given unloaded Agent (skipped unless its counts hold anything) and the next page for each loaded one with more. */
loadMoreFor: (agentIds: string[], category: SessionCategory) => Promise<void>;
/** Drops and refetches one Agent's loaded pairs (post-import refresh), keeping open folders populated. */
refreshAgent: (agentId: string) => Promise<void>;
}
/**
* @param includeCli Mirror of the user's "show CLI sessions" preference: appended to
* every paged fetch (`cli=1`); flipping it resets and refetches the whole tree under
* the new filter, like the sessions store does.
*/
export function useTraceTree(projectId: string | null, includeCli = false): TraceTree {
/** sessionId → pooled row (the newest fetch wins — see module doc). */
const [rows, setRows] = useState<ReadonlyMap<string, TraceSessionRow>>(new Map());
const [pageState, setPageState] = useState<ReadonlyMap<string, PagePosition>>(new Map());
const [countsByAgent, setCountsByAgent] = useState<ReadonlyMap<string, SessionCategoryCounts>>(
new Map(),
);
const [workspaceCountsByAgent, setWorkspaceCountsByAgent] = useState<
ReadonlyMap<string, Readonly<Record<string, SessionCategoryCounts>>>
>(new Map());
const [errorByAgent, setErrorByAgent] = useState<ReadonlyMap<string, string>>(new Map());
const [pending, setPending] = useState<ReadonlySet<string>>(new Set());
// Generation counter: a Project switch invalidates in-flight responses.
const gen = useRef(0);
// Synchronous in-flight guard: ensureFirstFor runs from render effects, which can
// double-fire (StrictMode) before the async state update lands.
const inflight = useRef(new Set<string>());
const pageStateRef = useRef(pageState);
pageStateRef.current = pageState;
const countsRef = useRef(countsByAgent);
countsRef.current = countsByAgent;
// Project switch / CLI-preference flip: drop everything (rows / cursors / counts are
// Project- and filter-scoped) — consumers' ensure effects then refetch under the new state.
useEffect(() => {
gen.current += 1;
inflight.current.clear();
const emptyPages = new Map<string, PagePosition>();
pageStateRef.current = emptyPages;
setRows(new Map());
setPageState(emptyPages);
setCountsByAgent(new Map());
setWorkspaceCountsByAgent(new Map());
setErrorByAgent(new Map());
setPending(new Set());
}, [projectId, includeCli]);
const fetchPage = useCallback(
async (
pid: string,
agentId: string,
category: SessionCategory,
offset: number,
): Promise<FetchedPage> => {
const res = await api.getAgentTraces(pid, agentId, {
offset,
limit: TRACES_PAGE_SIZE + 1,
category,
...(includeCli ? { cli: true } : {}),
});
const page = splitPage(toSessionGroups(res), TRACES_PAGE_SIZE);
return {
agentId,
category,
items: page.items.map((g) => ({ ...g, agentId })),
hasMore: page.hasMore,
...(res.counts !== undefined ? { counts: res.counts } : {}),
...(res.workspaceCounts !== undefined ? { workspaceCounts: res.workspaceCounts } : {}),
};
},
[includeCli],
);
/** Merges fetched pages: rows overwrite by sessionId, cursors advance, counts refresh to the newest response. */
const applyPages = useCallback((pages: FetchedPage[], resetPairsOf?: string) => {
setRows((prev) => {
const next = new Map(prev);
if (resetPairsOf !== undefined) {
for (const [sid, row] of prev) if (row.agentId === resetPairsOf) next.delete(sid);
}
for (const p of pages) for (const row of p.items) next.set(row.sessionId, row);
return next;
});
setPageState((prev) => {
const next = new Map(prev);
if (resetPairsOf !== undefined) {
for (const key of prev.keys()) if (key.startsWith(`${resetPairsOf}\0`)) next.delete(key);
}
for (const p of pages) {
const key = pairKey(p.agentId, p.category);
next.set(key, {
hasMore: p.hasMore,
fetched:
(resetPairsOf === undefined ? (prev.get(key)?.fetched ?? 0) : 0) + p.items.length,
});
}
return next;
});
for (const p of pages) {
if (p.counts) {
setCountsByAgent((prev) => new Map(prev).set(p.agentId, p.counts!));
}
if (p.workspaceCounts) {
setWorkspaceCountsByAgent((prev) => new Map(prev).set(p.agentId, p.workspaceCounts!));
}
}
}, []);
const ensureFirstFor = useCallback(
(agentIds: string[]) => {
if (!projectId) return;
const targets = [...new Set(agentIds)].filter((agentId) => {
const key = pairKey(agentId, "active");
return !pageStateRef.current.has(key) && !inflight.current.has(key);
});
if (targets.length === 0) return;
const g = gen.current;
for (const agentId of targets) inflight.current.add(pairKey(agentId, "active"));
setPending((prev) => {
const next = new Set(prev);
for (const agentId of targets) next.add(pairKey(agentId, "active"));
return next;
});
setErrorByAgent((prev) => {
if (!targets.some((id) => prev.has(id))) return prev;
const next = new Map(prev);
for (const id of targets) next.delete(id);
return next;
});
void Promise.all(
targets.map(async (agentId) => {
try {
return await fetchPage(projectId, agentId, "active", 0);
} catch (e: unknown) {
if (g === gen.current) {
setErrorByAgent((prev) => new Map(prev).set(agentId, apiErrorText(e)));
}
return null;
}
}),
).then((results) => {
for (const agentId of targets) inflight.current.delete(pairKey(agentId, "active"));
setPending((prev) => {
const next = new Set(prev);
for (const agentId of targets) next.delete(pairKey(agentId, "active"));
return next;
});
if (g !== gen.current) return;
applyPages(results.filter((r) => r !== null));
});
},
[projectId, fetchPage, applyPages],
);
const loadMoreFor = useCallback(
async (agentIds: string[], category: SessionCategory) => {
if (!projectId) return;
const targets = [...new Set(agentIds)].filter((agentId) => {
const position = pageStateRef.current.get(pairKey(agentId, category));
if (position === undefined) return (countsRef.current.get(agentId)?.[category] ?? 0) > 0;
return position.hasMore;
});
if (targets.length === 0) return;
const g = gen.current;
const results = await Promise.all(
targets.map(async (agentId) => {
const offset = pageStateRef.current.get(pairKey(agentId, category))?.fetched ?? 0;
try {
return await fetchPage(projectId, agentId, category, offset);
} catch {
// Transient failure: leave the pair's state untouched (still unloaded /
// still has-more), so the affordance stays and the user can retry —
// the sessions store's convention.
return null;
}
}),
);
if (g !== gen.current) return;
applyPages(results.filter((r) => r !== null));
},
[projectId, fetchPage, applyPages],
);
/** Post-import refresh: refetch the Agent's active first page plus every folder category already loaded (an open folder must not blank). */
const refreshAgent = useCallback(
async (agentId: string) => {
if (!projectId) return;
const categories: SessionCategory[] = [
"active",
...FOLDER_CATEGORIES.filter((cat) => pageStateRef.current.has(pairKey(agentId, cat))),
];
const g = gen.current;
const results = await Promise.all(
categories.map(async (category) => {
try {
return await fetchPage(projectId, agentId, category, 0);
} catch {
return null;
}
}),
);
if (g !== gen.current) return;
applyPages(
results.filter((r) => r !== null),
agentId,
);
},
[projectId, fetchPage, applyPages],
);
const isLoadedFor = useCallback(
(agentId: string, category: SessionCategory) => pageState.has(pairKey(agentId, category)),
[pageState],
);
const isPendingFor = useCallback(
(agentId: string, category: SessionCategory) => pending.has(pairKey(agentId, category)),
[pending],
);
const hasMoreFor = useCallback(
(agentId: string, category: SessionCategory) => {
const position = pageState.get(pairKey(agentId, category));
if (position !== undefined) return position.hasMore;
// Unloaded pair: anything the counts report is by definition still unfetched.
return (countsByAgent.get(agentId)?.[category] ?? 0) > 0;
},
[pageState, countsByAgent],
);
return useMemo<TraceTree>(() => {
const rowsByAgent = new Map<string, TraceSessionRow[]>();
for (const row of rows.values()) {
const list = rowsByAgent.get(row.agentId);
if (list) list.push(row);
else rowsByAgent.set(row.agentId, [row]);
}
// sessionId embeds the creation timestamp: sorting by it descending is the same
// newest-first order the server pages with.
for (const list of rowsByAgent.values()) {
list.sort((a, b) => b.sessionId.localeCompare(a.sessionId));
}
const allRows = [...rowsByAgent.values()].flat();
return {
rowsByAgent,
allRows,
countsByAgent,
workspaceCountsByAgent,
errorByAgent,
isLoadedFor,
isPendingFor,
hasMoreFor,
ensureFirstFor,
loadMoreFor,
refreshAgent,
};
}, [
rows,
countsByAgent,
workspaceCountsByAgent,
errorByAgent,
isLoadedFor,
isPendingFor,
hasMoreFor,
ensureFirstFor,
loadMoreFor,
refreshAgent,
]);
}
+9 -4
View File
@@ -172,7 +172,7 @@ export function latestConversation(sessions: readonly SessionInfo[]): SessionInf
return best;
}
export interface WorkspaceGroup {
export interface WorkspaceGroup<T = SessionInfo> {
/** Stable group key: the Workspace path, or TEMP_WORKSPACE_GROUP_KEY for the merged temp group. */
key: string;
/** Display label: the path basename; empty for the temp group (the sidebar renders the localized name). */
@@ -182,7 +182,7 @@ export interface WorkspaceGroup {
/** True for the merged auto-temp group. */
temp: boolean;
/** Member Sessions, newest first (createdAt desc). */
sessions: SessionInfo[];
sessions: T[];
}
/**
@@ -192,9 +192,14 @@ export interface WorkspaceGroup {
* concatenates per-Agent server responses, so its order isn't globally chronological.
* createdAt is a uniform ISO-8601 UTC string (server: `new Date().toISOString()`),
* so lexicographic comparison equals chronological comparison.
* Generic over the row type (defaulting to SessionInfo, the sidebar's rows): the Trace
* page groups its own Session rows with the same logic — only `workspace` and the
* `createdAt` sort key are touched.
*/
export function groupSessionsByWorkspace(sessions: SessionInfo[]): WorkspaceGroup[] {
const byKey = new Map<string, WorkspaceGroup>();
export function groupSessionsByWorkspace<T extends { workspace: string; createdAt: string }>(
sessions: T[],
): WorkspaceGroup<T>[] {
const byKey = new Map<string, WorkspaceGroup<T>>();
for (const s of sessions) {
const key = workspaceGroupKey(s.workspace);
let group = byKey.get(key);
+5
View File
@@ -849,6 +849,8 @@ Scenarios:
"This model cannot view images directly: on send, images are saved to the session scratchpad and passed as file paths (viewed via describe_image)",
infoPanel: "Session info",
sessionStats: "Stats",
/** Info-dropdown jump to the Trace page, deep-linked to the current Session. */
viewTrace: "View trace",
statTokens: "Total Tokens",
statElapsed: "Elapsed",
statInput: "Input tokens",
@@ -1084,6 +1086,9 @@ Scenarios:
toolDefs: (n: number) => `Tool definitions (${n})`,
exportFile: "Export",
importTrace: "Import Trace",
/** Import dialog: which Agent receives the file (the endpoint is per-Agent). */
importAgent: "Import into Agent",
importPickFile: "Choose file",
importing: "Importing…",
/** Client-side pre-check before reading the picked file (same cap as the server's import route). */
fileTooLarge: "The file exceeds the 14MB limit.",
+5
View File
@@ -823,6 +823,8 @@ Benchmark:
"当前模型不支持直接查看图片:发送时图片将保存到会话临时目录,以文件路径转交(模型经 describe_image 查看)",
infoPanel: "Session 信息",
sessionStats: "统计",
/** Info-dropdown jump to the Trace page, deep-linked to the current Session. */
viewTrace: "查看轨迹",
statTokens: "Token 累计",
statElapsed: "用时",
statInput: "输入 tokens",
@@ -1054,6 +1056,9 @@ Benchmark:
toolDefs: (n: number) => `工具定义(${n})`,
exportFile: "导出",
importTrace: "导入 Trace",
/** Import dialog: which Agent receives the file (the endpoint is per-Agent). */
importAgent: "导入到 Agent",
importPickFile: "选择文件",
importing: "导入中…",
/** Client-side pre-check before reading the picked file (same cap as the server's import route). */
fileTooLarge: "文件超过 14MB 上限。",