refactor: organize supply frontend structure

This commit is contained in:
2026-07-22 18:16:43 +08:00
parent 699a0bced4
commit b9bc9c3d39
96 changed files with 917 additions and 745 deletions
@@ -1,21 +1,19 @@
import type { UIMessage } from "ai";
import type { Dispatch, SetStateAction } from "react";
import type {
AgentChatMessage,
AgentPermissionReply,
AgentPermissionStatus,
AgentQuestionRequest,
AgentStreamRenderState
} from "@/features/agent";
import type { AgentSessionStreamEvent } from "@/features/agent/api/client";
import {
applyPermissionResponse,
applyQuestionResponse,
toTodoUpdate,
upsertPermission,
upsertProgress,
upsertQuestion
} from "@/features/agent/session-state";
upsertQuestion,
type AgentChatMessage,
type AgentPermissionReply,
type AgentPermissionStatus,
type AgentQuestionRequest,
type AgentSessionStreamEvent,
type AgentStreamRenderState
} from "@/features/agent";
type AgentUiDataParts = {
progress: Record<string, unknown>;
@@ -0,0 +1,95 @@
import {
useCallback,
useEffect,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
type PointerEvent as ReactPointerEvent
} from "react";
import {
WORKBENCH_LAYOUT,
clampAgentPanelWidth,
getWorkbenchViewportLayout
} from "../layout/workbench-layout";
export function useAgentPanelResize(viewportWidth: number) {
const panelRef = useRef<HTMLDivElement | null>(null);
const resizeLeftRef = useRef(0);
const cleanupRef = useRef<(() => void) | null>(null);
const [width, setWidth] = useState<number | null>(null);
const [resizing, setResizing] = useState(false);
useEffect(() => () => cleanupRef.current?.(), []);
useEffect(() => {
setWidth((currentWidth) =>
currentWidth === null ? null : clampAgentPanelWidth(currentWidth, viewportWidth)
);
}, [viewportWidth]);
const resize = useCallback((clientX: number) => {
setWidth(clampAgentPanelWidth(clientX - resizeLeftRef.current, window.innerWidth));
}, []);
const handlePointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 0 || !panelRef.current) return;
event.preventDefault();
resizeLeftRef.current = panelRef.current.getBoundingClientRect().left;
setResizing(true);
resize(event.clientX);
cleanupRef.current?.();
const handlePointerMove = (pointerEvent: PointerEvent) => {
pointerEvent.preventDefault();
resize(pointerEvent.clientX);
};
const stopResizing = () => {
cleanupRef.current?.();
setResizing(false);
};
const cleanup = () => {
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", stopResizing);
window.removeEventListener("pointercancel", stopResizing);
cleanupRef.current = null;
};
cleanupRef.current = cleanup;
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", stopResizing);
window.addEventListener("pointercancel", stopResizing);
},
[resize]
);
const handleKeyDown = useCallback((event: ReactKeyboardEvent<HTMLDivElement>) => {
const currentWidth =
panelRef.current?.getBoundingClientRect().width ?? WORKBENCH_LAYOUT.desktop.agentWidth;
let nextWidth = currentWidth;
if (event.key === "ArrowLeft") {
nextWidth -= 16;
} else if (event.key === "ArrowRight") {
nextWidth += 16;
} else if (event.key === "Home") {
nextWidth = getWorkbenchViewportLayout(window.innerWidth).agentWidth;
} else if (event.key === "End") {
nextWidth = window.innerWidth * WORKBENCH_LAYOUT.maxAgentViewportRatio;
} else {
return;
}
event.preventDefault();
setWidth(clampAgentPanelWidth(nextWidth, window.innerWidth));
}, []);
return {
handleKeyDown,
handlePointerDown,
panelRef,
resizing,
width
};
}
@@ -1,5 +1,3 @@
"use client";
import type { Map as MapLibreMap, MapLayerMouseEvent } from "maplibre-gl";
import type { RefObject } from "react";
import { useEffect } from "react";
@@ -1,5 +1,3 @@
"use client";
import type { Map as MapLibreMap } from "maplibre-gl";
import type { RefObject } from "react";
import { useEffect } from "react";
@@ -1,5 +1,3 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
@@ -7,34 +5,28 @@ import useSWR from "swr";
import useSWRImmutable from "swr/immutable";
import type { PersonaState } from "@/shared/ai-elements/persona";
import { env } from "@/shared/config/env";
import { showMapNotice } from "@/features/map/core/components/notice-actions";
import type {
AgentApprovalMode,
AgentChatMessage,
AgentModelOption,
AgentPermissionReply,
AgentPermissionRequest,
AgentQuestionRequest,
AgentStreamRenderState
} from "@/features/agent";
import {
createAgentApiClient,
type AgentSessionStreamEvent
} from "@/features/agent/api/client";
import { showMapNotice } from "@/features/map/core";
import {
FrontendActionExecutor,
agentBootstrapKey,
agentSessionsKey,
createAgentApiClient,
fetchAgentBootstrap,
fetchAgentSessions
} from "@/features/agent/api/swr";
import {
fetchAgentSessions,
isUiEnvelopeAllowed,
parseUiEnvelopePayload,
type AgentApprovalMode,
type AgentChatMessage,
type AgentModelOption,
type AgentPermissionReply,
type AgentPermissionRequest,
type AgentQuestionRequest,
type AgentSessionStreamEvent,
type AgentStreamRenderState,
type FrontendActionRequest,
type UIEnvelopePayload,
type UIRegistry
} from "@/features/agent/ui-envelope";
import type { FrontendActionRequest } from "@/features/agent/frontend-action";
import { FrontendActionExecutor } from "@/features/agent/frontend-action/executor";
} from "@/features/agent";
import {
applySessionStreamEvent,
createCompletedStreamRenderState,
@@ -1,5 +1,3 @@
"use client";
import type { Map as MapLibreMap, MapMouseEvent } from "maplibre-gl";
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import {
@@ -1,5 +1,3 @@
"use client";
import type { Map as MapLibreMap } from "maplibre-gl";
import { useEffect, useRef, useSyncExternalStore, type RefObject } from "react";
import { getResponsiveWorkbenchPadding } from "../map/camera";
@@ -1,5 +1,3 @@
"use client";
import "maplibre-gl/dist/maplibre-gl.css";
import maplibregl, { type Map as MapLibreMap, type MapSourceDataEvent } from "maplibre-gl";
@@ -1,5 +1,3 @@
"use client";
import type { Map as MapLibreMap, MapGeoJSONFeature, MapMouseEvent } from "maplibre-gl";
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import {
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import {
formatHeaderDataTime,
getMsUntilNextHeaderDataTimeTick
} from "./use-workbench-runtime-data";
describe("workbench runtime data", () => {
it("snaps the header time to the current five-minute interval", () => {
expect(formatHeaderDataTime(new Date(2026, 6, 22, 15, 43, 31))).toBe("2026-07-22 15:40");
});
it("schedules the next header refresh on the next interval boundary", () => {
expect(getMsUntilNextHeaderDataTimeTick(new Date(2026, 6, 22, 15, 43, 30))).toBe(90_000);
});
});
@@ -0,0 +1,71 @@
import { useEffect, useState } from "react";
import {
SCHEDULED_CONDITION_REFRESH_INTERVAL_MS,
createOperationalScheduledConditions
} from "../data/scheduled-conditions";
import type { ScheduledConditionItem } from "../types";
const HEADER_DATA_TIME_STEP_MINUTES = 5;
const INITIAL_HEADER_DATA_TIME = "同步中";
export function formatHeaderDataTime(date: Date) {
const snappedMinutes =
Math.floor(date.getMinutes() / HEADER_DATA_TIME_STEP_MINUTES) * HEADER_DATA_TIME_STEP_MINUTES;
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(snappedMinutes).padStart(2, "0");
return `${year}-${month}-${day} ${hours}:${minutes}`;
}
export function getMsUntilNextHeaderDataTimeTick(date: Date) {
const nextTick = new Date(date);
const nextMinutes =
Math.floor(date.getMinutes() / HEADER_DATA_TIME_STEP_MINUTES) * HEADER_DATA_TIME_STEP_MINUTES +
HEADER_DATA_TIME_STEP_MINUTES;
nextTick.setMinutes(nextMinutes, 0, 0);
return Math.max(nextTick.getTime() - date.getTime(), 1_000);
}
export function useWorkbenchRuntimeData() {
const [headerDataTime, setHeaderDataTime] = useState(INITIAL_HEADER_DATA_TIME);
const [scheduledConditions, setScheduledConditions] = useState<ScheduledConditionItem[]>([]);
const [scheduledConditionsLoading, setScheduledConditionsLoading] = useState(true);
useEffect(() => {
let timeoutId: number;
const refreshHeaderDataTime = () => {
const now = new Date();
setHeaderDataTime(formatHeaderDataTime(now));
timeoutId = window.setTimeout(refreshHeaderDataTime, getMsUntilNextHeaderDataTimeTick(now));
};
refreshHeaderDataTime();
return () => window.clearTimeout(timeoutId);
}, []);
useEffect(() => {
const refreshScheduledConditions = () => {
setScheduledConditions(createOperationalScheduledConditions(new Date()));
setScheduledConditionsLoading(false);
};
refreshScheduledConditions();
const intervalId = window.setInterval(
refreshScheduledConditions,
SCHEDULED_CONDITION_REFRESH_INTERVAL_MS
);
return () => window.clearInterval(intervalId);
}, []);
return {
headerDataTime,
scheduledConditions,
scheduledConditionsLoading
};
}