From e54b3672d93f4544bfe3fd13f0b23a337e4c3bac Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Fri, 5 Jun 2026 17:00:24 +0800 Subject: [PATCH] fix: keep v02 workspace history out of first screen LCP --- web/hwlab-cloud-web/index.html | 19 ++++++++++ web/hwlab-cloud-web/scripts/check.ts | 5 +++ web/hwlab-cloud-web/src/App.tsx | 2 +- .../conversation/ConversationPanel.test.ts | 12 ++++++- .../conversation/ConversationPanel.tsx | 26 ++++++++++---- .../conversation/MessageTracePanel.test.tsx | 14 ++++++-- .../conversation/MessageTracePanel.tsx | 9 ++--- .../components/shared/MarkdownRenderer.tsx | 18 ++++++++++ .../shared/MessageMarkdown.test.tsx | 24 ++++++++++--- .../src/components/shared/MessageMarkdown.tsx | 35 +++++++++++-------- web/hwlab-cloud-web/src/hooks/useAuth.ts | 28 ++++++++++++++- web/hwlab-cloud-web/src/styles/workbench.css | 7 ++++ web/hwlab-cloud-web/src/types/config.ts | 18 ++++++++++ 13 files changed, 183 insertions(+), 34 deletions(-) create mode 100644 web/hwlab-cloud-web/src/components/shared/MarkdownRenderer.tsx diff --git a/web/hwlab-cloud-web/index.html b/web/hwlab-cloud-web/index.html index bafd07ed..635290c9 100644 --- a/web/hwlab-cloud-web/index.html +++ b/web/hwlab-cloud-web/index.html @@ -5,6 +5,25 @@ HWLAB 云工作台 +
diff --git a/web/hwlab-cloud-web/scripts/check.ts b/web/hwlab-cloud-web/scripts/check.ts index be524ea5..d8b0f409 100644 --- a/web/hwlab-cloud-web/scripts/check.ts +++ b/web/hwlab-cloud-web/scripts/check.ts @@ -39,11 +39,13 @@ console.log("hwlab-cloud-web check: React module assets present"); const html = readWeb("index.html"); const appTsx = readWeb("src/App.tsx"); const apiClientSource = readWeb("src/services/api/client.ts"); +const authHookSource = readWeb("src/hooks/useAuth.ts"); const appSource = readCloudWebAppSource(rootDir); const css = readWeb("src/styles/workbench.css"); assert.match(html, /
<\/div>/u, "index.html must only expose React mount root"); assert.match(html, /src="\/src\/main\.tsx"/u, "index.html must load React TSX entry"); +assert.match(html, /HWLAB_CLOUD_WEB_EARLY_WORKSPACE_BOOTSTRAP/u, "workspace bootstrap must start during HTML parse for the workspace first screen"); assert.doesNotMatch(html, /workbench-shell|device-pod-sidebar|command-input|conversation-list|session-tabs/u, "index.html must not contain business DOM shell"); for (const removed of ["app.ts", "app-device-pod.ts", "app-conversation.ts", "app-helpers.ts", "app-skills.ts", "styles.css", "web-types.d.ts"]) { assert.equal(fs.existsSync(path.join(rootDir, removed)), false, `${removed} must not remain as a legacy runtime path`); @@ -58,6 +60,7 @@ assertIncludes(appSource, "PerformanceView", "performance view component must ex assertIncludes(appSource, "SkillsView", "skills component must exist"); assertIncludes(appSource, "SettingsView", "settings component must exist"); assertIncludes(appTsx, "const workspaceRoute = route === \"workspace\"", "React runtime must explicitly identify workspace route before starting workbench data flows"); +assertIncludes(authHookSource, "consumeEarlyWorkspaceBootstrap", "auth hook must consume the HTML-started workspace bootstrap instead of issuing a second first-screen bootstrap"); assertIncludes(apiClientSource, "adapter: (): Promise> => fetchJson(\"/v1/rpc/system.health\", { method: \"POST\"", "adapter health must use POST /v1/rpc/system.health"); assertIncludes(appSource, "selectVisibleDevicePodId", "Device Pod detail probes must be selected from the authenticated visible list"); assertIncludes(appSource, "api.devicePods()", "Device Pod list must be fetched before detail probes"); @@ -85,6 +88,8 @@ assert.match(css, /\.message-body p\s*\{\s*white-space:\s*pre-wrap;\s*\}/u, "fin assert.match(css, /@keyframes trace-update-pulse/u, "trace updates must have an explicit visual pulse"); assert.match(css, /@keyframes trace-working-border/u, "running trace frame must show an explicit working animation"); assert.match(css, /\.message-trace-body\s*\{[\s\S]*?max-height:\s*calc\(4 \* 1\.35em \+ 14px\);/u, "trace/tool output body must be capped to about four lines with internal scroll"); +assert.match(css, /\.message-card\.message-agent \.message-final-response\[data-first-screen-history="true"\]\s*\{[\s\S]*?max-height:\s*calc\(4 \* 1\.45em \+ 24px\);/u, "first-screen historical final response must have bounded own scroll instead of driving page-level LCP"); +assert.match(css, /\.message-card\.message-agent \.message-final-response\[data-first-screen-history="true"\] \.message-body > p,[\s\S]*?max-height:\s*calc\(3 \* 1\.45em \+ 8px\);/u, "first-screen historical final response inner blocks must be bounded so text nodes do not become page-level LCP"); assert.doesNotMatch(appSource, /message-trace-storage-key/u, "trace summary must not expose raw trace ids as visible chips"); assert.doesNotMatch(appSource, /last ·/u, "trace summary must not show raw last-event labels"); diff --git a/web/hwlab-cloud-web/src/App.tsx b/web/hwlab-cloud-web/src/App.tsx index 2e4f6c02..a775a1b7 100644 --- a/web/hwlab-cloud-web/src/App.tsx +++ b/web/hwlab-cloud-web/src/App.tsx @@ -185,7 +185,7 @@ export function App(): ReactElement { /> ) : null}
- {route === "workspace" ? copyTextToClipboard(firstNonEmptyString(store.composer.sessionId, store.state.workspace?.selectedAgentSessionId, store.state.workspace?.workspace?.selectedAgentSessionId, store.activeConversationId) ?? "")} onCancelMessage={(id) => void store.cancelAgentMessage(id)} onRetryMessage={(id) => void store.retryAgentMessage(id)} onReplayTrace={(id) => void store.replayAgentTrace(id)} /> : null} + {route === "workspace" ? copyTextToClipboard(firstNonEmptyString(store.composer.sessionId, store.state.workspace?.selectedAgentSessionId, store.state.workspace?.workspace?.selectedAgentSessionId, store.activeConversationId) ?? "")} onCancelMessage={(id) => void store.cancelAgentMessage(id)} onRetryMessage={(id) => void store.retryAgentMessage(id)} onReplayTrace={(id) => void store.replayAgentTrace(id)} /> : null} {route === "performance" ? : null} {route === "skills" ? : null} {route === "access" ? : null} diff --git a/web/hwlab-cloud-web/src/components/conversation/ConversationPanel.test.ts b/web/hwlab-cloud-web/src/components/conversation/ConversationPanel.test.ts index 4c701860..8549690d 100644 --- a/web/hwlab-cloud-web/src/components/conversation/ConversationPanel.test.ts +++ b/web/hwlab-cloud-web/src/components/conversation/ConversationPanel.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { test } from "bun:test"; -import { shouldAutoReplayTrace } from "./ConversationPanel"; +import { FIRST_SCREEN_HISTORY_MARKDOWN_DELAY_MS, shouldAutoReplayTrace } from "./ConversationPanel"; import type { ChatMessage } from "../../types/domain"; const baseAgentMessage: ChatMessage = { @@ -33,3 +33,13 @@ test("conversation panel does not auto replay loaded, empty, or running traces", assert.equal(shouldAutoReplayTrace({ ...baseAgentMessage, traceReplayStatus: "完整 trace 已回放:49 个原始 event" }), false); assert.equal(shouldAutoReplayTrace({ ...baseAgentMessage, runnerTrace: { ...baseAgentMessage.runnerTrace, fullTraceLoaded: true, events: [{ seq: 1, label: "assistant:message" }] } }), false); }); + +test("conversation panel delays first-screen historical markdown upgrade past the LCP window", () => { + assert.ok(FIRST_SCREEN_HISTORY_MARKDOWN_DELAY_MS > 3000); +}); + +test("conversation panel passes the first-screen markdown delay to historical message bodies", async () => { + const source = await Bun.file(new URL("./ConversationPanel.tsx", import.meta.url)).text(); + + assert.match(source, /MessageMarkdown upgradeDelayMs=\{firstScreenHistory \? FIRST_SCREEN_HISTORY_MARKDOWN_DELAY_MS : undefined\}/u); +}); diff --git a/web/hwlab-cloud-web/src/components/conversation/ConversationPanel.tsx b/web/hwlab-cloud-web/src/components/conversation/ConversationPanel.tsx index e09c7164..a92379b9 100644 --- a/web/hwlab-cloud-web/src/components/conversation/ConversationPanel.tsx +++ b/web/hwlab-cloud-web/src/components/conversation/ConversationPanel.tsx @@ -14,6 +14,7 @@ import { MessageActions } from "./MessageActions"; interface ConversationPanelProps { messages: ChatMessage[]; + loading: boolean; availability: CodeAgentAvailability | null; live: LiveSurface | null; chatPending: boolean; @@ -23,8 +24,11 @@ interface ConversationPanelProps { onReplayTrace(messageId: string): void; } -export function ConversationPanel({ messages, availability, live, chatPending, codeAgentTimeoutMs, onCopySession, onCancelMessage, onRetryMessage, onReplayTrace }: ConversationPanelProps & { codeAgentTimeoutMs: number }): ReactElement { +const EMPTY_MESSAGE_ID_SET = new Set(); + +export function ConversationPanel({ messages, loading, availability, live, chatPending, codeAgentTimeoutMs, onCopySession, onCancelMessage, onRetryMessage, onReplayTrace }: ConversationPanelProps & { codeAgentTimeoutMs: number }): ReactElement { const scrollFollow = useScrollFollow([messages]); + const firstScreenMessageIds = useFirstScreenMessageIds(messages, loading); return (
@@ -48,6 +52,7 @@ export function ConversationPanel({ messages, availability, live, chatPending, c onCancelMessage(message.id)} onRetry={() => onRetryMessage(message.id)} @@ -61,6 +66,14 @@ export function ConversationPanel({ messages, availability, live, chatPending, c ); } +function useFirstScreenMessageIds(messages: ChatMessage[], loading: boolean): Set { + const capturedRef = useRef | null>(null); + if (!loading && capturedRef.current === null) { + capturedRef.current = new Set(messages.map((message) => message.id)); + } + return capturedRef.current ?? EMPTY_MESSAGE_ID_SET; +} + function latestAgentMessage(messages: ChatMessage[]): Record | null { for (let index = messages.length - 1; index >= 0; index -= 1) { const message = messages[index]; @@ -114,8 +127,9 @@ export function shouldAutoReplayTrace(message: ChatMessage): boolean { } const HISTORICAL_TRACE_REPLAY_DELAY_MS = 4500; +export const FIRST_SCREEN_HISTORY_MARKDOWN_DELAY_MS = 5200; -function MessageCard({ message, codeAgentTimeoutMs, onCancel, onRetry, onReplayTrace }: { message: ChatMessage; codeAgentTimeoutMs: number; onCancel(): void; onRetry(): void; onReplayTrace(): void }): ReactElement { +function MessageCard({ message, firstScreenHistory, codeAgentTimeoutMs, onCancel, onRetry, onReplayTrace }: { message: ChatMessage; firstScreenHistory: boolean; codeAgentTimeoutMs: number; onCancel(): void; onRetry(): void; onReplayTrace(): void }): ReactElement { const traceKey = `hwlab.workbench.trace-open.${message.traceId ?? message.id}`; const replayRequestedRef = useRef(false); useEffect(() => { @@ -129,9 +143,9 @@ function MessageCard({ message, codeAgentTimeoutMs, onCancel, onRetry, onReplayT const hasFinalResponse = message.status !== "running" && message.text.trim().length > 0; return (
- {message.runnerTrace ? : null} -
- {hasFinalResponse ? {message.text} : null} + {message.runnerTrace ? : null} +
+ {hasFinalResponse ? {message.text} : null}
- {message.text} + {message.text} { +test("message trace panel keeps markdown rows marked while first-screen fallback is plain text", () => { const trace: RunnerTrace = { traceId: "trc_markdown_row", status: "completed", @@ -26,11 +26,19 @@ test("message trace panel renders assistant markdown rows through shared markdow const html = renderToStaticMarkup(); assert.match(html, /data-body-format="markdown"/u); - assert.match(html, //u); - assert.match(html, /D601-F103-V2<\/code>/u); + assert.match(html, /\| devicePodId \| debug \|/u); + assert.match(html, /`D601-F103-V2`/u); assert.doesNotMatch(html, /
 {
+  const source = Bun.file(new URL("./MessageTracePanel.tsx", import.meta.url));
+  return source.text().then((text) => {
+    assert.match(text, /markdownUpgradeDelayMs\?: number/u);
+    assert.match(text, /upgradeDelayMs=\{markdownUpgradeDelayMs\}/u);
+  });
+});
+
 test("message trace panel exposes running status for live trace animation", () => {
   const trace: RunnerTrace = {
     traceId: "trc_running_row",
diff --git a/web/hwlab-cloud-web/src/components/conversation/MessageTracePanel.tsx b/web/hwlab-cloud-web/src/components/conversation/MessageTracePanel.tsx
index 686244a1..379f3f11 100644
--- a/web/hwlab-cloud-web/src/components/conversation/MessageTracePanel.tsx
+++ b/web/hwlab-cloud-web/src/components/conversation/MessageTracePanel.tsx
@@ -11,9 +11,10 @@ interface MessageTracePanelProps {
   trace: RunnerTrace;
   defaultOpen?: boolean;
   storageKey?: string;
+  markdownUpgradeDelayMs?: number;
 }
 
-export function MessageTracePanel({ trace, defaultOpen, storageKey }: MessageTracePanelProps): ReactElement {
+export function MessageTracePanel({ trace, defaultOpen, storageKey, markdownUpgradeDelayMs }: MessageTracePanelProps): ReactElement {
   const traceRunning = isRunningTrace(trace);
   const storedOpen = readStoredOpen(storageKey, defaultOpen ?? traceRunning);
   const [open, setOpen] = useState(storedOpen);
@@ -74,7 +75,7 @@ export function MessageTracePanel({ trace, defaultOpen, storageKey }: MessageTra
                   
                   {row.header}
                 
-                {row.body ?  : null}
+                {row.body ?  : null}
               
             ))}
           
@@ -84,11 +85,11 @@ export function MessageTracePanel({ trace, defaultOpen, storageKey }: MessageTra
   );
 }
 
-function TraceRowBody({ row }: { row: TraceEventRow }): ReactElement | null {
+function TraceRowBody({ row, markdownUpgradeDelayMs }: { row: TraceEventRow; markdownUpgradeDelayMs?: number }): ReactElement | null {
   if (!row.body) return null;
   if (row.bodyFormat === "markdown") {
     return (
-      
+      
         {row.body}
       
     );
diff --git a/web/hwlab-cloud-web/src/components/shared/MarkdownRenderer.tsx b/web/hwlab-cloud-web/src/components/shared/MarkdownRenderer.tsx
new file mode 100644
index 00000000..3692ba9c
--- /dev/null
+++ b/web/hwlab-cloud-web/src/components/shared/MarkdownRenderer.tsx
@@ -0,0 +1,18 @@
+import type { ReactElement } from "react";
+import ReactMarkdown from "react-markdown";
+import remarkGfm from "remark-gfm";
+
+export function MarkdownRenderer({ children }: { children: string }): ReactElement {
+  return (
+     (
+          {linkChildren}
+        )
+      }}
+    >
+      {children}
+    
+  );
+}
diff --git a/web/hwlab-cloud-web/src/components/shared/MessageMarkdown.test.tsx b/web/hwlab-cloud-web/src/components/shared/MessageMarkdown.test.tsx
index c9bc05b7..33cda92e 100644
--- a/web/hwlab-cloud-web/src/components/shared/MessageMarkdown.test.tsx
+++ b/web/hwlab-cloud-web/src/components/shared/MessageMarkdown.test.tsx
@@ -2,11 +2,20 @@ import assert from "node:assert/strict";
 import { test } from "bun:test";
 import { renderToStaticMarkup } from "react-dom/server";
 
-import { MessageMarkdown } from "./MessageMarkdown";
+import { MarkdownRenderer } from "./MarkdownRenderer";
+import { MessageMarkdown, PlainMessageText } from "./MessageMarkdown";
 
-test("message markdown renders GFM tables and inline code through the shared third-party component", () => {
+test("message markdown module keeps the third-party renderer lazy for first screen", async () => {
+  const source = await Bun.file(new URL("./MessageMarkdown.tsx", import.meta.url)).text();
+
+  assert.doesNotMatch(source, /from "react-markdown"/u);
+  assert.match(source, /lazy\(\(\) => import\("\.\/MarkdownRenderer"\)/u);
+  assert.match(source, /upgradeDelayMs = 1800/u);
+});
+
+test("markdown renderer renders GFM tables and inline code through the shared third-party component", () => {
   const html = renderToStaticMarkup(
-    {"| Key | Value |\n| --- | --- |\n| **status** | `ok` |"}
+    {"| Key | Value |\n| --- | --- |\n| **status** | `ok` |"}
   );
 
   assert.match(html, /
/u); @@ -15,7 +24,7 @@ test("message markdown renders GFM tables and inline code through the shared thi }); test("message markdown escapes raw HTML instead of injecting handwritten HTML", () => { - const html = renderToStaticMarkup({""}); + const html = renderToStaticMarkup({""}); assert.doesNotMatch(html, /"}); + + assert.doesNotMatch(html, /