Merge pull request #922 from pikasTech/fix/issue912-workspace-history-lcp

fix: keep v02 workspace history out of first screen LCP
This commit is contained in:
Lyon
2026-06-05 17:03:09 +08:00
committed by GitHub
13 changed files with 183 additions and 34 deletions
+19
View File
@@ -5,6 +5,25 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>HWLAB 云工作台</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<script>
(function () {
var hash = window.location.hash;
var path = window.location.pathname.replace(/\/+$/g, "") || "/";
if (path !== "/" && path !== "/workspace") return;
if (hash && hash !== "#/workspace" && hash !== "#/") return;
var projectId = "prj_device_pod_workbench";
window.HWLAB_CLOUD_WEB_EARLY_WORKSPACE_BOOTSTRAP = fetch("/auth/workspace-bootstrap?projectId=" + encodeURIComponent(projectId), {
credentials: "same-origin",
headers: { accept: "application/json" }
}).then(function (response) {
return response.json().catch(function () { return null; }).then(function (body) {
return { ok: response.ok, status: response.status, data: body, projectId: projectId };
});
}).catch(function (error) {
return { ok: false, status: 0, data: null, error: String(error && error.message ? error.message : error), projectId: projectId };
});
}());
</script>
</head>
<body data-auth-state="checking">
<div id="root"></div>
+5
View File
@@ -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 id="root"><\/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<ApiResult<LiveProbePayload>> => 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");
+1 -1
View File
@@ -185,7 +185,7 @@ export function App(): ReactElement {
/>
) : null}
<section className="center-workspace">
{route === "workspace" ? <ConversationPanel messages={store.state.messages} availability={store.state.codeAgentAvailability} live={store.state.live} chatPending={store.state.chatPending} codeAgentTimeoutMs={store.state.codeAgentTimeoutMs} onCopySession={() => 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" ? <ConversationPanel messages={store.state.messages} loading={store.state.loading} availability={store.state.codeAgentAvailability} live={store.state.live} chatPending={store.state.chatPending} codeAgentTimeoutMs={store.state.codeAgentTimeoutMs} onCopySession={() => 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" ? <DeferredRouteView><PerformanceView /></DeferredRouteView> : null}
{route === "skills" ? <DeferredRouteView><SkillsView /></DeferredRouteView> : null}
{route === "access" ? <DeferredRouteView><AccessView /></DeferredRouteView> : null}
@@ -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);
});
@@ -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<string>();
export function ConversationPanel({ messages, loading, availability, live, chatPending, codeAgentTimeoutMs, onCopySession, onCancelMessage, onRetryMessage, onReplayTrace }: ConversationPanelProps & { codeAgentTimeoutMs: number }): ReactElement {
const scrollFollow = useScrollFollow<HTMLElement>([messages]);
const firstScreenMessageIds = useFirstScreenMessageIds(messages, loading);
return (
<section className="view workbench-view" id="workspace" data-view="workspace" data-scroll-follow={scrollFollow.following ? "bottom" : "detached"} aria-labelledby="workspace-title" ref={scrollFollow.ref} onScroll={scrollFollow.onScroll}>
<div className="conversation-column">
@@ -48,6 +52,7 @@ export function ConversationPanel({ messages, availability, live, chatPending, c
<MessageCard
key={message.id}
message={message}
firstScreenHistory={firstScreenMessageIds.has(message.id)}
codeAgentTimeoutMs={codeAgentTimeoutMs}
onCancel={() => 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<string> {
const capturedRef = useRef<Set<string> | 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<string, unknown> | 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 (
<article className={`message-card message-${message.role} tone-border-${toneClass(message.status)}`} data-message-status={message.status}>
{message.runnerTrace ? <MessageTracePanel trace={message.runnerTrace} defaultOpen={message.status === "running"} storageKey={traceKey} /> : null}
<section className="message-final-response" aria-label="Final Response">
{hasFinalResponse ? <MessageMarkdown>{message.text}</MessageMarkdown> : null}
{message.runnerTrace ? <MessageTracePanel trace={message.runnerTrace} defaultOpen={message.status === "running"} storageKey={traceKey} markdownUpgradeDelayMs={firstScreenHistory ? FIRST_SCREEN_HISTORY_MARKDOWN_DELAY_MS : undefined} /> : null}
<section className="message-final-response" aria-label="Final Response" data-first-screen-history={firstScreenHistory ? "true" : undefined}>
{hasFinalResponse ? <MessageMarkdown upgradeDelayMs={firstScreenHistory ? FIRST_SCREEN_HISTORY_MARKDOWN_DELAY_MS : undefined}>{message.text}</MessageMarkdown> : null}
</section>
<MessageDebug
message={message}
@@ -144,7 +158,7 @@ function MessageCard({ message, codeAgentTimeoutMs, onCancel, onRetry, onReplayT
}
return (
<article className={`message-card message-${message.role} tone-border-${toneClass(message.status)}`} data-message-status={message.status}>
<MessageMarkdown>{message.text}</MessageMarkdown>
<MessageMarkdown upgradeDelayMs={firstScreenHistory ? FIRST_SCREEN_HISTORY_MARKDOWN_DELAY_MS : undefined}>{message.text}</MessageMarkdown>
<MessageDebug
message={message}
codeAgentTimeoutMs={codeAgentTimeoutMs}
@@ -5,7 +5,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { MessageTracePanel } from "./MessageTracePanel";
import type { RunnerTrace } from "../../types/domain";
test("message trace panel renders assistant markdown rows through shared markdown renderer", () => {
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(<MessageTracePanel trace={trace} defaultOpen />);
assert.match(html, /data-body-format="markdown"/u);
assert.match(html, /<table>/u);
assert.match(html, /<code>D601-F103-V2<\/code>/u);
assert.match(html, /\| devicePodId \| debug \|/u);
assert.match(html, /`D601-F103-V2`/u);
assert.doesNotMatch(html, /<pre class="message-trace-body message-copy-markdown" data-body-format="markdown"/u);
});
test("message trace panel accepts a delayed markdown upgrade for first-screen history", () => {
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",
@@ -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<boolean>(storedOpen);
@@ -74,7 +75,7 @@ export function MessageTracePanel({ trace, defaultOpen, storageKey }: MessageTra
<span className={`message-trace-tone tone-${toneClass(row.tone)}`} aria-hidden="true"></span>
<span className="message-trace-label">{row.header}</span>
</header>
{row.body ? <TraceRowBody row={row} /> : null}
{row.body ? <TraceRowBody row={row} markdownUpgradeDelayMs={markdownUpgradeDelayMs} /> : null}
</li>
))}
</ol>
@@ -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 (
<MessageMarkdown className="message-trace-body message-trace-markdown message-copy-markdown" data-body-format="markdown">
<MessageMarkdown className="message-trace-body message-trace-markdown message-copy-markdown" data-body-format="markdown" upgradeDelayMs={markdownUpgradeDelayMs}>
{row.body}
</MessageMarkdown>
);
@@ -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 (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ href, children: linkChildren, node: _node, ...props }) => (
<a {...props} href={href} rel="noreferrer" target="_blank">{linkChildren}</a>
)
}}
>
{children}
</ReactMarkdown>
);
}
@@ -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(
<MessageMarkdown>{"| Key | Value |\n| --- | --- |\n| **status** | `ok` |"}</MessageMarkdown>
<MarkdownRenderer>{"| Key | Value |\n| --- | --- |\n| **status** | `ok` |"}</MarkdownRenderer>
);
assert.match(html, /<table>/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(<MessageMarkdown>{"<script>alert(1)</script>"}</MessageMarkdown>);
const html = renderToStaticMarkup(<MarkdownRenderer>{"<script>alert(1)</script>"}</MarkdownRenderer>);
assert.doesNotMatch(html, /<script>/u);
assert.match(html, /&lt;script&gt;alert\(1\)&lt;\/script&gt;/u);
@@ -27,3 +36,10 @@ test("message markdown preserves source newlines for final response display", ()
assert.match(html, /class="message-body"/u);
assert.match(html, /\n/u);
});
test("plain first-screen fallback escapes raw HTML", () => {
const html = renderToStaticMarkup(<PlainMessageText>{"<script>alert(1)</script>"}</PlainMessageText>);
assert.doesNotMatch(html, /<script>/u);
assert.match(html, /&lt;script&gt;alert\(1\)&lt;\/script&gt;/u);
});
@@ -1,26 +1,33 @@
import type { ReactElement } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { ReactElement, ReactNode } from "react";
import { lazy, Suspense, useEffect, useState } from "react";
interface MessageMarkdownProps {
children: string;
className?: string;
upgradeDelayMs?: number;
[key: `data-${string}`]: string | undefined;
}
export function MessageMarkdown({ children, className = "message-body", ...props }: MessageMarkdownProps): ReactElement {
export function MessageMarkdown({ children, className = "message-body", upgradeDelayMs = 1800, ...props }: MessageMarkdownProps): ReactElement {
return (
<div className={className} {...props}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ href, children: linkChildren, node: _node, ...props }) => (
<a {...props} href={href} rel="noreferrer" target="_blank">{linkChildren}</a>
)
}}
>
{children}
</ReactMarkdown>
<DeferredMarkdown source={children} upgradeDelayMs={upgradeDelayMs} fallback={<PlainMessageText>{children}</PlainMessageText>} />
</div>
);
}
const MarkdownRenderer = lazy(() => import("./MarkdownRenderer").then((module) => ({ default: module.MarkdownRenderer })));
function DeferredMarkdown({ source, upgradeDelayMs, fallback }: { source: string; upgradeDelayMs: number; fallback: ReactNode }): ReactElement {
const [ready, setReady] = useState(false);
useEffect(() => {
const id = window.setTimeout(() => setReady(true), Math.max(0, upgradeDelayMs));
return () => window.clearTimeout(id);
}, [source, upgradeDelayMs]);
if (!ready) return <>{fallback}</>;
return <Suspense fallback={fallback}><MarkdownRenderer>{source}</MarkdownRenderer></Suspense>;
}
export function PlainMessageText({ children }: { children: string }): ReactElement {
return <p>{children}</p>;
}
+27 -1
View File
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { api } from "../services/api/client";
import { WORKBENCH_PROJECT_ID } from "../state/constants";
import type { AuthSession, AuthState, AuthUser, WorkspaceRecord } from "../types/domain";
import type { WorkbenchEarlyWorkspaceBootstrap } from "../types/config";
import { asRecord, nonEmptyString } from "../utils";
const DEFAULT_USERNAME = "admin";
@@ -26,7 +27,7 @@ export function useAuth(): UseAuthResult {
async function run(): Promise<void> {
const config = window.HWLAB_CLOUD_WEB_CONFIG?.auth;
const mode = config?.mode ?? "auto";
const current = mode === "auto" ? await api.workspaceBootstrap(WORKBENCH_PROJECT_ID) : await api.session();
const current = mode === "auto" ? await resolveWorkspaceBootstrap() : await api.session();
if (canceled) return;
if (current.ok && current.data?.authenticated === true) {
const workspace = workspaceFromPayload(current.data);
@@ -82,6 +83,31 @@ export function useAuth(): UseAuthResult {
return useMemo(() => ({ authState, session, error, login, logout }), [authState, session, error, login, logout]);
}
async function resolveWorkspaceBootstrap(): ReturnType<typeof api.workspaceBootstrap> {
const early = await consumeEarlyWorkspaceBootstrap();
if (early) return early;
return api.workspaceBootstrap(WORKBENCH_PROJECT_ID);
}
async function consumeEarlyWorkspaceBootstrap(): Promise<Awaited<ReturnType<typeof api.workspaceBootstrap>> | null> {
const pending = window.HWLAB_CLOUD_WEB_EARLY_WORKSPACE_BOOTSTRAP;
if (!pending) return null;
window.HWLAB_CLOUD_WEB_EARLY_WORKSPACE_BOOTSTRAP = undefined;
const early = await pending;
if (early.projectId !== WORKBENCH_PROJECT_ID) return null;
return normalizeEarlyWorkspaceBootstrap(early);
}
function normalizeEarlyWorkspaceBootstrap(early: WorkbenchEarlyWorkspaceBootstrap): Awaited<ReturnType<typeof api.workspaceBootstrap>> {
const data = early.data ?? null;
return {
ok: early.ok === true,
status: Number.isFinite(Number(early.status)) ? Number(early.status) : 0,
data,
error: early.ok === true ? null : early.error ?? `HTTP ${early.status ?? 0}`
};
}
function workspaceFromPayload(payload: unknown): WorkspaceRecord | null {
const record = asRecord(payload);
const workspace = asRecord(record?.workspace);
@@ -200,6 +200,13 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
.message-card.message-user { justify-self: end; width: fit-content; max-width: min(74%, 720px); padding: 0; border-color: #c9d7d2; background: #f7fbf8; }
.message-card.message-agent { display: grid; gap: 6px; width: 100%; border-left-width: 4px; background: linear-gradient(90deg, #fbfffb 0%, #fff 42%); }
.message-card.message-agent .message-final-response { border: 1px solid #dce7df; border-radius: 6px; background: #fbfdf9; padding: 8px 10px; }
.message-card.message-agent .message-final-response[data-first-screen-history="true"] { max-height: calc(4 * 1.45em + 24px); overflow: auto; scrollbar-gutter: stable; }
.message-card.message-agent .message-final-response[data-first-screen-history="true"] .message-body > p,
.message-card.message-agent .message-final-response[data-first-screen-history="true"] .message-body > ul,
.message-card.message-agent .message-final-response[data-first-screen-history="true"] .message-body > ol,
.message-card.message-agent .message-final-response[data-first-screen-history="true"] .message-body > blockquote,
.message-card.message-agent .message-final-response[data-first-screen-history="true"] .message-body > table,
.message-card.message-agent .message-final-response[data-first-screen-history="true"] .message-body > pre { max-height: calc(3 * 1.45em + 8px); overflow: auto; scrollbar-gutter: stable; }
.message-head { display: none; }
.message-body { line-height: 1.45; overflow-wrap: anywhere; }
.message-body p { white-space: pre-wrap; }
+18
View File
@@ -30,10 +30,28 @@ export interface WorkbenchWorkspaceBootstrap {
consumed?: boolean;
}
export interface WorkbenchEarlyWorkspaceBootstrap {
ok?: boolean;
status?: number;
data?: {
authenticated?: boolean;
user?: unknown;
actor?: unknown;
expiresAt?: string;
projectId?: string;
workspace?: WorkspaceRecord | null;
workspaceStatus?: number | null;
workspaceError?: unknown;
} | null;
error?: string;
projectId?: string;
}
declare global {
interface Window {
HWLAB_CLOUD_WEB_CONFIG?: WorkbenchConfig;
HWLAB_CLOUD_WEB_TIMEOUTS?: WorkbenchTimeoutConfig;
HWLAB_CLOUD_WEB_WORKSPACE_BOOTSTRAP?: WorkbenchWorkspaceBootstrap;
HWLAB_CLOUD_WEB_EARLY_WORKSPACE_BOOTSTRAP?: Promise<WorkbenchEarlyWorkspaceBootstrap>;
}
}