feat(server,web): DB-served session list, CLI-session visibility toggle, and sidebar group paging (#158)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-08-03 13:51:14 +08:00
committed by GitHub
parent e6a58ac131
commit aeffc8a02f
16 changed files with 359 additions and 42 deletions
+10 -2
View File
@@ -221,12 +221,20 @@ export const getAgentTraces = (projectId: string, agentId: string) =>
export const listSessions = (
projectId: string,
agentId: string,
opts?: { offset: number; limit: number; category?: SessionCategory; withCounts?: boolean },
opts?: {
offset: number;
limit: number;
category?: SessionCategory;
withCounts?: boolean;
/** Also list CLI-created Sessions (Trace discovery + adoption); default = web rows straight from the DB. */
cli?: boolean;
},
) => {
const qs = opts
? `?limit=${opts.limit}&offset=${opts.offset}` +
(opts.category ? `&category=${opts.category}` : "") +
(opts.withCounts ? "&counts=1" : "")
(opts.withCounts ? "&counts=1" : "") +
(opts.cli ? "&cli=1" : "")
: "";
return apiFetch<SessionsResponse>(
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}/sessions${qs}`,
+36 -3
View File
@@ -36,6 +36,7 @@ import { agentDisplayName, projectDisplayName, useProject } from "../../state/pr
import { useSessions } from "../../state/sessions";
import {
FOLDER_CATEGORIES,
SIDEBAR_GROUP_PAGE_SIZE,
SIDEBAR_PAGE_SIZE,
aggregateWorkspaceCounts,
groupSessionsByWorkspace,
@@ -45,6 +46,7 @@ import {
workspaceGroupKey,
} from "../../lib/session-grouping";
import type { FolderCategory, SessionPartition } from "../../lib/session-grouping";
import { Switch } from "../ui/switch";
import { Dropdown } from "../ui/dropdown";
import { AgentAvatar } from "../ui/agent-avatar";
import { Chevron } from "../ui/chevron";
@@ -214,6 +216,8 @@ export function Sidebar({
loading,
remove,
replace,
showCliSessions,
setShowCliSessions,
} = useSessions();
const chatMatch = useMatch("/chat/:sessionId");
const activeSessionId = chatMatch?.params.sessionId ?? null;
@@ -281,6 +285,7 @@ export function Sidebar({
useEffect(() => {
setCollapsedGroups(loadGroupSet(collapseStoreKey));
setPinnedGroups(loadGroupSet(pinStoreKey));
setGroupCap(SIDEBAR_GROUP_PAGE_SIZE);
}, [collapseStoreKey, pinStoreKey]);
/** Expanded folders (subagent / scheduled / archived; collapsed by default), keyed by folderKey — each folder has its own open state. */
const [openFolders, setOpenFolders] = useState<ReadonlySet<string>>(new Set());
@@ -288,6 +293,8 @@ export function Sidebar({
const [pendingLoads, setPendingLoads] = 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());
/** How many GROUPS render (#139: dozens of Agents/Workspaces made the list too tall to scan); "more groups" raises it a page at a time, reset per Project and on a mode switch. */
const [groupCap, setGroupCap] = useState(SIDEBAR_GROUP_PAGE_SIZE);
/** Session pending delete confirmation (null = none). */
const [deletingSession, setDeletingSession] = useState<SessionInfo | null>(null);
const [deletingBusy, setDeletingBusy] = useState(false);
@@ -300,6 +307,8 @@ export function Sidebar({
const setGroupMode = (mode: GroupMode) => {
localStorage.setItem(GROUP_MODE_KEY, mode);
setGroupModeState(mode);
// The two modes have unrelated group lists: restart the reveal window.
setGroupCap(SIDEBAR_GROUP_PAGE_SIZE);
};
/** Workspace groups (workspace mode): computed from the flat list, temp directories merged last. */
@@ -665,6 +674,18 @@ export function Sidebar({
);
};
/** Reveal-next-page-of-groups row (render cap only — data loading is untouched). */
const moreGroupsRow = (total: number) => (
<button
type="button"
onClick={() => setGroupCap((c) => c + SIDEBAR_GROUP_PAGE_SIZE)}
className={`${folderClass} mt-1`}
>
<span className="w-3" aria-hidden />
{S.chat.moreGroups(total - groupCap)}
</button>
);
const navItems: Array<{ to: string; label: string; icon: string }> = [
{ to: "/agents", label: S.nav.agents, icon: NAV_ICONS.agents },
{ to: "/skills", label: S.nav.skills, icon: NAV_ICONS.skills },
@@ -869,7 +890,7 @@ export function Sidebar({
loading && agents.length === 0 ? (
<SkeletonList rows={5} />
) : (
orderedAgents.map((agent) => {
orderedAgents.slice(0, groupCap).map((agent) => {
const parts = partitionSessions(byAgent.get(agent.agentId) ?? []);
const collapsed = collapsedGroups.has(agent.agentId);
const pinned = pinnedGroups.has(agent.agentId);
@@ -933,14 +954,18 @@ export function Sidebar({
);
})
)
) : loading && sessions.length === 0 ? (
) : null}
{groupMode === "agent" && orderedAgents.length > groupCap
? moreGroupsRow(orderedAgents.length)
: null}
{groupMode === "agent" ? null : loading && sessions.length === 0 ? (
<SkeletonList rows={5} />
) : orderedWorkspaceGroups.length === 0 ? (
<p className="px-2.5 pt-3 text-xs text-gray-400 dark:text-gray-600">
{S.chat.noSessions}
</p>
) : (
orderedWorkspaceGroups.map((group) => {
orderedWorkspaceGroups.slice(0, groupCap).map((group) => {
const parts = partitionSessions(group.sessions);
const collapsed = collapsedGroups.has(group.key);
const pinned = pinnedGroups.has(group.key);
@@ -1004,6 +1029,9 @@ export function Sidebar({
);
})
)}
{groupMode === "workspace" && orderedWorkspaceGroups.length > groupCap
? moreGroupsRow(orderedWorkspaceGroups.length)
: null}
</div>
{/* Bottom user config */}
@@ -1058,6 +1086,11 @@ export function Sidebar({
<SettingRow label={S.settings.language}>
<Segmented options={langOptions} value={lang} onChange={setLang} />
</SettingRow>
{/* Off (default) = the sidebar lists only web-created Sessions, served straight
from the DB; on = CLI Sessions are discovered from the Trace directory too. */}
<SettingRow label={S.settings.showCliSessions}>
<Switch checked={showCliSessions} onChange={setShowCliSessions} />
</SettingRow>
</div>
<div className="mt-1 border-t border-gray-100 pt-1 dark:border-gray-800">
<button
+7
View File
@@ -54,6 +54,13 @@ export function workspaceLabel(workspace: string): string {
*/
export const SIDEBAR_PAGE_SIZE = 10;
/**
* Groups (Agents / Workspace groups) rendered per sidebar "page": the initial render cap,
* raised by one page per "more groups" click. A pure display cap — with dozens of groups the
* full list renders too tall to scan (#139); the groups' data loading is unchanged.
*/
export const SIDEBAR_GROUP_PAGE_SIZE = 10;
/**
* 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
+4
View File
@@ -31,6 +31,8 @@ export const en: Strings = {
settings: {
language: "Language",
/** Sidebar Session list: also show CLI-created Sessions (default off — the list then never scans the Trace directories). */
showCliSessions: "Show CLI sessions",
theme: "Theme",
themeLight: "Light",
themeDark: "Dark",
@@ -798,6 +800,8 @@ Scenarios:
openPreview: "Click to preview",
showMoreFiles: (n: number) => `Show ${n} more ${n === 1 ? "file" : "files"}`,
showLess: "Show less",
/** Reveal the next page of sidebar groups (#139); n = groups still hidden. */
moreGroups: (n: number) => `More groups (${n})`,
contextUsage: "Context usage",
contextUnknown: "Context usage: unknown until the next request reports it",
slashHint: "Type / for commands",
+4
View File
@@ -33,6 +33,8 @@ export const zh = {
settings: {
language: "语言",
/** Sidebar Session list: also show CLI-created Sessions (default off — the list then never scans the Trace directories). */
showCliSessions: "显示 CLI 会话",
theme: "主题",
themeLight: "浅色",
themeDark: "深色",
@@ -779,6 +781,8 @@ Benchmark:
openPreview: "点击预览",
showMoreFiles: (n: number) => `显示其余 ${n} 个文件`,
showLess: "收起",
/** Reveal the next page of sidebar groups (#139); n = groups still hidden. */
moreGroups: (n: number) => `更多分组(${n})`,
contextUsage: "上下文占用",
contextUnknown: "上下文占用:压缩后待下次请求回报",
slashHint: "输入 / 使用命令",
+34 -2
View File
@@ -71,6 +71,10 @@ interface SessionsContextValue {
setStatus: (sessionId: string, status: SessionStatus) => void;
/** session_title server event → update the title in place. */
setTitle: (sessionId: string, title: string) => void;
/** Whether CLI-created Sessions are listed too (persisted per user; default off = the list is served from the DB without Trace scanning). */
showCliSessions: boolean;
/** Flip the CLI-session preference: persists it and refetches the whole list under the new filter. */
setShowCliSessions: (value: boolean) => void;
}
const SessionsContext = createContext<SessionsContextValue | null>(null);
@@ -108,6 +112,28 @@ export function SessionsProvider({ children }: { children: ReactNode }) {
ReadonlyMap<string, Readonly<Record<string, SessionCategoryCounts>>>
>(new Map());
const [loading, setLoading] = useState(true);
// "Show CLI sessions" preference: server-persisted per user (ui_prefs); hydrated once on
// mount, default off. Changing it recreates reload(), and the reset effect below refetches
// the whole list under the new filter.
const [showCliSessions, setShowCliSessionsState] = useState(false);
useEffect(() => {
let cancelled = false;
void api
.getPrefs()
.then((res) => {
if (!cancelled && res.prefs.showCliSessions === true) setShowCliSessionsState(true);
})
.catch(() => undefined); // Unreachable prefs: stay with the default (web only).
return () => {
cancelled = true;
};
}, []);
const setShowCliSessions = useCallback((value: boolean) => {
setShowCliSessionsState(value);
// Fire-and-forget: PUT /me/prefs merges shallowly; a lost write only costs persistence,
// the in-memory toggle already took effect.
void api.putPrefs({ showCliSessions: value }).catch(() => undefined);
}, []);
// Generation counter: invalidates any in-flight response once the Project/Agent set
// changes or a reload happens.
const gen = useRef(0);
@@ -142,6 +168,7 @@ export function SessionsProvider({ children }: { children: ReactNode }) {
limit: SIDEBAR_PAGE_SIZE + 1,
category,
...(category === "active" ? { withCounts: true } : {}),
...(showCliSessions ? { cli: true } : {}),
});
return {
category,
@@ -190,7 +217,7 @@ export function SessionsProvider({ children }: { children: ReactNode }) {
} finally {
if (g === gen.current) setLoading(false);
}
}, [projectId, agentIdsKey]);
}, [projectId, agentIdsKey, showCliSessions]);
useEffect(() => {
setSessions([]);
@@ -232,6 +259,7 @@ export function SessionsProvider({ children }: { children: ReactNode }) {
offset,
limit: SIDEBAR_PAGE_SIZE + 1,
category,
...(showCliSessions ? { cli: true } : {}),
})
).sessions;
return { agentId, ...splitPage(fetched, SIDEBAR_PAGE_SIZE) };
@@ -261,7 +289,7 @@ export function SessionsProvider({ children }: { children: ReactNode }) {
return next;
});
},
[projectId],
[projectId, showCliSessions],
);
const isLoadedFor = useCallback(
@@ -413,6 +441,8 @@ export function SessionsProvider({ children }: { children: ReactNode }) {
replace,
setStatus,
setTitle,
showCliSessions,
setShowCliSessions,
};
}, [
sessions,
@@ -428,6 +458,8 @@ export function SessionsProvider({ children }: { children: ReactNode }) {
replace,
setStatus,
setTitle,
showCliSessions,
setShowCliSessions,
]);
return <SessionsContext.Provider value={value}>{children}</SessionsContext.Provider>;