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:
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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, /<script>alert\(1\)<\/script>/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, /<script>alert\(1\)<\/script>/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>;
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user