feat(charts): manage investigation charts in a scrolling workspace
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
import { useLayoutEffect, useState } from "react";
|
||||
import { columnsForLayout, reconcileChartSlots, swapChartSlots, type ChartLayout, type ChartSlot } from "../utils/chart-window-slots";
|
||||
|
||||
/** Keep chart identities in fixed positions throughout the scrolling grid. */
|
||||
export function useChartWindowLayout(ids: string[]) {
|
||||
const [container, setContainer] = useState<HTMLElement | null>(null);
|
||||
const [narrow, setNarrow] = useState(window.innerWidth < 760);
|
||||
const [preferredLayout, setPreferredLayout] = useState<ChartLayout>("four");
|
||||
const layout = narrow ? "one" : preferredLayout;
|
||||
const columns = columnsForLayout(layout);
|
||||
const [slots, setSlots] = useState<ChartSlot[]>([]);
|
||||
const available = reconcileChartSlots(slots, ids, columns);
|
||||
if (available !== slots) setSlots(available);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!container) return;
|
||||
const measure = () => setNarrow(container.clientWidth < 720);
|
||||
measure();
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, [container]);
|
||||
|
||||
return {
|
||||
containerRef: setContainer,
|
||||
layout,
|
||||
preferredLayout,
|
||||
narrow,
|
||||
setLayout: setPreferredLayout,
|
||||
slots: available,
|
||||
columns,
|
||||
move: (id: string, target: number) => {
|
||||
const source = available.indexOf(id);
|
||||
const next = swapChartSlots(available, source, target);
|
||||
if (next !== available) {
|
||||
setSlots(next);
|
||||
}
|
||||
},
|
||||
reset: () => {
|
||||
setSlots(reconcileChartSlots([], ids, columns));
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -95,3 +95,59 @@ it("advances saved snapshots on its timer, ignores new results and stops when th
|
||||
act(() => vi.advanceTimersByTime(3000));
|
||||
expect(result.current.records).toEqual([{ value: 3 }]);
|
||||
});
|
||||
|
||||
it("requests only the live task manager and forgets automatic intent across task switches", async () => {
|
||||
vi.useFakeTimers();
|
||||
const task = { task_id: "task-a", title: "运行中调查", execution_generation: 1, status: "running", evidence: [] } as unknown as import("../api/operational-task-client").OperationalTask;
|
||||
const command = { action: "open_chart" as const, chart_id: "pressure", evidence_refs: ["test"], chart: {
|
||||
title: "压力", chart_type: "line" as const, x_data: ["10:00"], series: [{ name: "压力", data: [20] }]
|
||||
} };
|
||||
const panel = document.createElement("div");
|
||||
panel.dataset.presentationScope = "investigation";
|
||||
const marker = document.createElement("div");
|
||||
marker.dataset.investigationChart = "pressure";
|
||||
marker.getBoundingClientRect = () => ({ height: 10 }) as DOMRect;
|
||||
panel.append(marker);
|
||||
document.body.append(panel);
|
||||
const initial = { ...options, scope: "investigation" as const, task, enabled: true };
|
||||
const { result, rerender } = renderHook((next) => useInvestigationPresentation(next), { initialProps: initial });
|
||||
const execute = async (origin: "live" | "preview" | "chat") => {
|
||||
await act(async () => {
|
||||
const pending = result.current.executeCommand(command, new AbortController().signal, origin);
|
||||
await vi.advanceTimersByTimeAsync(700);
|
||||
await pending;
|
||||
});
|
||||
};
|
||||
try {
|
||||
expect(result.current.chartWindowAutoOpen).toBe(false);
|
||||
await execute("preview");
|
||||
expect(result.current.chartWindowAutoOpen).toBe(false);
|
||||
await execute("chat");
|
||||
expect(result.current.chartWindowAutoOpen).toBe(false);
|
||||
panel.dataset.investigationCollapsed = "true";
|
||||
await execute("live");
|
||||
expect(result.current.chartWindowAutoOpen).toBe(false);
|
||||
panel.dataset.investigationCollapsed = "false";
|
||||
await execute("live");
|
||||
expect(result.current.chartWindowAutoOpen).toBe(true);
|
||||
rerender({ ...initial, task: { ...task, task_id: "task-b" } });
|
||||
expect(result.current.chartWindowAutoOpen).toBe(false);
|
||||
rerender(initial);
|
||||
expect(result.current.chartWindowAutoOpen).toBe(false);
|
||||
rerender({ ...initial, enabled: false });
|
||||
await execute("live");
|
||||
expect(result.current.chartWindowAutoOpen).toBe(false);
|
||||
rerender({ ...initial, task: { ...task, status: "completed", lifecycle_status: "completed" } });
|
||||
await execute("live");
|
||||
expect(result.current.chartWindowAutoOpen).toBe(false);
|
||||
rerender(initial);
|
||||
await act(() => result.current.runPreview({ blocks: [], records: [{ value: "历史结果" }], commands: [] }));
|
||||
act(() => result.current.history.onSelect(result.current.history.entries[0].id));
|
||||
await execute("live");
|
||||
expect(result.current.chartWindowAutoOpen).toBe(false);
|
||||
act(() => result.current.history.onSelect(null));
|
||||
expect(result.current.chartWindowAutoOpen).toBe(false);
|
||||
} finally {
|
||||
panel.remove();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -114,6 +114,9 @@ async function waitForChart(
|
||||
}
|
||||
|
||||
export function useInvestigationPresentation(options: Options) {
|
||||
const windowScope = JSON.stringify([options.scope, options.scopeId, options.projectId, options.task?.task_id, options.task?.execution_generation]);
|
||||
const [chartWindow, setChartWindow] = useState({ scope: windowScope, requested: false });
|
||||
if (chartWindow.scope !== windowScope) setChartWindow({ scope: windowScope, requested: false });
|
||||
const valuesRef = useRef(options);
|
||||
valuesRef.current = options;
|
||||
const storeRef = useRef(new PresentationViewHistory());
|
||||
@@ -155,6 +158,14 @@ export function useInvestigationPresentation(options: Options) {
|
||||
: "live",
|
||||
[]
|
||||
);
|
||||
const requestChartWindow = useCallback((origin: InvestigationPresentationActivity["source"]) => {
|
||||
const current = valuesRef.current;
|
||||
if (storeRef.current.selectedId !== null || current.scope === "chat" || current.scope === "preview") return;
|
||||
if (origin !== "live" || !current.enabled || current.task?.status !== "running") return;
|
||||
const panel = document.querySelector<HTMLElement>(`[data-presentation-scope="${current.scope ?? "investigation"}"]`);
|
||||
if (panel?.dataset.investigationCollapsed === "true") return;
|
||||
setChartWindow({ scope: JSON.stringify([current.scope, current.scopeId, current.projectId, current.task?.task_id, current.task?.execution_generation]), requested: true });
|
||||
}, []);
|
||||
const update = useCallback(
|
||||
(updater: (view: PresentationView) => PresentationView) => {
|
||||
storeRef.current.update(updater(storeRef.current.current));
|
||||
@@ -289,6 +300,7 @@ export function useInvestigationPresentation(options: Options) {
|
||||
})
|
||||
);
|
||||
signal.throwIfAborted();
|
||||
if (command.action === "open_chart") requestChartWindow(origin);
|
||||
if (historyId !== null) store.finish(historyId, "completed", undefined, actionSnapshot, pendingEntry);
|
||||
publish();
|
||||
setActivity((current) =>
|
||||
@@ -327,7 +339,7 @@ export function useInvestigationPresentation(options: Options) {
|
||||
throw cause;
|
||||
}
|
||||
},
|
||||
[scene, publish, update]
|
||||
[scene, publish, update, requestChartWindow]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -555,6 +567,8 @@ export function useInvestigationPresentation(options: Options) {
|
||||
const visible = storeRef.current.visible;
|
||||
const reviewing = storeRef.current.selectedId !== null;
|
||||
return {
|
||||
chartWindowAutoOpen: chartWindow.scope === windowScope && chartWindow.requested,
|
||||
chartWindowScope: windowScope,
|
||||
charts: visible.charts,
|
||||
blocks: visible.blocks,
|
||||
records: visible.records,
|
||||
|
||||
Reference in New Issue
Block a user