feat: session source in session_meta and an Automated sidebar folder (#40)
Co-authored-by: Alice <alice@prismshadow.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -185,9 +185,16 @@ export const getAgentTraces = (projectId: string, agentId: string) =>
|
||||
|
||||
// Session ---------------------------------------------------------------------
|
||||
|
||||
export const listSessions = (projectId: string, agentId: string) =>
|
||||
/** Optional paging (both absent = full list): the store requests `limit+1` per page to detect "has more". */
|
||||
export const listSessions = (
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
paging?: { offset: number; limit: number },
|
||||
) =>
|
||||
apiFetch<SessionsResponse>(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}/sessions`,
|
||||
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}/sessions${
|
||||
paging ? `?limit=${paging.limit}&offset=${paging.offset}` : ""
|
||||
}`,
|
||||
);
|
||||
|
||||
/** Server directory browsing: `path` is an absolute path; empty means start from the server's home directory. */
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
* there's no longer a separate "quick / advanced" pair of new-chat dialogs.
|
||||
* Color scheme is white/gray-based: active state uses a solid gray fill, running status uses a small color dot, no large blocks of color.
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { NavLink, useMatch, useNavigate } from "react-router";
|
||||
import type { SessionInfo } from "@prismshadow/penguin-server/api";
|
||||
import type { SessionInfo, SessionSource } from "@prismshadow/penguin-server/api";
|
||||
import * as api from "../../api/endpoints";
|
||||
import { ApiError } from "../../api/client";
|
||||
import { S } from "../../lib/strings";
|
||||
@@ -30,10 +30,14 @@ import type { Accent, Currency, FontScale, ThemeMode } from "../../state/theme";
|
||||
import { agentDisplayName, projectDisplayName, useProject } from "../../state/project";
|
||||
import { useSessions } from "../../state/sessions";
|
||||
import {
|
||||
SIDEBAR_PAGE_SIZE,
|
||||
groupAgentsWithMore,
|
||||
groupSessionsByWorkspace,
|
||||
partitionSessions,
|
||||
pinnedFirst,
|
||||
workspaceGroupKey,
|
||||
} from "../../lib/session-grouping";
|
||||
import type { SessionPartition } from "../../lib/session-grouping";
|
||||
import { Dropdown } from "../ui/dropdown";
|
||||
import { AgentAvatar } from "../ui/agent-avatar";
|
||||
import { Chevron } from "../ui/chevron";
|
||||
@@ -147,6 +151,13 @@ function saveGroupSet(storageKey: string | null, next: ReadonlySet<string>): voi
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open-state key of a per-origin folder (subagent / scheduled) inside a group: each folder
|
||||
* has its own state. "\0" never appears in Agent ids or Workspace paths, so the composite
|
||||
* never collides across groups or with plain group keys.
|
||||
*/
|
||||
const sourceFolderKey = (groupKey: string, source: SessionSource) => `${source}\0${groupKey}`;
|
||||
|
||||
/** Session status dot: running pulses green, compacting shows an amber dot; idle shows nothing. */
|
||||
function StatusDot({ session }: { session: SessionInfo }) {
|
||||
if (session.status === "running") {
|
||||
@@ -189,7 +200,8 @@ export function Sidebar({
|
||||
currentAgent,
|
||||
setCurrentAgentId,
|
||||
} = useProject();
|
||||
const { sessions, byAgent, loading, remove, replace } = useSessions();
|
||||
const { sessions, byAgent, hasMoreByAgent, loadMoreFor, loading, remove, replace } =
|
||||
useSessions();
|
||||
const chatMatch = useMatch("/chat/:sessionId");
|
||||
const activeSessionId = chatMatch?.params.sessionId ?? null;
|
||||
|
||||
@@ -218,6 +230,10 @@ export function Sidebar({
|
||||
}, [collapseStoreKey, pinStoreKey]);
|
||||
/** Expanded "archived" groups (collapsed by default), keyed like collapsedGroups. */
|
||||
const [openArchived, setOpenArchived] = useState<ReadonlySet<string>>(new Set());
|
||||
/** Expanded per-origin folders (subagent / scheduled Sessions; collapsed by default), keyed by sourceFolderKey — each folder has its own open state. */
|
||||
const [openSourceFolders, setOpenSourceFolders] = useState<ReadonlySet<string>>(new Set());
|
||||
/** Per-group display cap for active rows (keyed by group key; absent = SIDEBAR_PAGE_SIZE). "More" raises it a page at a time. */
|
||||
const [groupCaps, setGroupCaps] = useState<ReadonlyMap<string, number>>(new Map());
|
||||
/** Session pending delete confirmation (null = none). */
|
||||
const [deletingSession, setDeletingSession] = useState<SessionInfo | null>(null);
|
||||
const [deletingBusy, setDeletingBusy] = useState(false);
|
||||
@@ -278,6 +294,36 @@ export function Sidebar({
|
||||
return next;
|
||||
});
|
||||
|
||||
const toggleSourceFolder = (groupKey: string, source: SessionSource) =>
|
||||
setOpenSourceFolders((prev) => {
|
||||
const key = sourceFolderKey(groupKey, source);
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
|
||||
// The open chat is an automation-created Session: expand exactly its origin's folder in its
|
||||
// group, so the active row is never hidden inside a collapsed folder (mirrors the archived
|
||||
// expansion on archiving the open chat; archived wins, so an archived Session is left to
|
||||
// that folder). Auto-expansion fires ONCE per (grouping mode, active session): the ref guard
|
||||
// keeps list mutations (status ticks, reloads) from re-opening a folder the user explicitly
|
||||
// collapsed while that chat stays open. `sessions` must remain a dependency — the active
|
||||
// session may not be in the list yet on first render, and the guard is only set once the
|
||||
// row is actually found and expanded.
|
||||
const lastAutoExpandedRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!activeSessionId) return;
|
||||
const s = sessions.find((x) => x.sessionId === activeSessionId);
|
||||
if (!s || !s.source || s.archived) return;
|
||||
const guard = `${groupMode}\0${activeSessionId}`;
|
||||
if (lastAutoExpandedRef.current === guard) return;
|
||||
lastAutoExpandedRef.current = guard;
|
||||
const groupKey = groupMode === "agent" ? s.agentId : workspaceGroupKey(s.workspace);
|
||||
const key = sourceFolderKey(groupKey, s.source);
|
||||
setOpenSourceFolders((prev) => (prev.has(key) ? prev : new Set(prev).add(key)));
|
||||
}, [activeSessionId, sessions, groupMode]);
|
||||
|
||||
/** Archive / unarchive: persists immediately and updates in place (fails silently; the next list refresh self-corrects). */
|
||||
const toggleArchive = async (s: SessionInfo) => {
|
||||
// Archiving the currently open chat: expand the "archived" group so it doesn't silently vanish from the sidebar with no way back.
|
||||
@@ -404,36 +450,108 @@ export function Sidebar({
|
||||
</ul>
|
||||
);
|
||||
|
||||
/** Expanded group body shared by both modes: active rows + the collapsed-by-default archived subgroup (keyed by the group key). */
|
||||
const renderGroupBody = (
|
||||
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 per-origin folder (subagent / scheduled), parallel to the archived folder. */
|
||||
const renderSourceFolder = (
|
||||
groupKey: string,
|
||||
activeList: SessionInfo[],
|
||||
archivedList: SessionInfo[],
|
||||
source: SessionSource,
|
||||
rows: SessionInfo[],
|
||||
withAgentHint: boolean,
|
||||
) => {
|
||||
if (rows.length === 0) return null;
|
||||
const open = openSourceFolders.has(sourceFolderKey(groupKey, source));
|
||||
return (
|
||||
<div className="mt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSourceFolder(groupKey, source)}
|
||||
className={folderClass}
|
||||
>
|
||||
<Chevron open={open} size={12} />
|
||||
{S.chat.sourceGroups[source](rows.length)}
|
||||
</button>
|
||||
{open && renderRows(rows, withAgentHint)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** "More": reveal one more page of already-loaded active rows AND fetch the next server page for every contributing Agent that still has one. */
|
||||
const showMore = (groupKey: string, moreAgents: string[]) => {
|
||||
setGroupCaps((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(groupKey, (prev.get(groupKey) ?? SIDEBAR_PAGE_SIZE) + SIDEBAR_PAGE_SIZE);
|
||||
return next;
|
||||
});
|
||||
if (moreAgents.length > 0) void loadMoreFor(moreAgents);
|
||||
};
|
||||
|
||||
/**
|
||||
* Expanded group body shared by both modes: user rows (display-capped; "More" reveals and
|
||||
* loads further pages) + the collapsed-by-default subagent / scheduled / archived subgroups
|
||||
* (keyed by the group key; rendered uncapped over loaded data — they are collapsed by
|
||||
* default and only ever hold what the pages brought in).
|
||||
*/
|
||||
const renderGroupBody = (
|
||||
groupKey: string,
|
||||
parts: SessionPartition,
|
||||
withAgentHint: boolean,
|
||||
/** Agents contributing to this group that still have unfetched server pages. */
|
||||
moreAgents: string[],
|
||||
) => {
|
||||
const archivedOpen = openArchived.has(groupKey);
|
||||
const cap = groupCaps.get(groupKey) ?? SIDEBAR_PAGE_SIZE;
|
||||
const shownActive = parts.active.slice(0, cap);
|
||||
// "More" while hidden loaded rows exist OR any contributing Agent has server-side pages
|
||||
// left; a fetched page can also land rows in the folders below, so one click may grow
|
||||
// the visible list by fewer than a full page — the row simply stays until exhausted.
|
||||
const hasMore = parts.active.length > cap || moreAgents.length > 0;
|
||||
const empty =
|
||||
parts.active.length === 0 &&
|
||||
parts.subagent.length === 0 &&
|
||||
parts.schedule.length === 0 &&
|
||||
parts.archived.length === 0;
|
||||
return (
|
||||
<>
|
||||
{activeList.length === 0 && archivedList.length === 0 ? (
|
||||
{empty ? (
|
||||
<p className="px-2.5 py-1 text-xs text-gray-400 dark:text-gray-600">
|
||||
{S.chat.noSessions}
|
||||
</p>
|
||||
) : (
|
||||
renderRows(activeList, withAgentHint)
|
||||
renderRows(shownActive, withAgentHint)
|
||||
)}
|
||||
|
||||
{/* Archived group (collapsed by default) */}
|
||||
{archivedList.length > 0 && (
|
||||
{/* Load/reveal more (kept adjacent to the active list it extends, above the folders) */}
|
||||
{hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={S.chat.loadMore}
|
||||
onClick={() => showMore(groupKey, moreAgents)}
|
||||
className={`${folderClass} mt-0.5`}
|
||||
>
|
||||
<span className="w-3" aria-hidden />
|
||||
{S.chat.loadMore}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Per-origin folders (collapsed by default, above Archived): subagent first — spawned
|
||||
from the conversations at hand — then scheduled background runs. */}
|
||||
{renderSourceFolder(groupKey, "subagent", parts.subagent, withAgentHint)}
|
||||
{renderSourceFolder(groupKey, "schedule", parts.schedule, withAgentHint)}
|
||||
|
||||
{/* Archived group (collapsed by default; archived wins over the per-origin folders) */}
|
||||
{parts.archived.length > 0 && (
|
||||
<div className="mt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleArchivedGroup(groupKey)}
|
||||
className="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"
|
||||
className={folderClass}
|
||||
>
|
||||
<Chevron open={archivedOpen} size={12} />
|
||||
{S.chat.archivedGroup(archivedList.length)}
|
||||
{S.chat.archivedGroup(parts.archived.length)}
|
||||
</button>
|
||||
{archivedOpen && renderRows(archivedList, withAgentHint)}
|
||||
{archivedOpen && renderRows(parts.archived, withAgentHint)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -624,9 +742,7 @@ export function Sidebar({
|
||||
<SkeletonList rows={5} />
|
||||
) : (
|
||||
orderedAgents.map((agent) => {
|
||||
const list = byAgent.get(agent.agentId) ?? [];
|
||||
const activeList = list.filter((s) => !s.archived);
|
||||
const archivedList = list.filter((s) => s.archived);
|
||||
const parts = partitionSessions(byAgent.get(agent.agentId) ?? []);
|
||||
const collapsed = collapsedGroups.has(agent.agentId);
|
||||
const pinned = pinnedGroups.has(agent.agentId);
|
||||
return (
|
||||
@@ -678,7 +794,12 @@ export function Sidebar({
|
||||
|
||||
{collapsed
|
||||
? null
|
||||
: renderGroupBody(agent.agentId, activeList, archivedList, false)}
|
||||
: renderGroupBody(
|
||||
agent.agentId,
|
||||
parts,
|
||||
false,
|
||||
hasMoreByAgent.get(agent.agentId) === true ? [agent.agentId] : [],
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
@@ -691,8 +812,7 @@ export function Sidebar({
|
||||
</p>
|
||||
) : (
|
||||
orderedWorkspaceGroups.map((group) => {
|
||||
const activeList = group.sessions.filter((s) => !s.archived);
|
||||
const archivedList = group.sessions.filter((s) => s.archived);
|
||||
const parts = partitionSessions(group.sessions);
|
||||
const collapsed = collapsedGroups.has(group.key);
|
||||
const pinned = pinnedGroups.has(group.key);
|
||||
return (
|
||||
@@ -717,8 +837,9 @@ export function Sidebar({
|
||||
<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 = non-archived Sessions (user + automation-created; unchanged semantics) */}
|
||||
<span className="shrink-0 text-[11px] text-gray-400 dark:text-gray-500">
|
||||
{activeList.length}
|
||||
{parts.active.length + parts.subagent.length + parts.schedule.length}
|
||||
</span>
|
||||
<Chevron open={!collapsed} size={12} className="text-gray-400" />
|
||||
<span className="min-w-0 flex-1" />
|
||||
@@ -736,7 +857,15 @@ export function Sidebar({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{collapsed ? null : renderGroupBody(group.key, activeList, archivedList, true)}
|
||||
{/* A workspace group can span Agents: "More" fans out to every contributing Agent that still has pages. */}
|
||||
{collapsed
|
||||
? null
|
||||
: renderGroupBody(
|
||||
group.key,
|
||||
parts,
|
||||
true,
|
||||
groupAgentsWithMore(group.sessions, hasMoreByAgent),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -182,13 +182,36 @@ export function ChatPage() {
|
||||
};
|
||||
}, [projectId, selectedAgentId]);
|
||||
|
||||
// The Session list is paged: a deep-linked Session (old bookmark, cross-page jump) may sit
|
||||
// beyond the loaded pages. Look it up directly and insert it before the auto-select effect
|
||||
// below concludes it doesn't exist; only a failed probe releases that redirect.
|
||||
const [probeFailedId, setProbeFailedId] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (draft || !routeSessionId || sessionsLoading) return;
|
||||
if (sessions.some((s) => s.sessionId === routeSessionId)) return;
|
||||
let cancelled = false;
|
||||
api.getSession(routeSessionId).then(
|
||||
(res) => {
|
||||
if (!cancelled) addSession(res.session);
|
||||
},
|
||||
() => {
|
||||
if (!cancelled) setProbeFailedId(routeSessionId);
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [draft, routeSessionId, sessionsLoading, sessions, addSession]);
|
||||
|
||||
// Auto-select the most recent Session when the route doesn't select one; if there are none at all, fall back to draft state (instead of auto-creating one).
|
||||
useEffect(() => {
|
||||
if (sessionsLoading || draft) return;
|
||||
if (routeSessionId && sessions.some((s) => s.sessionId === routeSessionId)) return;
|
||||
// A routed id missing from the paged list isn't gone until the direct lookup fails.
|
||||
if (routeSessionId && probeFailedId !== routeSessionId) return;
|
||||
const first = sessions[0];
|
||||
navigate(first ? `/chat/${first.sessionId}` : `/chat/${DRAFT_SESSION_ID}`, { replace: true });
|
||||
}, [sessionsLoading, draft, routeSessionId, sessions, navigate]);
|
||||
}, [sessionsLoading, draft, routeSessionId, probeFailedId, sessions, navigate]);
|
||||
|
||||
// Sync task_state to the sidebar list badge.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -158,6 +158,8 @@ const summaryClass =
|
||||
function SessionMetaBody({ p }: { p: Record<string, unknown> }) {
|
||||
const rows: Array<[string, string]> = [
|
||||
["session_id", String(p.session_id ?? "")],
|
||||
// Session origin (subagent / schedule); user-created sessions have no source and show the empty dash.
|
||||
["source", String(p.source ?? "")],
|
||||
["model_id", String(p.model_id ?? "")],
|
||||
["context_window", String(p.model_context_window ?? "")],
|
||||
["thinking_level", String(p.thinking_level ?? "")],
|
||||
|
||||
@@ -41,6 +41,72 @@ export function workspaceLabel(workspace: string): string {
|
||||
return parts[parts.length - 1] ?? "/";
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebar page size: sessions fetched per Agent per page, and the per-group display cap
|
||||
* step for active rows. Fetches use limit = SIDEBAR_PAGE_SIZE + 1 (see splitPage) so one
|
||||
* request both fills a page and answers "is there more" without a response-envelope change.
|
||||
*/
|
||||
export const SIDEBAR_PAGE_SIZE = 20;
|
||||
|
||||
/**
|
||||
* Applies the limit+1 fetch trick: `fetched` came from a request with `limit = pageSize + 1`;
|
||||
* the visible page is the first `pageSize` items, and an overflow item (never shown) proves
|
||||
* the server has more.
|
||||
*/
|
||||
export function splitPage<T>(fetched: T[], pageSize: number): { items: T[]; hasMore: boolean } {
|
||||
return fetched.length > pageSize
|
||||
? { items: fetched.slice(0, pageSize), hasMore: true }
|
||||
: { items: fetched, hasMore: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* The distinct Agents contributing to a group that still have unfetched server pages —
|
||||
* a workspace-mode group can span Agents, so "load more" for the group fans out to every
|
||||
* contributing Agent with more (agent-mode groups are single-Agent and get 0..1 entries).
|
||||
*/
|
||||
export function groupAgentsWithMore(
|
||||
sessions: SessionInfo[],
|
||||
hasMoreByAgent: ReadonlyMap<string, boolean>,
|
||||
): string[] {
|
||||
const out: string[] = [];
|
||||
for (const s of sessions) {
|
||||
if (hasMoreByAgent.get(s.agentId) === true && !out.includes(s.agentId)) out.push(s.agentId);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Four-way split of one sidebar group's Sessions (rendered top to bottom in this order). */
|
||||
export interface SessionPartition {
|
||||
/** User-created, not archived: rendered directly in the group body. */
|
||||
active: SessionInfo[];
|
||||
/** Subagent-created (`source === "subagent"`), not archived: the collapsed "Subagents" folder. */
|
||||
subagent: SessionInfo[];
|
||||
/** Schedule-created (`source === "schedule"`), not archived: the collapsed "Scheduled" folder. */
|
||||
schedule: SessionInfo[];
|
||||
/** Archived: the collapsed "Archived" folder. Archived wins — an archived Session with a `source` goes here only. */
|
||||
archived: SessionInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Partitions a group's Sessions for rendering. Classification precedence: archived wins
|
||||
* regardless of `source` (archiving is an explicit user action, so the Archived folder
|
||||
* must show everything the user put there); otherwise a Session goes to its origin's
|
||||
* bucket, and an unrecognized future source falls through to the user rows (visible,
|
||||
* with its badge) rather than vanishing into the wrong folder. The sidebar renders the
|
||||
* parts top to bottom in the interface's field order — user rows, Subagents folder,
|
||||
* Scheduled folder, Archived folder. Input order is preserved within each part.
|
||||
*/
|
||||
export function partitionSessions(sessions: SessionInfo[]): SessionPartition {
|
||||
const parts: SessionPartition = { active: [], subagent: [], schedule: [], archived: [] };
|
||||
for (const s of sessions) {
|
||||
if (s.archived) parts.archived.push(s);
|
||||
else if (s.source === "subagent") parts.subagent.push(s);
|
||||
else if (s.source === "schedule") parts.schedule.push(s);
|
||||
else parts.active.push(s);
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
export interface WorkspaceGroup {
|
||||
/** Stable group key: the Workspace path, or TEMP_WORKSPACE_GROUP_KEY for the merged temp group. */
|
||||
key: string;
|
||||
|
||||
@@ -700,6 +700,13 @@ When done, open index.html in a browser and self-test once.`,
|
||||
archiveSession: "Archive",
|
||||
unarchiveSession: "Unarchive",
|
||||
archivedGroup: (n: number) => `Archived (${n})`,
|
||||
/** Sidebar group "reveal/load next page" row (display cap + server paging). */
|
||||
loadMore: "More",
|
||||
/** Sidebar folders for automation-created sessions (one per origin), parallel to Archived; wording matches the sourceNames badges. */
|
||||
sourceGroups: {
|
||||
subagent: (n: number) => `Subagents (${n})`,
|
||||
schedule: (n: number) => `Scheduled (${n})`,
|
||||
},
|
||||
skillsBanner: (names: string[]): string =>
|
||||
`Using skill${names.length === 1 ? "" : "s"}: ${names.join(", ")}`,
|
||||
},
|
||||
|
||||
@@ -680,6 +680,13 @@ Penguin 视觉风格(见 web-design 技能),深色/浅色主题(<html da
|
||||
archiveSession: "归档",
|
||||
unarchiveSession: "取消归档",
|
||||
archivedGroup: (n: number) => `已归档(${n})`,
|
||||
/** Sidebar group "reveal/load next page" row (display cap + server paging). */
|
||||
loadMore: "更多",
|
||||
/** Sidebar folders for automation-created sessions (one per origin), parallel to 已归档; wording matches the sourceNames badges. */
|
||||
sourceGroups: {
|
||||
subagent: (n: number) => `子智能体(${n})`,
|
||||
schedule: (n: number) => `定时任务(${n})`,
|
||||
},
|
||||
skillsBanner: (names: string[]): string => `使用技能:${names.join("、")}`,
|
||||
},
|
||||
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
* the sidebar groups by Agent, so all Agents' Sessions are loaded at once (fetched in parallel);
|
||||
* the chat page shares this same data for status sync / title events / self-healing reload.
|
||||
*
|
||||
* **Paged**: each Agent fetches SIDEBAR_PAGE_SIZE sessions per page (requesting one extra
|
||||
* to detect "has more" — see splitPage), so a Project with a long history doesn't pull
|
||||
* thousands of rows on every load; `loadMoreFor` appends the next page(s) on demand
|
||||
* (deduplicated by sessionId — new sessions shift server offsets). A reload resets every
|
||||
* Agent back to its first page.
|
||||
*
|
||||
* **Sessions are not auto-created here**: a new conversation starts as a draft (chat page `/chat/new`),
|
||||
* and the Session is only actually created when the first message is sent — after landing, the user
|
||||
* may still switch models or configure an API key first, so persisting the Session early would both
|
||||
@@ -21,15 +27,20 @@ import type { ReactNode } from "react";
|
||||
import type { SessionInfo, SessionStatus } from "@prismshadow/penguin-server/api";
|
||||
import * as api from "../api/endpoints";
|
||||
import { openUserEvents } from "../api/sse";
|
||||
import { SIDEBAR_PAGE_SIZE, splitPage } from "../lib/session-grouping";
|
||||
import { useProject } from "./project";
|
||||
|
||||
interface SessionsContextValue {
|
||||
/** Full list (each Agent's entries keep server-side reverse chronological order). */
|
||||
/** Loaded list (paged per Agent; each Agent's entries newest first). */
|
||||
sessions: SessionInfo[];
|
||||
/** agentId → that Agent's Session list (empty array if none). */
|
||||
/** agentId → that Agent's loaded Session list, newest first (empty array if none). */
|
||||
byAgent: ReadonlyMap<string, SessionInfo[]>;
|
||||
/** agentId → whether the server has more (unfetched) sessions for that Agent. */
|
||||
hasMoreByAgent: ReadonlyMap<string, boolean>;
|
||||
loading: boolean;
|
||||
reload: () => Promise<void>;
|
||||
/** Fetches and appends the next page for each given Agent that still has more (no-op otherwise). */
|
||||
loadMoreFor: (agentIds: string[]) => Promise<void>;
|
||||
/** Prepend to the list on success (draft materialized by the first message, or explicit creation via dialog). */
|
||||
add: (session: SessionInfo) => void;
|
||||
/** Remove from the list in place after deletion. */
|
||||
@@ -52,10 +63,16 @@ export function SessionsProvider({ children }: { children: ReactNode }) {
|
||||
const agentIdsKey = agents.map((a) => a.agentId).join(",");
|
||||
|
||||
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
||||
const [hasMoreByAgent, setHasMoreByAgent] = useState<ReadonlyMap<string, boolean>>(new Map());
|
||||
const [loading, setLoading] = useState(true);
|
||||
// Generation counter: invalidates any in-flight response once the Project/Agent set
|
||||
// changes or a reload happens.
|
||||
const gen = useRef(0);
|
||||
// Current values for loadMoreFor (offsets are computed from what is actually loaded).
|
||||
const sessionsRef = useRef<SessionInfo[]>([]);
|
||||
sessionsRef.current = sessions;
|
||||
const hasMoreRef = useRef<ReadonlyMap<string, boolean>>(hasMoreByAgent);
|
||||
hasMoreRef.current = hasMoreByAgent;
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const agentIds = agentIdsKey === "" ? [] : agentIdsKey.split(",");
|
||||
@@ -66,14 +83,23 @@ export function SessionsProvider({ children }: { children: ReactNode }) {
|
||||
const results = await Promise.all(
|
||||
agentIds.map(async (agentId) => {
|
||||
try {
|
||||
return (await api.listSessions(projectId, agentId)).sessions;
|
||||
// First page per Agent, requesting one extra row to learn whether more exist.
|
||||
const fetched = (
|
||||
await api.listSessions(projectId, agentId, {
|
||||
offset: 0,
|
||||
limit: SIDEBAR_PAGE_SIZE + 1,
|
||||
})
|
||||
).sessions;
|
||||
return { agentId, ...splitPage(fetched, SIDEBAR_PAGE_SIZE) };
|
||||
} catch {
|
||||
return []; // A single Agent's fetch failure shouldn't bring down the whole batch (e.g. its directory was deleted externally).
|
||||
// A single Agent's fetch failure shouldn't bring down the whole batch (e.g. its directory was deleted externally).
|
||||
return { agentId, items: [] as SessionInfo[], hasMore: false };
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (g !== gen.current) return;
|
||||
setSessions(results.flat());
|
||||
setSessions(results.flatMap((r) => r.items));
|
||||
setHasMoreByAgent(new Map(results.map((r) => [r.agentId, r.hasMore])));
|
||||
} finally {
|
||||
if (g === gen.current) setLoading(false);
|
||||
}
|
||||
@@ -81,9 +107,54 @@ export function SessionsProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
useEffect(() => {
|
||||
setSessions([]);
|
||||
setHasMoreByAgent(new Map());
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
/**
|
||||
* Next page for each given Agent that still has more. Offsets are the currently loaded
|
||||
* per-Agent counts; a session created since the last page shifts server offsets, so
|
||||
* appended rows are deduplicated by sessionId (a short page is fine — `hasMore` comes
|
||||
* from the server response, and the next click continues from the new count).
|
||||
*/
|
||||
const loadMoreFor = useCallback(
|
||||
async (agentIds: string[]) => {
|
||||
if (!projectId) return;
|
||||
const targets = [...new Set(agentIds)].filter((id) => hasMoreRef.current.get(id) === true);
|
||||
if (targets.length === 0) return;
|
||||
const g = gen.current;
|
||||
const results = await Promise.all(
|
||||
targets.map(async (agentId) => {
|
||||
const offset = sessionsRef.current.filter((s) => s.agentId === agentId).length;
|
||||
try {
|
||||
const fetched = (
|
||||
await api.listSessions(projectId, agentId, {
|
||||
offset,
|
||||
limit: SIDEBAR_PAGE_SIZE + 1,
|
||||
})
|
||||
).sessions;
|
||||
return { agentId, ...splitPage(fetched, SIDEBAR_PAGE_SIZE) };
|
||||
} catch {
|
||||
// Transient failure: keep "has more" so the affordance stays and the user can retry.
|
||||
return { agentId, items: [] as SessionInfo[], hasMore: true };
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (g !== gen.current) return; // Project switch / reload raced this page: drop it.
|
||||
setSessions((prev) => {
|
||||
const seen = new Set(prev.map((s) => s.sessionId));
|
||||
const appended = results.flatMap((r) => r.items.filter((s) => !seen.has(s.sessionId)));
|
||||
return appended.length > 0 ? [...prev, ...appended] : prev;
|
||||
});
|
||||
setHasMoreByAgent((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const r of results) next.set(r.agentId, r.hasMore);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[projectId],
|
||||
);
|
||||
|
||||
// User-level event stream (/api/events): a scheduled task firing may have created a new
|
||||
// Session (new-session mode); reload the list so it appears immediately. schedule_queued
|
||||
// doesn't change the list (the target Session already exists), so it's ignored.
|
||||
@@ -140,18 +211,39 @@ export function SessionsProvider({ children }: { children: ReactNode }) {
|
||||
if (list) list.push(s);
|
||||
else byAgent.set(s.agentId, [s]);
|
||||
}
|
||||
// Encounter order is no longer reliable with paging (appended pages are older, but a
|
||||
// deep-linked old session is prepended via add): sort each Agent's list newest first
|
||||
// (same key the server sorts by).
|
||||
for (const list of byAgent.values()) {
|
||||
list.sort(
|
||||
(a, b) => b.createdAt.localeCompare(a.createdAt) || b.sessionId.localeCompare(a.sessionId),
|
||||
);
|
||||
}
|
||||
return {
|
||||
sessions,
|
||||
byAgent,
|
||||
hasMoreByAgent,
|
||||
loading,
|
||||
reload,
|
||||
loadMoreFor,
|
||||
add,
|
||||
remove,
|
||||
replace,
|
||||
setStatus,
|
||||
setTitle,
|
||||
};
|
||||
}, [sessions, loading, reload, add, remove, replace, setStatus, setTitle]);
|
||||
}, [
|
||||
sessions,
|
||||
hasMoreByAgent,
|
||||
loading,
|
||||
reload,
|
||||
loadMoreFor,
|
||||
add,
|
||||
remove,
|
||||
replace,
|
||||
setStatus,
|
||||
setTitle,
|
||||
]);
|
||||
|
||||
return <SessionsContext.Provider value={value}>{children}</SessionsContext.Provider>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user