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:
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "输入 / 使用命令",
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user