diff --git a/platform_web/components/ServerManagementTerminalDrawer.test.tsx b/platform_web/components/ServerManagementTerminalDrawer.test.tsx index c33ac5d..8a4a266 100644 --- a/platform_web/components/ServerManagementTerminalDrawer.test.tsx +++ b/platform_web/components/ServerManagementTerminalDrawer.test.tsx @@ -99,6 +99,21 @@ describe("ServerManagementTerminalDrawer", () => { expect(systemLine?.querySelector("time")?.textContent).toBe(formatTerminalServerTime(serverTime)); }); + it("loads the current session tail when stream metadata arrives", async () => { + const stream = logStream("stdout-current", "session-current", "process.stdout"); + stream.latestSeq = 42; + apiMocks.queryLogStream.mockResolvedValue({ logStreamId: stream.id, entries: [logEntry(41, "tail before drawer opened"), logEntry(42, "latest stored output")], nextSeq: 42, latestSeq: 42 }); + await renderDrawer(); + + await emitSession("session-current"); + await emitStream(stream); + await flushPromises(); + + expect(container?.textContent).toContain("tail before drawer opened"); + expect(container?.textContent).toContain("latest stored output"); + expect(apiMocks.queryLogStream).toHaveBeenCalledWith({ logStreamId: stream.id, afterSeq: 0, limit: 500 }); + }); + it("renders an empty current session without accepting unrelated or sessionless logs", async () => { await renderDrawer(); @@ -153,7 +168,9 @@ describe("ServerManagementTerminalDrawer", () => { const oldStream = logStream("stdout-old", "session-old", "process.stdout", "2026-08-01T00:00:00Z"); oldStream.latestSeq = 900; apiMocks.listLogStreams.mockResolvedValue({ items: [oldStream], count: 1 }); - apiMocks.queryLogStream.mockResolvedValue({ logStreamId: oldStream.id, entries: [logEntry(1, "selected historical output", "2026-08-01T00:00:01Z")], nextSeq: 1, latestSeq: 1 }); + apiMocks.queryLogStream.mockImplementation((request: { logStreamId: string }) => Promise.resolve(request.logStreamId === oldStream.id + ? { logStreamId: oldStream.id, entries: [logEntry(1, "selected historical output", "2026-08-01T00:00:01Z")], nextSeq: 1, latestSeq: 1 } + : { logStreamId: request.logStreamId, entries: [], nextSeq: 0, latestSeq: 0 })); await renderDrawer(); await emitSession("session-current"); await emitStream(logStream("stdout-current", "session-current", "process.stdout")); diff --git a/platform_web/components/ServerManagementTerminalDrawer.tsx b/platform_web/components/ServerManagementTerminalDrawer.tsx index f709740..c433225 100644 --- a/platform_web/components/ServerManagementTerminalDrawer.tsx +++ b/platform_web/components/ServerManagementTerminalDrawer.tsx @@ -102,6 +102,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu const initialHistoryPendingRef = useRef(false); const liveSessionRef = useRef(undefined); const historyRequestRef = useRef(0); + const liveTailLoadedRef = useRef(new Map()); const quickCommands = useMemo(() => terminalQuickCommandsForPlugin(pluginId), [pluginId]); const supportsCommands = quickCommands.length > 0; @@ -124,6 +125,22 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu }); }, []); + const loadLiveStreamTail = useCallback((stream: LogStreamResponse) => { + const latestSeq = Number(stream.latestSeq); + if (!Number.isFinite(latestSeq) || latestSeq <= 0) return; + const loadedThrough = liveTailLoadedRef.current.get(stream.id) ?? 0; + if (loadedThrough >= latestSeq) return; + liveTailLoadedRef.current.set(stream.id, latestSeq); + void platformApiClient.queryLogStream({ logStreamId: stream.id, afterSeq: Math.max(0, latestSeq - terminalHistoryWindow), limit: terminalHistoryWindow }).then((response) => { + if (!eventBelongsToLiveSession(stream.logSessionId, liveSessionRef.current)) return; + appendLines(response.entries.map((entry) => terminalLineFromLog({ ...stream, latestSeq: response.latestSeq }, entry))); + lockTerminalFollow(); + }).catch((error) => { + if (!eventBelongsToLiveSession(stream.logSessionId, liveSessionRef.current)) return; + appendLines([terminalSystemLine("warn", `当前会话尾部日志读取失败,等待后续实时输出:${error instanceof Error ? error.message : "未知错误"}`, "SYSTEM", `tail-error-${stream.id}`, serverTimeRef.current)]); + }); + }, [appendLines, lockTerminalFollow]); + useEffect(() => { if (!open) return; setStreams({ status: "loading" }); @@ -137,6 +154,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu setHistoryLines({ status: "idle" }); setSelectedHistoryStreamId(""); liveSessionRef.current = undefined; + liveTailLoadedRef.current.clear(); serverTimeRef.current = undefined; historyRequestRef.current += 1; initialHistoryPendingRef.current = true; @@ -180,6 +198,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu if (!stream || !eventBelongsToLiveSession(stream.logSessionId, liveSessionRef.current)) return; ready = true; setStreams((current) => ({ status: "ready", data: mergeLogStreams(current.status === "ready" ? current.data : [], stream) })); + loadLiveStreamTail(stream); }); events.addEventListener("ready", () => { ready = true; @@ -198,7 +217,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu if (!ready) setStreams({ status: "error", reason: "实时日志推送连接失败" }); }; return () => events.close(); - }, [appendLines, lockTerminalFollow, open, serverId]); + }, [appendLines, loadLiveStreamTail, lockTerminalFollow, open, serverId]); useEffect(() => { if (!open || !historyOpen) return;