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
@@ -19,6 +19,7 @@ import {
} from "@/features/agent";
type AgentUiDataParts = {
session_created: Record<string, unknown>;
agent_message: Record<string, unknown>;
task_context: Record<string, unknown>;
execution_plan: Record<string, unknown>;
@@ -0,0 +1,76 @@
import { describe, expect, it, vi } from "vitest";
import type { ChatTransport, UIMessageChunk } from "ai";
import type { AgentApiClient } from "@/features/agent";
import { WorkbenchSessionResources } from "./workbench-session-resources";
import { createAgentRuntime, mergeHistoryMessages, resumeAgentRuntime, runtimeRunning } from "./agent-session-runtime";
import type { AgentUiMessage } from "./agent-session-message-state";
const message = (id: string, text = id): AgentUiMessage => ({ id, role: "assistant", parts: [{ type: "text", text }] });
describe("independent conversation runtimes", () => {
it("keeps the original response streaming after selecting another conversation", async () => {
const resources = new WorkbenchSessionResources();
let controller!: ReadableStreamDefaultController<UIMessageChunk>;
const transport: ChatTransport<AgentUiMessage> = {
sendMessages: async () => new ReadableStream({ start(stream) { controller = stream; } }),
reconnectToStream: async () => null,
};
const client = { refreshCredential: vi.fn(async () => {}), submitFrontendActionResult: vi.fn(async () => {}) } as unknown as AgentApiClient;
const first = createAgentRuntime(resources, "project:a", client, transport);
const second = createAgentRuntime(resources, "project:a", client, transport);
resources.activeAgent = first;
first.sessionId = "server-a";
const response = first.chat.sendMessage({ text: "核对城南压力" });
await vi.waitFor(() => expect(controller).toBeDefined());
controller.enqueue({ type: "start", messageId: "answer-a" });
controller.enqueue({ type: "text-start", id: "text-a" });
controller.enqueue({ type: "text-delta", id: "text-a", delta: "第一段" });
await vi.waitFor(() => expect(first.chat.messages.at(-1)?.parts).toEqual(expect.arrayContaining([expect.objectContaining({ text: "第一段" })])));
resources.activeAgent = second;
controller.enqueue({ type: "data-progress", data: { session_id: "another-session", title: "迟到的其他会话事件" } });
expect(runtimeRunning(first)).toBe(true);
controller.enqueue({ type: "data-credential_refresh_required", data: { request_id: "refresh-a", session_id: "server-a" } });
controller.enqueue({ type: "text-delta", id: "text-a", delta: ",后台完成" });
controller.enqueue({ type: "text-end", id: "text-a" });
controller.enqueue({ type: "finish", finishReason: "stop" });
controller.close();
await response;
expect(first.chat.messages.at(-1)?.parts).toEqual(expect.arrayContaining([expect.objectContaining({ text: "第一段,后台完成" })]));
expect(second.chat.messages).toEqual([]);
expect(first.sessionId).toBe("server-a");
expect(client.refreshCredential).toHaveBeenCalledWith("server-a", "refresh-a");
resources.dispose();
});
it("merges overlapping pages and a newer authoritative tail without duplicates", () => {
expect(mergeHistoryMessages([message("a"), message("b", "old")], [message("b", "new"), message("c")]))
.toEqual([message("a"), message("b", "new"), message("c")]);
expect(mergeHistoryMessages([message("c"), message("d")], [message("a"), message("b"), message("c", "new")]))
.toEqual([message("a"), message("b"), message("c", "new"), message("d")]);
});
it("routes background browser actions to a scoped refusal and keeps old message pages on resume", async () => {
const resources = new WorkbenchSessionResources();
const submit = vi.fn(async () => {});
const client = { submitFrontendActionResult: submit,
streamSession: vi.fn(async (_id, options) => {
options.onEvent({ type: "state", sessionId: "a", messages: [message("b", "new"), message("c")], isStreaming: true });
options.onEvent({ type: "frontend_action", sessionId: "a", data: {
version: "frontend-action@1", sessionId: "a", actionId: "action-a", toolCallId: "call-a", name: "locate_features",
params: {}, issuedAt: Date.now(), expiresAt: Date.now() + 15_000,
} });
options.onEvent({ type: "done", sessionId: "a", data: {} });
}),
} as unknown as AgentApiClient;
const transport = {} as ChatTransport<AgentUiMessage>;
const runtime = createAgentRuntime(resources, "project:a", client, transport, {
id: "a", sessionId: "a", title: "压力复核", createdAt: 1, updatedAt: 1, purpose: "ordinary_chat",
isTitleManuallyEdited: false, messages: [message("a"), message("b", "old")],
});
resumeAgentRuntime(resources, runtime);
await vi.waitFor(() => expect(runtime.controller).toBeUndefined());
expect(runtime.chat.messages).toEqual([message("a"), message("b", "new"), message("c")]);
expect(submit).toHaveBeenCalledWith("a", "action-a", expect.objectContaining({
status: "failed", error: expect.objectContaining({ code: "SESSION_NOT_ACTIVE" }),
}));
resources.dispose();
});
});
@@ -0,0 +1,203 @@
import { Chat } from "@ai-sdk/react";
import type { ChatTransport } from "ai";
import { parseFrontendActionRequest, type AgentApiClient, type AgentRunStatus, type AgentLoadedChatSession,
type AgentMessagePagination, type AgentSessionStreamEvent, type AgentSessionPurpose, type AgentTaskDiscussionContext, type AnswerUiSelection } from "@/features/agent";
import { showMapNotice } from "@/features/map/core";
import { applySessionStreamEvent, getDataString, toAgentChatMessages, toAgentUiMessages,
type AgentDataPart, type AgentUiMessage, type PermissionOverride, type QuestionOverride } from "./agent-session-message-state";
import type { WorkbenchSessionResources } from "./workbench-session-resources";
export type AgentReadingPosition = { messageId?: string; offset: number; atBottom: boolean };
export type AgentSessionRuntime = {
key: string;
scope: string;
client: AgentApiClient;
chat: Chat<AgentUiMessage>;
sessionId: string | null;
title: string;
topicId?: string | null;
purpose: AgentSessionPurpose;
taskId?: string;
draft: string;
reference?: AnswerUiSelection;
taskDiscussion?: { context: AgentTaskDiscussionContext; reportGeneration?: number | null };
permissionOverrides: Record<string, PermissionOverride>;
questionOverrides: Record<string, QuestionOverride>;
messagePage?: AgentMessagePagination;
readingPosition?: AgentReadingPosition;
loadedAt: number;
updatedAt: number;
resumed: boolean;
controller?: AbortController;
error?: string;
runStatus?: AgentRunStatus;
handlers?: {
onData: (part: AgentDataPart) => void;
onEvent: (event: AgentSessionStreamEvent) => void;
onError: (error: Error) => void;
onFinish: (status: AgentRunStatus) => void;
};
};
export function agentRuntimes(resources: WorkbenchSessionResources) {
return [...resources.agents.values()] as AgentSessionRuntime[];
}
export function runtimeRunning(runtime: AgentSessionRuntime) {
return runtime.resumed || runtime.chat.status === "submitted" || runtime.chat.status === "streaming";
}
export function runtimeNeedsInput(runtime: AgentSessionRuntime) {
return toAgentChatMessages(runtime.chat.messages, runtime.permissionOverrides, runtime.questionOverrides).some((message) =>
message.permissions?.some((permission) => permission.status === "pending") || message.questions?.some((question) => question.status === "pending"));
}
/** Server snapshots replace matching messages, while previously loaded pages retain their order. */
export function mergeHistoryMessages(current: AgentUiMessage[], incoming: AgentUiMessage[]) {
if (!current.length) return incoming;
if (!incoming.length) return current;
const firstOverlap = incoming.findIndex((message) => current.some((existing) => existing.id === message.id));
if (firstOverlap < 0) return [...current, ...incoming];
const index = current.findIndex((message) => message.id === incoming[firstOverlap].id);
const incomingById = new Map(incoming.map((message) => [message.id, message]));
const prefix = current.slice(0, index).filter((message) => !incomingById.has(message.id));
const suffix = current.slice(index).map((message) => incomingById.get(message.id) ?? message);
const known = new Set(suffix.map((message) => message.id));
return [...prefix, ...incoming.slice(0, firstOverlap), ...suffix, ...incoming.filter((message) => !known.has(message.id) && !incoming.slice(0, firstOverlap).includes(message))];
}
export function createAgentRuntime(resources: WorkbenchSessionResources, scope: string, client: AgentApiClient,
transport: ChatTransport<AgentUiMessage>, loaded?: AgentLoadedChatSession) {
const runtime = {
key: crypto.randomUUID(), scope, client, sessionId: loaded?.sessionId ?? null,
title: loaded?.title ?? "新对话", topicId: loaded?.topicId, purpose: loaded?.purpose ?? "ordinary_chat", taskId: loaded?.taskId,
draft: "", permissionOverrides: {}, questionOverrides: {}, messagePage: loaded?.messagePage,
loadedAt: loaded ? Date.now() : 0, updatedAt: loaded?.updatedAt ?? Date.now(), resumed: false, runStatus: loaded?.runStatus,
} as AgentSessionRuntime;
const active = () => resources.activeAgent === runtime;
const notify = () => queueMicrotask(() => resources.notify());
runtime.chat = new Chat<AgentUiMessage>({
id: runtime.key, transport, messages: toAgentUiMessages(loaded?.messages ?? []),
onData: (part) => {
if (resources.disposed) return;
const data = part as AgentDataPart;
const sessionId = getDataString(data.data, "session_id");
if (sessionId && runtime.sessionId && sessionId !== runtime.sessionId) return;
if (sessionId) runtime.sessionId = sessionId;
if (data.type === "data-session_title") runtime.title = getDataString(data.data, "title") ?? runtime.title;
if (data.type === "data-credential_refresh_required") {
void refreshRuntimeCredential(runtime, data.data);
} else if (!active() && data.type === "data-frontend_action") {
void rejectBackgroundAction(resources, runtime, data.data);
} else if (active()) runtime.handlers?.onData(data);
notify();
},
onError: (error) => { if (resources.disposed) return; runtime.error = error.message; runtime.runStatus = "error"; if (active()) runtime.handlers?.onError(error); notify(); },
onFinish: ({ isAbort, isError }) => {
if (resources.disposed) return;
runtime.loadedAt = Date.now();
runtime.updatedAt = Date.now();
runtime.runStatus = isAbort ? "aborted" : isError ? "error" : "completed";
if (active()) runtime.handlers?.onFinish(runtime.runStatus);
else notifyBackgroundCompletion(runtime);
notify();
},
});
resources.agents.set(runtime.key, runtime);
resources.disposers.add(() => { runtime.handlers = undefined; runtime.controller?.abort(); void runtime.chat.stop(); });
return runtime;
}
const credentialRequests = new WeakMap<AgentSessionRuntime, Set<string>>();
async function refreshRuntimeCredential(runtime: AgentSessionRuntime, data: Record<string, unknown>) {
const requestId = getDataString(data, "request_id");
if (!requestId || !runtime.sessionId) return;
const pending = credentialRequests.get(runtime) ?? new Set<string>();
credentialRequests.set(runtime, pending);
if (pending.has(requestId)) return;
pending.add(requestId);
try { await runtime.client.refreshCredential(runtime.sessionId, requestId); }
catch { pending.delete(requestId); runtime.error = "会话凭据续期失败,请重新登录后重试"; }
}
async function rejectBackgroundAction(resources: WorkbenchSessionResources, runtime: AgentSessionRuntime, data: Record<string, unknown>) {
const request = parseFrontendActionRequest(data);
if (!request || request.sessionId !== runtime.sessionId) return;
if (!resources.disposed) showMapNotice({ id: `agent-background-display-${runtime.key}`, tone: "info",
title: "后台对话需要展示结果", message: `回到“${runtime.title}”后重新请求地图或工作区展示。` });
await runtime.client.submitFrontendActionResult(request.sessionId, request.actionId, {
version: "frontend-action-result@1", actionId: request.actionId, status: "failed",
error: { code: "SESSION_NOT_ACTIVE", message: "返回对应会话后重新请求地图或工作区操作" }, completedAt: Date.now(),
}).catch(() => { runtime.error = "后台展示操作回执失败"; });
}
export function resumeAgentRuntime(resources: WorkbenchSessionResources, runtime: AgentSessionRuntime) {
if (!runtime.sessionId || runtime.controller || runtime.chat.status === "submitted" || runtime.chat.status === "streaming") return;
const controller = new AbortController();
runtime.controller = controller;
runtime.resumed = true;
runtime.runStatus = "running";
runtime.error = undefined;
void runtime.client.streamSession(runtime.sessionId, { signal: controller.signal, messageLimit: 40,
onEvent: (event) => {
if (resources.disposed) return;
const sessionId = event.type === "state" ? event.sessionId : event.sessionId ?? getDataString(event.data, "session_id");
if (sessionId && sessionId !== runtime.sessionId) return;
if (event.type === "state") {
const tail = toAgentUiMessages(event.messages);
const prior = runtime.chat.messages;
// Start filling a disconnected interval before reconciling the live tail.
if (prior.length && tail.length && !tail.some((message) => prior.some((old) => old.id === message.id))) {
const boundary = event.messagePage?.beforeCursor;
if (boundary) void fillHistoryGap(runtime, boundary, prior, controller.signal).then(() => resources.notify()).catch(() => {});
}
runtime.chat.messages = mergeHistoryMessages(prior, tail);
if (!runtime.messagePage) runtime.messagePage = event.messagePage;
runtime.resumed = event.isStreaming;
runtime.runStatus = event.runStatus ?? (event.isStreaming ? "running" : "completed");
} else {
if (event.type === "credential_refresh_required") void refreshRuntimeCredential(runtime, event.data);
else if (resources.activeAgent !== runtime && event.type === "frontend_action") void rejectBackgroundAction(resources, runtime, event.data);
runtime.chat.messages = applySessionStreamEvent(runtime.chat.messages, event);
if (event.type === "session_title") runtime.title = getDataString(event.data, "title") ?? runtime.title;
if (event.type === "done" || event.type === "error") {
runtime.resumed = false;
runtime.runStatus = event.type === "error" ? "error" : "completed";
runtime.updatedAt = Date.now();
}
}
if (resources.activeAgent === runtime && event.type !== "credential_refresh_required") runtime.handlers?.onEvent(event);
resources.notify();
},
}).catch((error: unknown) => {
if (!controller.signal.aborted) {
runtime.error = error instanceof Error ? error.message : "会话连接中断";
if (resources.activeAgent === runtime) runtime.handlers?.onError(new Error(runtime.error));
}
}).finally(() => {
if (runtime.resumed && !controller.signal.aborted && !runtime.error) runtime.error = "会话连接已断开,返回会话后重新连接";
if (runtime.controller === controller) { runtime.controller = undefined; runtime.resumed = false; }
runtime.loadedAt = Date.now();
if (!resources.disposed && !controller.signal.aborted && resources.activeAgent !== runtime) notifyBackgroundCompletion(runtime);
resources.notify();
});
}
function notifyBackgroundCompletion(runtime: AgentSessionRuntime) {
showMapNotice({ id: `agent-background-${runtime.key}`, tone: runtime.error || runtime.runStatus === "error" ? "error" : "info",
title: runtime.error || runtime.runStatus === "error" ? "后台对话处理失败" : runtime.runStatus === "aborted" ? "后台对话已停止" : "后台对话已完成",
message: runtime.error ?? runtime.title });
}
async function fillHistoryGap(runtime: AgentSessionRuntime, cursor: string, prior: AgentUiMessage[], signal: AbortSignal) {
let before: string | null = cursor;
const middle: AgentUiMessage[] = [];
while (before && !signal.aborted) {
const page = await runtime.client.loadOlderMessages(runtime.sessionId!, before, signal);
const messages = toAgentUiMessages(page.messages);
middle.unshift(...messages);
if (messages.some((message) => prior.some((old) => old.id === message.id))) break;
before = page.messagePage.beforeCursor;
}
if (!signal.aborted) runtime.chat.messages = mergeHistoryMessages(mergeHistoryMessages(prior, middle), runtime.chat.messages);
}
@@ -0,0 +1,33 @@
import { act, createElement } from "react";
import { createRoot } from "react-dom/client";
import { SWRConfig } from "swr";
import { expect, it, vi } from "vitest";
import type { AgentApiClient, AgentSessionPage } from "@/features/agent";
import { useAgentHistory } from "./use-agent-history";
import { WorkbenchSessionProvider } from "./workbench-session-provider";
it("keeps a late history refresh in its original project cache", async () => {
const clock = vi.spyOn(Date, "now").mockReturnValue(100);
const root = createRoot(document.createElement("div"));
const page = (id: string): AgentSessionPage => ({ sessions: [{ id, title: id, createdAt: 1, updatedAt: 1, purpose: "ordinary_chat" }], hasMore: false, nextCursor: null });
let finish!: (page: AgentSessionPage) => void;
const first = vi.fn().mockResolvedValueOnce(page("project-a-original")).mockImplementationOnce(() => new Promise<AgentSessionPage>((resolve) => { finish = resolve; }));
const second = vi.fn(async () => page("project-b-only"));
const clients = { a: { listSessionPage: first }, b: { listSessionPage: second } } as unknown as Record<"a" | "b", AgentApiClient>;
let history!: ReturnType<typeof useAgentHistory>;
function Probe({ scope }: { scope: "a" | "b" }) { history = useAgentHistory(clients[scope], scope); return null; }
const swrCache = new Map();
const render = (scope: "a" | "b") => root.render(createElement(WorkbenchSessionProvider, null,
createElement(SWRConfig, { value: { provider: () => swrCache, dedupingInterval: 0 } }, createElement(Probe, { scope }))));
try {
await act(async () => render("a"));
expect(history.recentSessions[0]?.id).toBe("project-a-original");
clock.mockReturnValue(61_000);
let pending!: Promise<void>;
await act(async () => { pending = history.ensure(); });
await act(async () => render("b"));
await act(async () => { finish(page("project-a-late-result")); await pending; });
expect(history.sessions.map((session) => session.id)).toEqual(["project-b-only"]);
expect(second).toHaveBeenCalledTimes(1);
} finally { await act(async () => root.unmount()); clock.mockRestore(); }
});
@@ -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),
};
}
@@ -1,77 +0,0 @@
import {
useCallback,
useEffect,
useRef,
useState,
type PointerEvent as ReactPointerEvent
} from "react";
import {
clampAgentPanelWidth,
getWorkbenchViewportLayout
} from "../layout/workbench-layout";
export function useAgentPanelResize(viewportWidth: number, conditionExpanded = false) {
const panelRef = useRef<HTMLDivElement | null>(null);
const cleanupRef = useRef<(() => void) | null>(null);
const [width, setWidth] = useState<number | null>(null);
const [resizing, setResizing] = useState(false);
useEffect(() => () => cleanupRef.current?.(), []);
const handlePointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 0 || !panelRef.current) return;
event.preventDefault();
cleanupRef.current?.();
const startX = event.clientX;
const startWidth = panelRef.current.getBoundingClientRect().width;
const handle = event.currentTarget;
const pointerId = event.pointerId;
handle.setPointerCapture(pointerId);
setResizing(true);
const handlePointerMove = (pointerEvent: PointerEvent) => {
if (pointerEvent.pointerId !== pointerId) return;
pointerEvent.preventDefault();
setWidth(clampAgentPanelWidth(
startWidth + pointerEvent.clientX - startX,
window.innerWidth,
conditionExpanded
));
};
const stopResizing = (pointerEvent?: PointerEvent) => {
if (pointerEvent && pointerEvent.pointerId !== pointerId) return;
cleanupRef.current?.();
setResizing(false);
};
const handleBlur = () => stopResizing();
const cleanup = () => {
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", stopResizing);
window.removeEventListener("pointercancel", stopResizing);
window.removeEventListener("blur", handleBlur);
if (handle.hasPointerCapture(pointerId)) handle.releasePointerCapture(pointerId);
cleanupRef.current = null;
};
cleanupRef.current = cleanup;
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", stopResizing);
window.addEventListener("pointercancel", stopResizing);
window.addEventListener("blur", handleBlur);
},
[conditionExpanded]
);
return {
handlePointerDown,
panelRef,
resizing,
width: clampAgentPanelWidth(
width ?? getWorkbenchViewportLayout(viewportWidth).agentWidth,
viewportWidth,
conditionExpanded
)
};
}
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { collectRecentAgentActivities } from "./use-agent-prompt-suggestions";
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, createElement } from "react";
import { createRoot, type Root } from "react-dom/client";
import { collectRecentAgentActivities, useAgentPromptSuggestions } from "./use-agent-prompt-suggestions";
import { WorkbenchSessionProvider } from "./workbench-session-provider";
import type { ScheduledConditionItem } from "../types";
import type { AgentChatSessionSummary } from "@/features/agent";
describe("recent activities for Agent suggestions", () => {
@@ -21,3 +25,56 @@ describe("recent activities for Agent suggestions", () => {
expect(activities.filter((item) => item.kind === "session").map((item) => item.title)).toEqual(["updated-old-conversation"]);
});
});
describe("recommendation cache lifecycle", () => {
let root: Root | undefined;
afterEach(async () => { if (root) await act(async () => root!.unmount()); root = undefined; vi.restoreAllMocks(); });
it("reuses recommendations across history refresh, panel reopen and a new empty conversation", async () => {
const container = document.createElement("div");
root = createRoot(container);
const request = vi.fn(async () => [{ question: "核查当前压力", activityId: null }]);
const empty: never[] = [];
function Probe({ enabled, sessionId }: { enabled: boolean; sessionId: string | null }) {
const result = useAgentPromptSuggestions({ enabled, model: "fixture/model", cacheScope: "project:a", sessionId,
request, alerts: empty, alertDataSource: "backend", conditions: empty, runtimeDataSource: "backend", artifact: null, sessions: empty });
return createElement("p", null, result.suggestions[0]?.question ?? "loading");
}
for (let i = 0; i < 10; i++) {
await act(async () => root!.render(createElement(WorkbenchSessionProvider, null, createElement(Probe, { enabled: false, sessionId: null }))));
await act(async () => root!.render(createElement(WorkbenchSessionProvider, null, createElement(Probe, { enabled: true, sessionId: `empty-${i}` }))));
}
expect(request).toHaveBeenCalledTimes(1);
expect(container.textContent).toBe("核查当前压力");
});
it("ignores task heartbeat timestamps but regenerates when an activity changes status", async () => {
root = createRoot(document.createElement("div"));
const request = vi.fn(async () => [{ question: "复核任务证据", activityId: "task:task-a" }]);
const empty: never[] = [];
function Probe({ time, status }: { time: number; status: "running" | "completed" }) {
const condition: ScheduledConditionItem = { id: "task-a", kind: "condition", taskId: "scada-diagnosis", sessionId: "native-a",
title: "SCADA 证据复核", summary: "检查压力监测", riskLevel: "normal", scheduledAt: "2026-10-22T01:00:00Z", updatedAt: time, status };
useAgentPromptSuggestions({ enabled: true, model: "fixture/model", cacheScope: "project:a", sessionId: null, request,
alerts: empty, alertDataSource: "backend", conditions: [condition], runtimeDataSource: "backend", artifact: null, sessions: empty });
return null;
}
for (let i = 0; i < 10; i++) await act(async () => root!.render(createElement(WorkbenchSessionProvider, null,
createElement(Probe, { time: 1000 + i * 5000, status: "running" }))));
expect(request).toHaveBeenCalledTimes(1);
await act(async () => root!.render(createElement(WorkbenchSessionProvider, null, createElement(Probe, { time: 60000, status: "completed" }))));
expect(request).toHaveBeenCalledTimes(2);
});
it("isolates projects and caches generation errors instead of retrying each reopen", async () => {
root = createRoot(document.createElement("div"));
const request = vi.fn(async () => { throw new Error("unavailable"); });
const empty: never[] = [];
function Probe({ scope, enabled }: { scope: string; enabled: boolean }) {
useAgentPromptSuggestions({ enabled, cacheScope: scope, model: "fixture/model", sessionId: null,
request, alerts: empty, alertDataSource: "backend", conditions: empty, runtimeDataSource: "backend", artifact: null, sessions: empty });
return null;
}
for (const [scope, enabled] of [["a", true], ["a", false], ["a", true], ["b", true]] as const) {
await act(async () => root!.render(createElement(WorkbenchSessionProvider, null, createElement(Probe, { scope, enabled }))));
}
expect(request).toHaveBeenCalledTimes(2);
});
});
@@ -3,6 +3,8 @@ import type { AgentApiClient, AgentChatSessionSummary, AgentPromptSuggestion, Ag
import type { WorkbenchAlert, ScheduledConditionItem, WorkbenchAlertDataSource, WorkbenchRuntimeDataSource } from "../types";
import type { AnalysisArtifact } from "../workspace/workspace-model";
import { useWorkbenchSessionResources } from "./workbench-session-resources";
type ActivityInputs = {
alerts: WorkbenchAlert[];
alertDataSource: WorkbenchAlertDataSource;
@@ -44,38 +46,52 @@ export function collectRecentAgentActivities(input: ActivityInputs, now = Date.n
return [...new Map([...alerts, ...tasks, ...analyses, ...sessions].map((item) => [item.id, item])).values()];
}
export function useAgentPromptSuggestions({ enabled, model, sessionId, request, alerts, alertDataSource, conditions, runtimeDataSource, artifact, sessions }: ActivityInputs & {
export function useAgentPromptSuggestions({ enabled, model, cacheScope = "project", request, alerts, alertDataSource, conditions, runtimeDataSource, artifact, sessions }: ActivityInputs & {
enabled: boolean;
model: string;
sessionId: string | null;
cacheScope?: string;
request: AgentApiClient["generatePromptSuggestions"];
}) {
const { cache } = useWorkbenchSessionResources();
const activities = useMemo(() => collectRecentAgentActivities({ alerts, alertDataSource, conditions, runtimeDataSource, artifact, sessions }),
[alerts, alertDataSource, conditions, runtimeDataSource, artifact, sessions]);
const snapshot = useRef(activities);
useEffect(() => { snapshot.current = activities; }, [activities]);
const key = JSON.stringify([enabled, model, sessionId]);
const [state, setState] = useState<{ key: string; suggestions: DisplaySuggestion[]; loading: boolean; unavailable: boolean }>({
key: "", suggestions: [], loading: false, unavailable: false,
// Heartbeat timestamps change while the activity itself stays the same.
const digest = JSON.stringify([...activities].sort((a, b) => a.id.localeCompare(b.id)).map((activity) => ({
id: activity.id, kind: activity.kind, title: activity.title, summary: activity.summary,
status: activity.status, origin: activity.origin,
})));
const activitySnapshot = useRef(activities);
activitySnapshot.current = activities;
const key = JSON.stringify(["suggestions", cacheScope, model, digest]);
const [state, setState] = useState<{ scope: string; suggestions: DisplaySuggestion[]; loading: boolean; unavailable: boolean }>({
scope: cacheScope, suggestions: [], loading: false, unavailable: false,
});
useEffect(() => {
if (!enabled) return;
const controller = new AbortController();
const context = snapshot.current;
setState({ key, suggestions: [], loading: true, unavailable: false });
void request(context, model, controller.signal).then((suggestions) => {
if (controller.signal.aborted) return;
let disposed = false;
const previous = cache.peek<DisplaySuggestion[]>(key);
const apply = (suggestions: DisplaySuggestion[]) => {
if (!disposed) setState({ scope: cacheScope, suggestions, loading: false, unavailable: false });
};
if (previous?.value) apply(previous.value);
else setState((current) => ({ scope: cacheScope,
suggestions: current.scope === cacheScope ? current.suggestions : [], loading: true, unavailable: false }));
void cache.load(key, async () => {
const context = activitySnapshot.current;
const suggestions = await request(context, model, AbortSignal.timeout(60_000));
const sourceById = new Map(context.map((item) => [item.id, item]));
setState({ key, loading: false, unavailable: false, suggestions: suggestions.map((item) => {
return suggestions.map((item) => {
const source = item.activityId ? sourceById.get(item.activityId) : undefined;
return { ...item, sourceLabel: source
? `${source.origin === "demo" ? "演示" : ""}${activityLabels[source.kind]} · ${timeFormatter.format(new Date(source.occurred_at))}`
: "供水任务建议" };
}) });
}).catch(() => {
if (!controller.signal.aborted) setState({ key, suggestions: [], loading: false, unavailable: true });
});
}, 10 * 60_000, { errorTtl: 30_000 }).then(apply).catch(() => {
if (!disposed) setState((current) => ({ scope: cacheScope,
suggestions: current.scope === cacheScope ? current.suggestions : [], loading: false, unavailable: true }));
});
return () => controller.abort();
}, [enabled, key, model, request]);
return state.key === key ? state : { suggestions: [], loading: enabled, unavailable: false };
return () => { disposed = true; };
}, [cache, cacheScope, digest, enabled, key, model, request]);
return state.scope === cacheScope ? state : { suggestions: [], loading: enabled, unavailable: false };
}
@@ -0,0 +1,58 @@
import { act, createElement } from "react";
import { createRoot } from "react-dom/client";
import { SWRConfig } from "swr";
import { expect, it, vi } from "vitest";
import type { AgentApiClient, AgentTopic } from "@/features/agent";
import { useAgentTopics } from "./use-agent-topics";
import { WorkbenchSessionProvider } from "./workbench-session-provider";
it("reuses theme reads and keeps a late project result out of the personal theme list", async () => {
const topic = (id: string): AgentTopic => ({ id, name: id, createdAt: "2026-10-22T01:00:00Z", updatedAt: "2026-10-22T01:00:00Z" });
let finish!: (topics: AgentTopic[]) => void;
const projectRead = vi.fn(() => new Promise<AgentTopic[]>((resolve) => { finish = resolve; }));
const personalRead = vi.fn(async () => [topic("personal-theme")]);
const clients = { project: { listTopics: projectRead }, personal: { listTopics: personalRead } } as unknown as Record<"project" | "personal", AgentApiClient>;
const root = createRoot(document.createElement("div"));
const cache = new Map();
let topics!: ReturnType<typeof useAgentTopics>;
function Probe({ scope }: { scope: "project" | "personal" }) { topics = useAgentTopics(clients[scope], scope); return null; }
const render = (scope: "project" | "personal") => root.render(createElement(WorkbenchSessionProvider, null,
createElement(SWRConfig, { value: { provider: () => cache, dedupingInterval: 0 } }, createElement(Probe, { scope }))));
try {
await act(async () => render("project"));
let pending!: Promise<void>;
await act(async () => { pending = topics.ensure(); });
await act(async () => render("personal"));
await act(async () => { finish([topic("project-theme")]); await pending; });
expect(topics.topics.map((entry) => entry.id)).toEqual(["personal-theme"]);
for (let i = 0; i < 10; i++) await act(async () => { await topics.ensure(); });
expect(projectRead).toHaveBeenCalledTimes(1);
expect(personalRead).toHaveBeenCalledTimes(1);
} finally { await act(async () => root.unmount()); }
});
it("keeps a newly created topic selectable when the open menu's older read finishes later", async () => {
const topic = (id: string): AgentTopic => ({ id, name: id, createdAt: "2026-10-22T01:00:00Z", updatedAt: "2026-10-22T01:00:00Z" });
let finish!: (topics: AgentTopic[]) => void;
const client = {
listTopics: vi.fn(() => new Promise<AgentTopic[]>((resolve) => { finish = resolve; })),
createTopic: vi.fn(async () => topic("new-topic")),
} as unknown as AgentApiClient;
let topics!: ReturnType<typeof useAgentTopics>;
function Probe() { topics = useAgentTopics(client, "project:slow-read"); return null; }
const root = createRoot(document.createElement("div"));
const cache = new Map();
try {
await act(async () => root.render(createElement(WorkbenchSessionProvider, null,
createElement(SWRConfig, { value: { provider: () => cache, dedupingInterval: 0 } }, createElement(Probe)))));
let reading!: Promise<void>;
let creating!: Promise<AgentTopic>;
await act(async () => { reading = topics.ensure(); });
await act(async () => { creating = topics.create("new-topic"); });
await act(async () => { finish([topic("existing-topic")]); await reading; await creating; });
expect(topics.topics.map((entry) => entry.id)).toEqual(["existing-topic", "new-topic"]);
await act(async () => { await topics.ensure(); });
expect(topics.topics.map((entry) => entry.id)).toEqual(["existing-topic", "new-topic"]);
expect(client.listTopics).toHaveBeenCalledTimes(1);
} finally { await act(async () => root.unmount()); }
});
@@ -0,0 +1,33 @@
import { useCallback, useRef } from "react";
import useSWR from "swr";
import type { AgentApiClient, AgentTopic } from "@/features/agent";
import { useWorkbenchSessionResources } from "./workbench-session-resources";
const TTL = 5 * 60_000;
export function useAgentTopics(client: AgentApiClient, scope: string) {
const resources = useWorkbenchSessionResources();
const currentScope = useRef(scope);
currentScope.current = scope;
const key = JSON.stringify(["agent-topics", scope]);
const fetchTopics = useCallback(() => resources.cache.load(key, () => client.listTopics(), TTL), [client, key, resources]);
const state = useSWR(["agent-topics", scope], fetchTopics, { revalidateOnFocus: false, shouldRetryOnError: false });
const save = async (change: (current: AgentTopic[]) => AgentTopic[]) => {
const next = change(resources.cache.peek<AgentTopic[]>(key)?.value ?? state.data ?? []);
if (resources.disposed) return;
resources.cache.set(key, next, TTL);
if (currentScope.current === scope) await state.mutate(next, { revalidate: false });
};
const ensure = async () => {
const topics = await fetchTopics();
if (!resources.disposed && currentScope.current === scope) await state.mutate(topics, { revalidate: false });
};
return {
topics: state.data ?? [], loading: state.isLoading, error: state.error,
ensure,
// Finish the shared read before writing, so a late menu snapshot cannot replace the mutation.
create: async (name: string) => { await ensure(); const topic = await client.createTopic(name); await save((current) => [...current, topic]); return topic; },
rename: async (id: string, name: string) => { await ensure(); const topic = await client.renameTopic(id, name); await save((current) => current.map((entry) => entry.id === id ? topic : entry)); },
remove: async (id: string) => { await ensure(); await client.deleteTopic(id); await save((current) => current.filter((topic) => topic.id !== id)); },
};
}
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import type { WorkbenchAccess } from "../api/request-context";
import {
fetchElementGisProperties,
fetchSourceGisFeatureProperties,
@@ -18,7 +18,7 @@ const INITIAL_STATE: GisPropertiesState = { status: "idle", groups: [] };
export function useElementGisProperties(
feature: DetailFeature | null,
projectId: string | null,
getAccessToken?: AccessTokenProvider
getAccessToken?: WorkbenchAccess
) {
const [state, setState] = useState<GisPropertiesState>(INITIAL_STATE);
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import type { FeatureCollection } from "geojson";
import type { Map as MapLibreMap, PaddingOptions } from "maplibre-gl";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import type { WorkbenchAccess } from "../api/request-context";
import { fetchOperationalTask, fetchOperationalTaskPresentationCursor, type OperationalTask } from "../api/operational-task-client";
import type { InvestigationMapTarget } from "../api/investigation-report";
import type { InvestigationOverlay, InvestigationPresentationCommand } from "../api/investigation-presentation";
@@ -13,7 +13,7 @@ import { InvestigationPresentationFollower } from "./investigation-presentation-
export type InvestigationChart = Extract<InvestigationPresentationCommand, { action: "open_chart" }>;
type Options = {
enabled: boolean; task: OperationalTask | null; projectId: string | null;
sync: WorkbenchSyncState; getAccessToken?: AccessTokenProvider;
sync: WorkbenchSyncState; getAccessToken?: WorkbenchAccess;
mapRef: RefObject<MapLibreMap | null>; mapReady: boolean;
controller: WorkbenchMapController; getPadding: () => PaddingOptions;
onClearLayers: () => void;
@@ -1,5 +1,8 @@
import { renderHook } from "@testing-library/react";
import type { Map as MapLibreMap } from "maplibre-gl";
import { describe, expect, it, vi } from "vitest";
import {
useMapInteractions,
clearMapFeatureInteractionState,
isPointOccludedByCityBuilding,
setMapFeatureInteractionState,
@@ -71,3 +74,17 @@ describe("map feature interaction state", () => {
expect(queryRenderedFeatures).not.toHaveBeenCalled();
});
});
it("suspends feature picking while the map is used to look up a place", () => {
const canvas = document.createElement("canvas");
const map = { on: vi.fn(), off: vi.fn(), getLayer: vi.fn(() => ({})), getCanvas: () => canvas };
const props = { enabled: false, mapRef: { current: map as unknown as MapLibreMap },
availableSourceIds: ["pipes"] as const, mapReady: true, onSelectFeature: vi.fn(), selectedFeature: null };
const { rerender } = renderHook(useMapInteractions, { initialProps: props });
expect(map.on).not.toHaveBeenCalled();
rerender({ ...props, enabled: true });
expect(map.on).toHaveBeenCalledWith("click", expect.any(Array), expect.any(Function));
rerender(props);
expect(map.off).toHaveBeenCalledWith("click", expect.any(Array), expect.any(Function));
});
@@ -58,6 +58,7 @@ export function isPointOccludedByCityBuilding(map: CityOcclusionMap, point: Poin
}
type UseMapInteractionsOptions = {
enabled?: boolean;
availableSourceIds: readonly WaterNetworkSourceId[];
mapRef: RefObject<MapLibreMap | null>;
mapReady: boolean;
@@ -66,6 +67,7 @@ type UseMapInteractionsOptions = {
};
export function useMapInteractions({
enabled = true,
availableSourceIds,
mapRef,
mapReady,
@@ -74,7 +76,7 @@ export function useMapInteractions({
}: UseMapInteractionsOptions) {
useEffect(() => {
const map = mapRef.current;
if (!mapReady || !map) return;
if (!mapReady || !map || !enabled) return;
let hoveredFeature: FeatureReference | null = null;
@@ -127,12 +129,15 @@ export function useMapInteractions({
map.on("click", hitLayerIds, handleClick);
return () => {
// eslint-disable-next-line react-hooks/exhaustive-deps -- Read the live owner ref to detect map disposal.
if (mapRef.current !== map) return;
clearHoveredFeature();
map.getCanvas().style.cursor = "";
map.off("mousemove", hitLayerIds, handleMouseMove);
map.off("mouseleave", hitLayerIds, handleMouseLeave);
map.off("click", hitLayerIds, handleClick);
};
}, [availableSourceIds, mapRef, mapReady, onSelectFeature, selectedFeature]);
}, [availableSourceIds, mapRef, mapReady, onSelectFeature, selectedFeature, enabled]);
useEffect(() => {
const map = mapRef.current;
@@ -142,7 +147,8 @@ export function useMapInteractions({
setMapFeatureInteractionState(map, next, { selected: true, hovered: false });
}
return () => {
if (next && map.getSource(next.source)) {
// eslint-disable-next-line react-hooks/exhaustive-deps -- Read the live owner ref to detect map disposal.
if (mapRef.current === map && next && map.getSource(next.source)) {
clearMapFeatureInteractionState(map, next, "selected");
}
};
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import type { WorkbenchAccess } from "../api/request-context";
import {
acknowledgeOperationalTaskNotification,
createOperationalRequestId,
@@ -23,7 +23,7 @@ import type { WorkbenchEvent } from "../api/workbench-sync-client";
import type { WorkbenchSyncState } from "./use-workbench-sync";
export type ManualInvestigationKind = "water_quality_complaint" | "burst_report";
export type ManualInvestigationKind = "water_quality_complaint" | "burst_report" | "scada_anomaly";
export type CreateInvestigationInput = {
kind: ManualInvestigationKind;
title: string;
@@ -66,7 +66,7 @@ export function selectHeaderNotifications(items: OperationalTaskNotification[])
}
export function useOperationalTasks(
getAccessToken: AccessTokenProvider | undefined,
getAccessToken: WorkbenchAccess | undefined,
sync: WorkbenchSyncState
) {
const syncEvents = sync.events;
File diff suppressed because it is too large Load Diff
@@ -40,6 +40,7 @@ export type WorkbenchDrawingAnnotation = {
};
type UseWorkbenchDrawingOptions = {
selectionEnabled?: boolean;
mapRef: RefObject<MapLibreMap | null>;
mapReady: boolean;
activeMode: WorkbenchDrawMode | null;
@@ -47,6 +48,7 @@ type UseWorkbenchDrawingOptions = {
};
export function useWorkbenchDrawing({
selectionEnabled = true,
mapRef,
mapReady,
activeMode,
@@ -134,7 +136,7 @@ export function useWorkbenchDrawing({
useEffect(() => {
const map = mapRef.current;
if (!mapReady || !map || activeMode) {
if (!mapReady || !map || activeMode || !selectionEnabled) {
return;
}
@@ -167,6 +169,9 @@ export function useWorkbenchDrawing({
});
return () => {
// The map owner may already have disposed this instance during project switching.
// eslint-disable-next-line react-hooks/exhaustive-deps -- Read the live owner ref to detect map disposal.
if (mapRef.current !== activeMap) return;
DRAWING_INTERACTIVE_LAYER_IDS.forEach((layerId) => {
if (activeMap.getLayer(layerId)) {
activeMap.off("click", layerId, handleDrawingClick);
@@ -175,7 +180,7 @@ export function useWorkbenchDrawing({
}
});
};
}, [activeMode, mapReady, mapRef]);
}, [activeMode, mapReady, mapRef, selectionEnabled]);
const updateDraft = useCallback(
(draft: DrawingDraftState | null) => {
@@ -1,17 +1,18 @@
import { act, cleanup, renderHook } from "@testing-library/react";
import type { Map as MapLibreMap } from "maplibre-gl";
import type { Map as MapLibreMap, MapMouseEvent } from "maplibre-gl";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { searchPlaces, type PlaceSearchItem, type PlaceSearchResults } from "../api/place-search-client";
import { reverseGeocodePoint, searchPlaces, type PlaceSearchItem, type PlaceSearchResults } from "../api/place-search-client";
import { locateGeocodingResult, markGeocodingLocation } from "../map/geocoding-location";
import { useWorkbenchGeocoding } from "./use-workbench-geocoding";
vi.mock("../api/place-search-client", () => ({ searchPlaces: vi.fn() }));
vi.mock("../api/place-search-client", () => ({ searchPlaces: vi.fn(), reverseGeocodePoint: vi.fn() }));
vi.mock("../map/geocoding-location", () => ({ locateGeocodingResult: vi.fn(), markGeocodingLocation: vi.fn() }));
const location: PlaceSearchItem = { id: "place-1", name: "南桥路", address: "奉贤区南桥路", longitude: 121.44, latitude: 31.22, category: "道路名", district: "奉贤区" };
const other = { ...location, id: "place-2", address: "奉贤区南桥路", longitude: 121.35, latitude: 30.77 };
const results: PlaceSearchResults = { items: [location, other], total: 2, truncated: false, region: "奉贤区" };
const remove = vi.fn();
const options = {
placeSearch: { enabled: true, provider: "tianditu" as const, admin_code: "310120", region: "奉贤区" },
active: true, projectId: "project-1", mapRef: { current: {} as MapLibreMap }, mapReady: true,
getPadding: () => ({ top: 48, right: 400, bottom: 48, left: 48 }), reducedMotion: true, onLocate: vi.fn()
};
@@ -22,6 +23,23 @@ beforeEach(() => {
afterEach(cleanup);
describe("place search selection lifecycle", () => {
it("disables search without project configuration, even for the original workspace", async () => {
const { result } = renderHook(useWorkbenchGeocoding, {
initialProps: { ...options, placeSearch: null }
});
act(() => result.current.changeAddress("南桥路"));
await act(() => result.current.search());
expect(result.current.configured).toBe(false);
expect(result.current.error).toBe("当前项目暂未配置地名搜索");
expect(searchPlaces).not.toHaveBeenCalled();
});
it("uses the selected project's configured region", async () => {
const { result } = renderHook(useWorkbenchGeocoding, {
initialProps: { ...options, placeSearch: { ...options.placeSearch, admin_code: "341126", region: "凤阳县" } }
});
expect(result.current.configured).toBe(true);
expect(result.current.region).toBe("凤阳县");
});
it("does not move until selection and replaces the previous marker", async () => {
vi.mocked(searchPlaces).mockResolvedValue(results);
const { result, rerender } = renderHook(useWorkbenchGeocoding, { initialProps: options });
@@ -84,3 +102,95 @@ describe("place search selection lifecycle", () => {
unmount();
});
});
function pickingMap() {
const canvas = document.createElement("canvas");
const listeners = new Map<string, (event: MapMouseEvent) => void>();
const map = {
on: vi.fn((name: string, handler: (event: MapMouseEvent) => void) => listeners.set(name, handler)),
off: vi.fn((name: string) => listeners.delete(name)),
getCanvas: () => canvas
};
return {
map: map as unknown as MapLibreMap,
canvas,
click: (longitude = location.longitude, latitude = location.latitude) => listeners.get("click")?.({
lngLat: { lng: longitude, lat: latitude }, preventDefault: vi.fn()
} as unknown as MapMouseEvent)
};
}
describe("map point place lookup", () => {
it("ends picking after a successful lookup and keeps the marker and camera unchanged", async () => {
const map = pickingMap();
const onPickingChange = vi.fn();
const props = { ...options, picking: true, onPickingChange, mapRef: { current: map.map } };
vi.mocked(reverseGeocodePoint).mockResolvedValue(location);
const { result, rerender } = renderHook(useWorkbenchGeocoding, { initialProps: props });
onPickingChange.mockClear();
expect(map.canvas.style.cursor).toBe("crosshair");
await act(async () => { map.click(); });
expect(result.current.result).toEqual(location);
expect(result.current.origin).toBe("map");
expect(result.current.choosing).toBe(false);
expect(onPickingChange).toHaveBeenCalledExactlyOnceWith(false);
expect(markGeocodingLocation).toHaveBeenCalledWith(map.map, location);
expect(locateGeocodingResult).not.toHaveBeenCalled();
act(() => result.current.locate());
expect(locateGeocodingResult).toHaveBeenCalledOnce();
rerender({ ...props, picking: false });
expect(map.canvas.style.cursor).toBe("");
expect(map.map.off).toHaveBeenCalledWith("click", expect.any(Function));
});
it("ignores an older point after a second click", async () => {
const map = pickingMap();
let first!: (value: PlaceSearchItem) => void;
let second!: (value: PlaceSearchItem) => void;
vi.mocked(reverseGeocodePoint)
.mockReturnValueOnce(new Promise((resolve) => { first = resolve; }))
.mockReturnValueOnce(new Promise((resolve) => { second = resolve; }));
const { result } = renderHook(() => useWorkbenchGeocoding({ ...options, picking: true, mapRef: { current: map.map } }));
act(() => map.click());
const firstSignal = vi.mocked(reverseGeocodePoint).mock.calls[0][4];
act(() => map.click(other.longitude, other.latitude));
expect(firstSignal?.aborted).toBe(true);
await act(async () => { second(other); });
await act(async () => { first(location); });
expect(result.current.result).toEqual(other);
});
it.each(["close", "project", "escape"])("aborts an outstanding map lookup on %s", async (action) => {
const map = pickingMap();
let resolve!: (value: PlaceSearchItem) => void;
vi.mocked(reverseGeocodePoint).mockReturnValue(new Promise((done) => { resolve = done; }));
const props = { ...options, picking: true, onPickingChange: vi.fn(), mapRef: { current: map.map } };
const { result, rerender } = renderHook(useWorkbenchGeocoding, { initialProps: props });
act(() => map.click());
const signal = vi.mocked(reverseGeocodePoint).mock.calls[0][4];
if (action === "close") rerender({ ...props, active: false });
if (action === "project") rerender({ ...props, projectId: "other-project" });
if (action === "escape") act(() => window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })));
expect(signal?.aborted).toBe(true);
await act(async () => { resolve(location); });
expect(result.current.result).toBeNull();
expect(result.current.pending).toBe(false);
expect(props.onPickingChange).toHaveBeenCalledWith(false);
});
it("shows a lookup error and accepts another point", async () => {
const map = pickingMap();
const onPickingChange = vi.fn();
vi.mocked(reverseGeocodePoint).mockRejectedValueOnce(new Error("取点超出项目范围")).mockResolvedValueOnce(location);
const { result } = renderHook(() => useWorkbenchGeocoding({ ...options, picking: true, onPickingChange, mapRef: { current: map.map } }));
onPickingChange.mockClear();
await act(async () => { map.click(); });
expect(result.current.error).toBe("取点超出项目范围");
expect(onPickingChange).not.toHaveBeenCalled();
await act(async () => { map.click(); });
expect(result.current.error).toBeNull();
expect(result.current.result).toEqual(location);
expect(onPickingChange).toHaveBeenCalledExactlyOnceWith(false);
});
});
@@ -1,13 +1,17 @@
import type { Map as MapLibreMap, PaddingOptions } from "maplibre-gl";
import type { Map as MapLibreMap, MapMouseEvent, PaddingOptions } from "maplibre-gl";
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { searchPlaces, type PlaceSearchItem, type PlaceSearchResults } from "../api/place-search-client";
import type { WorkbenchAccess } from "../api/request-context";
import { reverseGeocodePoint, searchPlaces, type PlaceSearchItem, type PlaceSearchResults } from "../api/place-search-client";
import { locateGeocodingResult, markGeocodingLocation } from "../map/geocoding-location";
import type { PlaceSearchConfiguration } from "../api/project-client";
type GeocodingOptions = {
placeSearch?: PlaceSearchConfiguration | null;
active: boolean;
picking?: boolean;
onPickingChange?: (picking: boolean) => void;
projectId: string | null;
getAccessToken?: AccessTokenProvider;
getAccessToken?: WorkbenchAccess;
mapRef: RefObject<MapLibreMap | null>;
mapReady: boolean;
getPadding: () => PaddingOptions;
@@ -16,6 +20,10 @@ type GeocodingOptions = {
};
export function useWorkbenchGeocoding(options: GeocodingOptions) {
const configured = Boolean(options.placeSearch?.enabled);
const region = options.placeSearch?.region ?? "";
const picking = Boolean(options.active && configured && options.mapReady && options.picking);
const [origin, setOrigin] = useState<"search" | "map">("search");
const [address, setAddress] = useState("");
const [result, setResult] = useState<PlaceSearchItem | null>(null);
const [results, setResults] = useState<PlaceSearchResults | null>(null);
@@ -37,15 +45,20 @@ export function useWorkbenchGeocoding(options: GeocodingOptions) {
setResults(null);
setChoosing(true);
setError(null);
setOrigin("search");
}, [cancel]);
useEffect(() => {
clear();
setAddress("");
optionsRef.current.onPickingChange?.(false);
return () => requestRef.current?.abort();
}, [options.projectId, options.getAccessToken, clear]);
useEffect(() => {
if (!options.active) cancel();
}, [options.active, cancel]);
if (!options.active || !configured) {
cancel();
optionsRef.current.onPickingChange?.(false);
}
}, [options.active, configured, cancel]);
const locate = useCallback(() => {
const current = optionsRef.current;
@@ -59,18 +72,75 @@ export function useWorkbenchGeocoding(options: GeocodingOptions) {
const map = options.mapRef.current;
if (!map || !options.mapReady || !result) return;
const marker = markGeocodingLocation(map, result);
locate();
if (origin === "search") locate();
return () => { marker.remove(); };
}, [options.mapRef, options.mapReady, result, locate]);
}, [options.mapRef, options.mapReady, result, origin, locate]);
async function lookupPoint(longitude: number, latitude: number) {
const current = optionsRef.current;
if (!current.active || !current.picking || !current.placeSearch?.enabled) return;
clear();
setOrigin("map");
const controller = new AbortController();
requestRef.current = controller;
setPending(true);
try {
const item = await reverseGeocodePoint(longitude, latitude, current.projectId,
current.getAccessToken, controller.signal, current.placeSearch);
if (controller.signal.aborted || requestRef.current !== controller) return;
setResult(item);
setResults({ items: [item], total: 1, truncated: false, region: current.placeSearch.region });
setChoosing(false);
optionsRef.current.onPickingChange?.(false);
} catch (failure) {
if (controller.signal.aborted || requestRef.current !== controller) return;
setError(failure instanceof Error && failure.name === "TimeoutError"
? "地图取点查询超时,请重新取点"
: failure instanceof Error ? failure.message : "地图取点查询失败,请重新取点");
} finally {
if (requestRef.current === controller) { requestRef.current = null; setPending(false); }
}
}
const lookupPointRef = useRef(lookupPoint);
lookupPointRef.current = lookupPoint;
useEffect(() => {
const map = options.mapRef.current;
if (!picking || !map) return;
const canvas = map.getCanvas();
const previousCursor = canvas.style.cursor;
canvas.style.cursor = "crosshair";
const handleClick = (event: MapMouseEvent) => {
event.preventDefault();
const longitude = ((event.lngLat.lng + 180) % 360 + 360) % 360 - 180;
void lookupPointRef.current(longitude, event.lngLat.lat);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
event.stopPropagation();
cancel();
optionsRef.current.onPickingChange?.(false);
};
map.on("click", handleClick);
window.addEventListener("keydown", handleKeyDown, true);
return () => {
map.off("click", handleClick);
canvas.style.cursor = previousCursor;
window.removeEventListener("keydown", handleKeyDown, true);
};
}, [picking, options.mapRef, cancel]);
async function search() {
optionsRef.current.onPickingChange?.(false);
clear();
if (!configured) { setError("当前项目暂未配置地名搜索"); return; }
if (!address.trim()) { setError("请输入需要搜索的地址或地名"); return; }
const controller = new AbortController();
requestRef.current = controller;
setPending(true);
try {
const value = await searchPlaces(address, options.projectId, options.getAccessToken, controller.signal);
const value = await searchPlaces(address, options.projectId, options.getAccessToken, controller.signal, options.placeSearch);
if (controller.signal.aborted || requestRef.current !== controller) return;
setResults(value);
} catch (failure) {
@@ -84,7 +154,12 @@ export function useWorkbenchGeocoding(options: GeocodingOptions) {
}
return {
address, result, results, choosing, pending, error, locate, search,
address, result, results, choosing, pending, error, locate, search, region, origin, picking,
togglePicking: () => {
cancel();
if (!picking) { clear(); setOrigin("map"); optionsRef.current.onLocate(); }
optionsRef.current.onPickingChange?.(!picking);
},
select: (id: string) => {
if (pending) return;
const item = results?.items.find((candidate) => candidate.id === id);
@@ -95,9 +170,10 @@ export function useWorkbenchGeocoding(options: GeocodingOptions) {
}
},
changeSelection: () => setChoosing(true),
enabled: options.mapReady,
changeAddress: (value: string) => { clear(); setAddress(value); },
clear: () => { clear(); setAddress(""); }
configured,
enabled: configured && options.mapReady,
changeAddress: (value: string) => { optionsRef.current.onPickingChange?.(false); clear(); setAddress(value); },
clear: () => { optionsRef.current.onPickingChange?.(false); clear(); setAddress(""); }
};
}
@@ -8,6 +8,7 @@ import { WorkbenchMapController } from "../map/workbench-map-controller";
export function useWorkbenchMapController({
mapRef,
geoserverWorkspace,
mapReady,
leftPanelOpen,
rightPanelOpen,
@@ -16,6 +17,7 @@ export function useWorkbenchMapController({
getScadaFeatures,
availableSourceIds
}: {
geoserverWorkspace?: string;
mapRef: RefObject<MapLibreMap | null>;
mapReady: boolean;
leftPanelOpen: boolean;
@@ -42,6 +44,7 @@ export function useWorkbenchMapController({
const controllerRef = useRef<WorkbenchMapController | null>(null);
if (!controllerRef.current) {
controllerRef.current = new WorkbenchMapController({
geoserverWorkspace,
getMap: () => mapRef.current,
isReady: () => valuesRef.current.mapReady,
getPadding: () => {
@@ -9,7 +9,7 @@ import type {
StyleSpecification
} from "maplibre-gl";
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import type { WorkbenchAccess } from "../api/request-context";
import { env } from "@/shared/config/env";
import { fetchScadaSnapshot, type ScadaDevice } from "../api/scada-client";
import type { DetailFeature } from "../types";
@@ -62,6 +62,7 @@ declare global {
}
type UseWorkbenchMapOptions = {
featureInteractionsEnabled?: boolean;
containerRef: RefObject<HTMLDivElement | null>;
impactVisible: boolean;
layerVisibility: Record<string, boolean>;
@@ -71,7 +72,8 @@ type UseWorkbenchMapOptions = {
simulationOverlay: FeatureCollection<Geometry> | null;
allowDemoSimulation: boolean;
networkView?: NetworkViewParams | null;
getAccessToken?: AccessTokenProvider;
getAccessToken?: WorkbenchAccess;
geoserverWorkspace?: string;
};
type UseWorkbenchMapResult = {
@@ -132,6 +134,7 @@ const EMPTY_SIMULATION_COLLECTION: FeatureCollection = {
};
export function useWorkbenchMap({
featureInteractionsEnabled = true,
containerRef,
impactVisible,
layerVisibility,
@@ -141,6 +144,7 @@ export function useWorkbenchMap({
simulationOverlay,
allowDemoSimulation,
networkView,
geoserverWorkspace,
getAccessToken
}: UseWorkbenchMapOptions): UseWorkbenchMapResult {
const mapRef = useRef<MapLibreMap | null>(null);
@@ -199,7 +203,7 @@ export function useWorkbenchMap({
);
geoServerLayerProbeInFlightRef.current = true;
void resolveGeoServerLayerAvailability(requestAbortController.signal)
void resolveGeoServerLayerAvailability(requestAbortController.signal, geoserverWorkspace)
.then((availability) => {
if (lifecycleAbortController.signal.aborted) return;
setAvailableGeoServerSourceIds((current) =>
@@ -237,7 +241,7 @@ export function useWorkbenchMap({
geoServerLayerProbeInFlightRef.current = false;
window.clearTimeout(probeTimeout);
};
}, [geoServerLayerRefreshRevision]);
}, [geoServerLayerRefreshRevision, geoserverWorkspace]);
useEffect(() => {
impactVisibleRef.current = impactVisible;
@@ -306,7 +310,7 @@ export function useWorkbenchMap({
(map.getSource(SCADA_SOURCE_ID) as GeoJSONSource | undefined)?.setData(
scadaCollectionRef.current
);
const sources = createWaterNetworkSources(REQUIRED_GEOSERVER_SOURCE_IDS);
const sources = createWaterNetworkSources(REQUIRED_GEOSERVER_SOURCE_IDS, geoserverWorkspace);
REQUIRED_GEOSERVER_SOURCE_IDS.forEach((sourceId) => {
const source = sources[sourceId];
if (source) map.addSource(sourceId, source);
@@ -315,8 +319,10 @@ export function useWorkbenchMap({
map.addSource(SIMULATION_SOURCE_IDS.annotations, simulationSources.annotations);
map.addSource(SIMULATION_SOURCE_IDS.backendAnalysis, simulationSources.backendAnalysis);
syncSimulationSourceData(map, simulationOverlayRef.current, allowDemoSimulationRef.current);
await registerSupplyAssetImages(map);
const scadaRegistration = await registerScadaImages(map);
await registerSupplyAssetImages(map, scadaAbortController.signal);
if (scadaAbortController.signal.aborted) return;
const scadaRegistration = await registerScadaImages(map, scadaAbortController.signal);
if (scadaAbortController.signal.aborted) return;
const activeScadaBusinessLayers =
scadaRegistration.status === "unavailable"
? scadaFallbackBusinessLayers
@@ -444,13 +450,13 @@ export function useWorkbenchMap({
mapRef.current = null;
delete window.__waterNetworkMap;
};
}, [containerRef, getAccessToken]);
}, [containerRef, getAccessToken, geoserverWorkspace]);
useEffect(() => {
const map = mapRef.current;
if (!mapReady || !map) return;
syncDataDependentGeoServerLayers(map, availableGeoServerSourceIds, layerVisibility);
}, [availableGeoServerSourceIds, layerVisibility, mapReady]);
syncDataDependentGeoServerLayers(map, availableGeoServerSourceIds, layerVisibility, geoserverWorkspace);
}, [availableGeoServerSourceIds, layerVisibility, mapReady, geoserverWorkspace]);
useEffect(() => {
const map = mapRef.current;
@@ -462,6 +468,7 @@ export function useWorkbenchMap({
}, [mapReady, networkView]);
useMapInteractions({
enabled: featureInteractionsEnabled,
availableSourceIds: availableGeoServerSourceIds,
mapRef,
mapReady,
@@ -557,7 +564,8 @@ function syncSimulationSourceData(
function syncDataDependentGeoServerLayers(
map: MapLibreMap,
availableSourceIds: readonly GeoServerWaterNetworkSourceId[],
layerVisibility: Record<string, boolean>
layerVisibility: Record<string, boolean>,
workspace?: string
) {
const availableSourceIdSet = new Set<GeoServerWaterNetworkSourceId>(availableSourceIds);
@@ -574,7 +582,7 @@ function syncDataDependentGeoServerLayers(
const requestedOptionalSourceIds = DATA_DEPENDENT_GEOSERVER_SOURCE_IDS.filter((sourceId) =>
availableSourceIdSet.has(sourceId)
);
const sources = createWaterNetworkSources(requestedOptionalSourceIds);
const sources = createWaterNetworkSources(requestedOptionalSourceIds, workspace);
requestedOptionalSourceIds.forEach((sourceId) => {
if (!map.getSource(sourceId) && sources[sourceId]) {
map.addSource(sourceId, sources[sourceId]);
@@ -620,7 +628,8 @@ function addMissingDataDependentLayers(
layers: StyleSpecification["layers"],
beforeLayerCandidates: readonly string[],
availableSourceIdSet: ReadonlySet<GeoServerWaterNetworkSourceId>,
layerVisibility: Record<string, boolean>
layerVisibility: Record<string, boolean>,
workspace?: string
) {
const beforeLayerId = beforeLayerCandidates.find((layerId) => map.getLayer(layerId));
layers.forEach((layer) => {
@@ -18,6 +18,7 @@ function presentation(
systemRole: "user",
isSystemAdmin: false,
networkView: null,
placeSearch: null,
metadataAvailable: true,
accessContextAvailable: true,
...overrides
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import type { WorkbenchAccess } from "../api/request-context";
import {
fetchWorkbenchProjectPresentation,
type WorkbenchProjectPresentation
@@ -14,11 +14,17 @@ const PROJECT_ROLE_LABELS: Record<string, string> = {
export function useWorkbenchProjectPresentation(
fallbackUser: WorkbenchUser,
getAccessToken?: AccessTokenProvider
getAccessToken?: WorkbenchAccess
) {
const [presentation, setPresentation] = useState<WorkbenchProjectPresentation | null>(null);
const [presentation, setPresentation] = useState<WorkbenchProjectPresentation | null>(
() => typeof getAccessToken === "object" ? getAccessToken.presentation ?? null : null
);
useEffect(() => {
if (typeof getAccessToken === "object" && getAccessToken.presentation) {
setPresentation(getAccessToken.presentation);
return;
}
const controller = new AbortController();
void fetchWorkbenchProjectPresentation(getAccessToken, controller.signal)
.then((value) => {
@@ -39,6 +45,7 @@ export function useWorkbenchProjectPresentation(
user,
projectId: presentation?.projectId ?? null,
networkView: presentation?.networkView ?? null,
placeSearch: presentation?.placeSearch ?? null,
dataSource: presentation?.metadataAvailable || presentation?.accessContextAvailable
? "backend" as const
: "fallback" as const
@@ -81,5 +81,6 @@ describe("workbench responsive layout", () => {
act(() => result.current.openAgentPanelForViewport());
expect(result.current.mobileSheet).toBe("agent");
expect(result.current.mobileSheetSnap).toBe("full");
});
});
@@ -96,7 +96,7 @@ export function useWorkbenchResponsiveLayout({
}
setMobileSheet("agent");
setMobileSheetSnap("half");
setMobileSheetSnap("full");
}
function handleConditionExpandedChange(nextExpanded: boolean) {
@@ -0,0 +1,47 @@
import { cleanup, renderHook, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { WorkbenchRequestContext } from "../api/request-context";
import type { WorkbenchSyncState } from "./use-workbench-sync";
import { useWorkbenchRuntimeData } from "./use-workbench-runtime-data";
vi.mock("../api/realtime-flow-client", () => ({
fetchRealtimePipeFlows: vi.fn().mockResolvedValue({ records: [], observedAt: null })
}));
const fengyangAccess: WorkbenchRequestContext = {
project: {
project_id: "b962c44e-8433-4a10-81e9-a64b70ac1152",
name: "凤阳(新版)",
code: "fengyang_v2",
gs_workspace: "fengyang_v2",
status: "active",
project_role: "member"
},
getAccessToken: async () => "test-token"
};
const sync = {
snapshot: null,
events: [],
status: "live",
error: null,
detailResetGeneration: 0,
acknowledgeEventsThrough: vi.fn(),
refresh: vi.fn().mockResolvedValue(undefined),
subscribePresentationEvents: vi.fn(() => () => undefined)
} as unknown as WorkbenchSyncState;
afterEach(cleanup);
describe("project-scoped workbench runtime data", () => {
it("shows mock scheduled conditions outside the demo project", async () => {
const { result } = renderHook(() => useWorkbenchRuntimeData(fengyangAccess, sync));
await waitFor(() => expect(result.current.scheduledConditionsLoading).toBe(false));
expect(result.current.scheduledConditions.length).toBeGreaterThan(0);
expect(result.current.scheduledConditions.every((condition) => condition.kind === "condition"))
.toBe(true);
expect(result.current.scenarios).toEqual([]);
});
});
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { getWorkbenchProject, type WorkbenchAccess } from "../api/request-context";
import { env } from "@/shared/config/env";
import {
type WorkbenchRuntimeSnapshot
@@ -128,9 +128,11 @@ function isSameLocalOperationalDate(value: string, operationalDate: Date) {
}
export function useWorkbenchRuntimeData(
getAccessToken: AccessTokenProvider | undefined,
getAccessToken: WorkbenchAccess | undefined,
sync: WorkbenchSyncState
) {
const scopedProject = getWorkbenchProject(getAccessToken);
const allowDemoScenarios = !scopedProject || scopedProject.gs_workspace === "tjwater_next";
const [headerDataTime, setHeaderDataTime] = useState(INITIAL_HEADER_DATA_TIME);
const [mockConditions, setMockConditions] = useState<ScheduledConditionItem[]>([]);
const backendSnapshot: WorkbenchRuntimeSnapshot | null = sync.snapshot?.runtime ?? null;
@@ -252,7 +254,8 @@ export function useWorkbenchRuntimeData(
scheduledConditionsLoading,
realtimePipeFlows,
flowDataSource: realtimePipeFlows ? "realtime" as const : "mock" as const,
...resolvedRuntimeData
...resolvedRuntimeData,
scenarios: !allowDemoScenarios && resolvedRuntimeData.scenarioDataSource === "mock" ? [] : resolvedRuntimeData.scenarios
};
}
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import type { WorkbenchAccess } from "../api/request-context";
import {
consumeWorkbenchEvents,
fetchWorkbenchSyncSnapshot,
@@ -45,7 +45,7 @@ export function shouldApplyWorkbenchSnapshot(input: {
&& (input.resetCursor || input.eventCursor >= input.currentCursor);
}
export function useWorkbenchSync(getAccessToken?: AccessTokenProvider): WorkbenchSyncState {
export function useWorkbenchSync(getAccessToken?: WorkbenchAccess): WorkbenchSyncState {
const channelRef = useRef(new WorkbenchEventChannel());
const [snapshot, setSnapshot] = useState<WorkbenchSyncSnapshot | null>(null);
const [events, setEvents] = useState<WorkbenchEvent[]>([]);
@@ -0,0 +1,8 @@
import { useEffect, useMemo, type ReactNode } from "react";
import { WorkbenchSessionContext, WorkbenchSessionResources } from "./workbench-session-resources";
export function WorkbenchSessionProvider({ children, owner }: { children: ReactNode; owner?: unknown }) {
const resources = useMemo(() => new WorkbenchSessionResources(owner), [owner]);
useEffect(() => { resources.retain(); return () => resources.release(); }, [resources]);
return <WorkbenchSessionContext.Provider key={resources.id} value={resources}>{children}</WorkbenchSessionContext.Provider>;
}
@@ -0,0 +1,34 @@
import { createContext, useContext, useState } from "react";
import type { AgentSessionRuntime } from "./agent-session-runtime";
import { ResourceCache } from "@/shared/cache/resource-cache";
export class WorkbenchSessionResources {
constructor(readonly owner?: unknown) {}
readonly id = crypto.randomUUID();
disposed = false;
readonly cache = new ResourceCache();
readonly agents = new Map<string, AgentSessionRuntime>();
readonly disposers = new Set<() => void>();
activeAgent: AgentSessionRuntime | null = null;
private listeners = new Set<() => void>();
private revision = 0;
private retainCount = 0;
subscribe = (listener: () => void) => { this.listeners.add(listener); return () => this.listeners.delete(listener); };
getSnapshot = () => this.revision;
notify() { if (!this.disposed) { this.revision++; this.listeners.forEach((listener) => listener()); } }
retain() { this.retainCount++; }
release() {
this.retainCount--;
// StrictMode immediately reacquires its lease; a real unmount releases it.
queueMicrotask(() => { if (this.retainCount === 0) this.dispose(); });
}
dispose() { this.disposed = true; this.activeAgent = null; this.disposers.forEach((dispose) => dispose()); this.disposers.clear(); this.cache.clear(); this.agents.clear(); }
}
export const WorkbenchSessionContext = createContext<WorkbenchSessionResources | null>(null);
export function useWorkbenchSessionResources() {
const provided = useContext(WorkbenchSessionContext);
const [fallback] = useState(() => new WorkbenchSessionResources());
return provided ?? fallback;
}