From f095e6136991e89af2e3f46c4dde1af06d9a3e9a Mon Sep 17 00:00:00 2001 From: lyon Date: Fri, 19 Jun 2026 02:45:53 +0800 Subject: [PATCH] fix: stabilize workbench bottom follow scroll --- .../scripts/workbench-e2e-server.ts | 100 +++++++++- .../src/components/agent/TraceTimeline.vue | 25 +-- .../workbench/ConversationPanel.vue | 19 +- .../workbench/MessageTraceDebugPanel.vue | 28 +-- .../src/composables/useBottomFollowScroll.ts | 144 ++++++++++++++ web/hwlab-cloud-web/src/styles/workbench.css | 10 +- .../workbench-e2e/specs/scroll-follow.spec.ts | 186 ++++++++++++++++++ .../specs/trace-rendering.spec.ts | 2 +- 8 files changed, 455 insertions(+), 59 deletions(-) create mode 100644 web/hwlab-cloud-web/src/composables/useBottomFollowScroll.ts create mode 100644 web/hwlab-cloud-web/tests/workbench-e2e/specs/scroll-follow.spec.ts diff --git a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts index a7c67091..f35faa7f 100644 --- a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts +++ b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts @@ -61,6 +61,7 @@ const progressOnlyText = "PROGRESS_ONLY_SHOULD_NOT_RENDER_AS_FINAL"; const finalOnlyText = "FINAL_ONLY_SHOULD_RENDER"; let state = createScenarioState("baseline"); +const sseClients = new Set(); const server = createServer((request, response) => { void handleRequest(request, response).catch((error) => { @@ -85,6 +86,10 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse) state = createScenarioState(String(body.scenarioId ?? "baseline")); return json(response, 200, stateSummary()); } + if (path === "/__e2e/scroll-follow/append" && method === "POST") { + const body = await readJson(request); + return json(response, 200, appendScrollFollowEvents(positiveInteger(body.count, 1))); + } if (path === "/__e2e/gc-empty-sessions" && method === "POST") { const body = await readJson(request); return json(response, 200, gcEmptySessions(body)); @@ -95,7 +100,7 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse) return json(response, payload.authenticated === true ? 200 : 401, payload); } if (path === "/auth/login" && method === "POST") return authLoginResponse(response); - if (path === "/v1/workbench/events" && method === "GET") return sse(response, url); + if (path === "/v1/workbench/events" && method === "GET") return sse(request, response, url); if (path === "/v1/workbench/sessions" && method === "GET") { await delay(state.sessionDelayMs); return json(response, 200, workbenchSessionListPayload(url)); @@ -274,6 +279,11 @@ function createScenarioState(scenarioId: string): ScenarioState { traces.trc_terminal_empty = terminalEmptyTrace(); } if (id === "progress-only-final-response") markRunningProgressOnly(sessions, traces); + if (id === "scroll-follow-long-trace") { + const session = scrollFollowSession(); + sessions.unshift(session); + traces.trc_scroll_follow = scrollFollowTrace(90); + } if (id === "cross-session-late-events") { sessions.unshift(crossSessionLateB(), crossSessionLateA()); traces.trc_late_A = crossSessionLateTraceA(); @@ -281,7 +291,9 @@ function createScenarioState(scenarioId: string): ScenarioState { if (id === "deleted-session-deeplink") sessions.unshift(archivedDeletedSession()); if (id === "session-switch-empty-reload") sessions.push(emptySession()); if (id === "empty-session-gc") sessions.unshift(staleEmptyTtlSession(), freshEmptyTtlSession(), staleMessagedTtlSession()); - const selectedSessionId = id === "deep-link" || id === "stale-nested-trace" + const selectedSessionId = id === "scroll-follow-long-trace" + ? "ses_scroll_follow" + : id === "deep-link" || id === "stale-nested-trace" ? "ses_failed" : id === "legacy-cnv-deeplink-canonical" ? "ses_running" @@ -342,6 +354,82 @@ function markRunningProgressOnly(sessions: SessionRecord[], traces: Record { + const seq = index + 1; + return seq % 2 === 0 + ? { id: `msg_scroll_history_agent_${seq}`, messageId: `msg_scroll_history_agent_${seq}`, role: "agent", title: "Code Agent", text: `历史回复 ${seq}\n\n用于撑开主工作区滚动高度。`, status: "completed", createdAt: now, sessionId: "ses_scroll_follow", threadId: "thr_scroll_follow", turnId: `turn_scroll_history_${seq}` } + : { id: `msg_scroll_history_user_${seq}`, messageId: `msg_scroll_history_user_${seq}`, role: "user", title: "用户", text: `历史问题 ${seq}:保持主工作区可滚动。`, status: "sent", createdAt: now, sessionId: "ses_scroll_follow", threadId: "thr_scroll_follow", turnId: `turn_scroll_history_${seq}` }; + }); + const messages = [ + ...history, + { id: "msg_scroll_follow_user", messageId: "msg_scroll_follow_user", role: "user", title: "用户", text: "scroll follow long trace probe", status: "sent", createdAt: now, sessionId: "ses_scroll_follow", threadId: "thr_scroll_follow", traceId: "trc_scroll_follow", turnId: "turn_scroll_follow" }, + { id: "msg_scroll_follow_agent", messageId: "msg_scroll_follow_agent", role: "agent", title: "Code Agent", text: "", status: "running", createdAt: now, sessionId: "ses_scroll_follow", threadId: "thr_scroll_follow", traceId: "trc_scroll_follow", turnId: "turn_scroll_follow", runnerTrace: trace } + ]; + return { + sessionId: "ses_scroll_follow", + threadId: "thr_scroll_follow", + status: "running", + lastTraceId: "trc_scroll_follow", + startedAt: now, + updatedAt: now, + messageCount: messages.length, + firstUserMessagePreview: "scroll follow long trace probe", + messages + }; +} + +function scrollFollowTrace(count: number): JsonRecord { + const events = Array.from({ length: count }, (_, index) => scrollFollowEvent(index + 1)); + return { traceId: "trc_scroll_follow", status: "running", sessionId: "ses_scroll_follow", threadId: "thr_scroll_follow", turnId: "turn_scroll_follow", events, eventCount: events.length, fullTraceLoaded: true, hasMore: false }; +} + +function scrollFollowEvent(seq: number): JsonRecord { + const createdAt = new Date(Date.parse("2026-06-18T18:00:00.000Z") + seq * 1000).toISOString(); + if (seq % 5 === 0) { + return { seq, sourceSeq: seq, createdAt, label: "agentrun:assistant:message", type: "assistant_message", status: "running", message: `滚动跟随采样 assistant row ${seq}\n\n- markdown 行 ${seq}\n- append 后不能闪到顶部` }; + } + return { + seq, + sourceSeq: seq, + createdAt, + label: "item/commandExecution:running", + type: "commandExecution", + status: "running", + itemId: `scroll-follow-${seq}`, + command: `printf 'scroll follow row ${seq}\\n'`, + stdout: `scroll-follow stdout ${seq}\nline ${seq} keeps the row tall enough for scroll sampling\n` + }; +} + +function appendScrollFollowEvents(count: number): JsonRecord { + if (state.scenarioId !== "scroll-follow-long-trace") return { ok: false, status: 409, error: { code: "scenario_mismatch", scenarioId: state.scenarioId } }; + const trace = state.traces.trc_scroll_follow ?? scrollFollowTrace(0); + const events = Array.isArray(trace.events) ? [...trace.events as JsonRecord[]] : []; + const appended: JsonRecord[] = []; + for (let index = 0; index < count; index += 1) { + const seq = Number(events.at(-1)?.seq ?? events.length) + 1; + const event = scrollFollowEvent(seq); + events.push(event); + appended.push(event); + } + const nextTrace = { ...trace, status: "running", events, eventCount: events.length, fullTraceLoaded: true, hasMore: false }; + state.traces.trc_scroll_follow = nextTrace; + const session = sessionById("ses_scroll_follow"); + if (session) { + session.status = "running"; + session.updatedAt = new Date().toISOString(); + session.messages = (session.messages ?? []).map((message) => message.role === "agent" && message.traceId === "trc_scroll_follow" ? { ...message, status: "running", runnerTrace: nextTrace } : message); + } + for (const event of appended) { + broadcastSse("workbench.trace.event", { type: "trace.event", sessionId: "ses_scroll_follow", threadId: "thr_scroll_follow", traceId: "trc_scroll_follow", event, snapshot: nextTrace }); + } + broadcastSse("workbench.turn.snapshot", { type: "turn.snapshot", sessionId: "ses_scroll_follow", threadId: "thr_scroll_follow", traceId: "trc_scroll_follow", turn: turnPayload("trc_scroll_follow") }); + return { ok: true, status: "appended", appended: appended.length, eventCount: events.length }; +} + function createManualSession(body: JsonRecord): SessionRecord { const now = new Date().toISOString(); const token = state.scenarioId === "server-authoritative-create" ? "server_created" : Date.now().toString(36); @@ -720,8 +808,10 @@ function workbenchTracePayload(traceId: string, url: URL): JsonRecord { return { ok: true, status: "ok", contractVersion: "workbench-read-model-v1", traceId, sessionId: payload.sessionId ?? null, threadId: payload.threadId ?? null, traceStatus: payload.status, events: payload.events, eventCount: payload.eventCount, hasMore: payload.hasMore, nextSeq: payload.nextSinceSeq, range: payload.range, fullTraceLoaded: payload.fullTraceLoaded, terminalEvidence: payload.terminalEvidence, finalResponse: payload.finalResponse, traceSummary: payload.traceSummary, retention: payload.retention }; } -function sse(response: ServerResponse, url: URL): void { +function sse(request: IncomingMessage, response: ServerResponse, url: URL): void { response.writeHead(200, { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-cache", connection: "keep-alive" }); + sseClients.add(response); + request.on("close", () => sseClients.delete(response)); writeSse(response, "workbench.connected", { type: "connected", sessionId: url.searchParams.get("sessionId") }); if (state.scenarioId === "cross-session-late-events") { const scenarioId = state.scenarioId; @@ -747,6 +837,10 @@ function sse(response: ServerResponse, url: URL): void { }, state.scenarioId === "progress-only-final-response" ? 5_000 : 350); } +function broadcastSse(eventName: string, payload: JsonRecord): void { + for (const client of Array.from(sseClients)) writeSse(client, eventName, payload); +} + function writeSse(response: ServerResponse, eventName: string, payload: JsonRecord): void { response.write(`event: ${eventName}\n`); response.write(`data: ${JSON.stringify(payload)}\n\n`); diff --git a/web/hwlab-cloud-web/src/components/agent/TraceTimeline.vue b/web/hwlab-cloud-web/src/components/agent/TraceTimeline.vue index 87dc7df1..95eec8da 100644 --- a/web/hwlab-cloud-web/src/components/agent/TraceTimeline.vue +++ b/web/hwlab-cloud-web/src/components/agent/TraceTimeline.vue @@ -1,15 +1,16 @@