feat: add project-aware agent workspace
Generic Container CI/CD / test-build-publish (push) Successful in 2m9s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 2m39s

This commit is contained in:
2026-10-22 18:52:18 +08:00
parent c6a0e74e72
commit 2d8506fea0
191 changed files with 12373 additions and 3556 deletions
@@ -0,0 +1,82 @@
import { useCallback, useMemo, useRef, useState } from "react";
import useSWR from "swr";
import useSWRInfinite from "swr/infinite";
import type { AgentApiClient, AgentChatSessionSummary, AgentSessionPage } from "@/features/agent";
import { useWorkbenchSessionResources } from "./workbench-session-resources";
const TTL = 60_000;
export function useAgentHistory(client: AgentApiClient, scope: string) {
const resources = useWorkbenchSessionResources();
const [topicFilter, setTopicFilter] = useState<{ scope: string; id?: string }>({ scope });
const topicId = topicFilter.scope === scope ? topicFilter.id : undefined;
const setTopicId = useCallback((id: string | undefined) => setTopicFilter({ scope, id }), [scope]);
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const [enabled, setEnabled] = useState(false);
const [pendingUpdateKey, setPendingUpdateKey] = useState<string | null>(null);
const viewKey = JSON.stringify([scope, query, topicId]);
const view = useRef({ scope, query, topicId });
view.current = { scope, query, topicId };
const staged = useRef<{ key: string; page: AgentSessionPage } | null>(null);
const [orderVersion, setOrderVersion] = useState(0);
const key = useCallback((q: string, cursor?: string | null, topic?: string) => JSON.stringify(["history", scope, q, cursor ?? null, topic ?? null]), [scope]);
const fetchPage = useCallback((q: string, cursor?: string | null, force = false, topic?: string) => resources.cache.load(key(q, cursor, topic),
() => client.listSessionPage({ q, topicId: topic, includeCollaboration: true, cursor: cursor ?? undefined, signal: AbortSignal.timeout(15_000) }), TTL, { force }), [client, key, resources]);
const recent = useSWR(["agent-recent-sessions", scope], () => fetchPage(""), {
revalidateOnFocus: false, shouldRetryOnError: false,
});
const pages = useSWRInfinite<AgentSessionPage>(
(_index, previous: AgentSessionPage | null) => !enabled || (previous && !previous.hasMore) ? null
: ["agent-history-page", scope, query, previous?.nextCursor ?? null, topicId ?? null],
([, , q, cursor, topic]: [string, string, string, string | null, string | null]) => fetchPage(q, cursor, false, topic ?? undefined),
{ revalidateOnFocus: false, revalidateFirstPage: false, shouldRetryOnError: false, persistSize: false },
);
const sessions = useMemo(() => [...new Map((pages.data ?? (query || topicId ? [] : recent.data ? [recent.data] : []))
.flatMap((page) => page.sessions).map((session) => [session.id, session])).values()], [pages.data, query, recent.data, topicId]);
const refresh = useCallback(async () => {
const page = staged.current?.key === viewKey ? staged.current.page : await fetchPage(query, null, true, topicId);
if (view.current.scope !== scope || view.current.query !== query || view.current.topicId !== topicId) return;
staged.current = null;
await pages.mutate([page], { revalidate: false });
await pages.setSize(1);
if (!query && !topicId) await recent.mutate(page, { revalidate: false });
setPendingUpdateKey(null);
setOrderVersion((version) => version + 1);
}, [fetchPage, pages, query, recent, scope, topicId, viewKey]);
const ensure = useCallback(async () => {
setOpen(true);
setEnabled(true);
const stale = (resources.cache.peek(key(query, null, topicId))?.expiresAt ?? 0) <= Date.now();
const page = await fetchPage(query, null, false, topicId);
if (view.current.scope !== scope || view.current.query !== query || view.current.topicId !== topicId) return;
// Opening a window never invalidates a fresh page or hides existing content.
if (!pages.data?.length) await pages.mutate([page], { revalidate: false });
else if (stale) { staged.current = { key: viewKey, page }; setPendingUpdateKey(viewKey); }
if (!query && !topicId) await recent.mutate(page, { revalidate: false });
}, [fetchPage, key, pages, query, recent, resources, scope, topicId, viewKey]);
const update = useCallback(async (change: (sessions: AgentChatSessionSummary[]) => AgentChatSessionSummary[]) => {
resources.cache.updateMatching<AgentSessionPage>((cacheKey) => {
if (!cacheKey.startsWith('["history",')) return false;
const value = JSON.parse(cacheKey) as unknown[];
return value[0] === "history" && value[1] === scope;
}, (page) => ({ ...page, sessions: change(page.sessions) }));
const recentPage = resources.cache.peek<AgentSessionPage>(key(""))?.value;
if (recentPage) {
const next = { ...recentPage, sessions: change(recentPage.sessions) };
resources.cache.set(key(""), next, TTL);
if (view.current.scope === scope) await recent.mutate(next, { revalidate: false });
}
if (view.current.scope !== scope || view.current.query !== query || view.current.topicId !== topicId) return;
await pages.mutate((current) => current?.map((page) => ({ ...page, sessions: change(page.sessions) })), { revalidate: false });
// Search and later pages can now have different membership/order. Retain what is being read.
setPendingUpdateKey(viewKey);
}, [key, pages, query, recent, resources, scope, topicId, viewKey]);
return {
topicId, setTopicId, sessions, recentSessions: recent.data?.sessions ?? [], query, setQuery,
loading: (open ? pages.isLoading : recent.isLoading), error: pages.error ?? recent.error,
loadingMore: pages.isValidating, hasMore: pages.data?.at(-1)?.hasMore ?? recent.data?.hasMore ?? false,
loadMore: () => pages.setSize(pages.size + 1), refresh, ensure, update,
pendingUpdate: pendingUpdateKey === viewKey, isOpen: open, orderVersion, close: () => setOpen(false),
};
}