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:
@@ -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