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