diff --git a/web/hwlab-cloud-web/src/components/command-bar/CommandBar.test.ts b/web/hwlab-cloud-web/src/components/command-bar/CommandBar.test.ts
index 301f8c11..8df0f939 100644
--- a/web/hwlab-cloud-web/src/components/command-bar/CommandBar.test.ts
+++ b/web/hwlab-cloud-web/src/components/command-bar/CommandBar.test.ts
@@ -4,6 +4,8 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { test } from "bun:test";
+import { commandInputRows } from "./CommandBar";
+
const sourcePath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "CommandBar.tsx");
test("CommandBar releases the send lock on the next tick instead of waiting for the full agent turn", () => {
@@ -13,3 +15,10 @@ test("CommandBar releases the send lock on the next tick instead of waiting for
assert.doesNotMatch(source, /await props\.onSubmit\(next\)/u);
assert.doesNotMatch(source, /\.finally\(\(\) => setSubmitting\(false\)\)/u);
});
+
+test("CommandBar textarea grows to five physical lines then becomes scrollable", () => {
+ assert.deepEqual(commandInputRows(""), { rows: 1, scrollable: false });
+ assert.deepEqual(commandInputRows("one\ntwo\nthree"), { rows: 3, scrollable: false });
+ assert.deepEqual(commandInputRows("1\n2\n3\n4\n5"), { rows: 5, scrollable: false });
+ assert.deepEqual(commandInputRows("1\n2\n3\n4\n5\n6"), { rows: 5, scrollable: true });
+});
diff --git a/web/hwlab-cloud-web/src/components/command-bar/CommandBar.tsx b/web/hwlab-cloud-web/src/components/command-bar/CommandBar.tsx
index 843c4b24..2b28c8bd 100644
--- a/web/hwlab-cloud-web/src/components/command-bar/CommandBar.tsx
+++ b/web/hwlab-cloud-web/src/components/command-bar/CommandBar.tsx
@@ -1,5 +1,5 @@
import type { ReactElement } from "react";
-import { FormEvent, KeyboardEvent, useEffect, useState } from "react";
+import { FormEvent, KeyboardEvent, useEffect, useMemo, useState } from "react";
import type { ProviderProfile } from "../../types/domain";
import { DraftsList } from "./DraftsList";
@@ -31,6 +31,7 @@ export function CommandBar({ pickedDraft, onPickedDraftConsumed, disabledReason,
const [value, setValue] = useState("");
const [submitting, setSubmitting] = useState(false);
const [draftsOpen, setDraftsOpen] = useState(false);
+ const inputRows = useMemo(() => commandInputRows(value), [value]);
useEffect(() => {
if (pickedDraft) {
@@ -68,7 +69,7 @@ export function CommandBar({ pickedDraft, onPickedDraftConsumed, disabledReason,
hwlab
-
@@ -78,6 +79,11 @@ export function CommandBar({ pickedDraft, onPickedDraftConsumed, disabledReason,
);
}
+export function commandInputRows(value: string): { rows: number; scrollable: boolean } {
+ const physicalLines = Math.max(1, value.split("\n").length);
+ return { rows: Math.min(5, physicalLines), scrollable: physicalLines > 5 };
+}
+
function sendLabel(submitMode: "turn" | "steer", submitting: boolean): string {
if (submitting) return submitMode === "steer" ? "引导中" : "发送中";
return submitMode === "steer" ? "引导" : "发送";
diff --git a/web/hwlab-cloud-web/src/components/conversation/MessageTracePanel.tsx b/web/hwlab-cloud-web/src/components/conversation/MessageTracePanel.tsx
index 874e4164..686244a1 100644
--- a/web/hwlab-cloud-web/src/components/conversation/MessageTracePanel.tsx
+++ b/web/hwlab-cloud-web/src/components/conversation/MessageTracePanel.tsx
@@ -24,9 +24,7 @@ export function MessageTracePanel({ trace, defaultOpen, storageKey }: MessageTra
useEffect(() => {
if (traceRunning) {
setOpen(true);
- return;
}
- setOpen(false);
}, [traceRunning, trace.traceId]);
useEffect(() => {
diff --git a/web/hwlab-cloud-web/src/state/conversation.ts b/web/hwlab-cloud-web/src/state/conversation.ts
index 4368a8d4..f73a61c3 100644
--- a/web/hwlab-cloud-web/src/state/conversation.ts
+++ b/web/hwlab-cloud-web/src/state/conversation.ts
@@ -95,7 +95,7 @@ export async function ensureWorkspace(current: WorkspaceRecord | null): Promise<
return response.ok ? response.data?.workspace ?? null : null;
}
-export async function persistConversation(input: { workspace: WorkspaceRecord | null; conversationId: string; sessionId: string | null; threadId: string | null; messages: ChatMessage[] }): Promise {
+export async function persistConversation(input: { workspace: WorkspaceRecord | null; conversationId: string; sessionId: string | null; threadId: string | null; messages: ChatMessage[] }): Promise {
await api.saveConversation(input.conversationId, {
projectId: WORKBENCH_PROJECT_ID,
conversationId: input.conversationId,
@@ -107,7 +107,7 @@ export async function persistConversation(input: { workspace: WorkspaceRecord |
snapshot: { source: "cloud-web-react-account-sync", updatedAt: new Date().toISOString(), sessionStatus: input.messages.at(-1)?.status ?? "source" }
});
if (input.workspace?.workspaceId) {
- await api.updateWorkspace(input.workspace.workspaceId, {
+ const response = await api.updateWorkspace(input.workspace.workspaceId, {
projectId: WORKBENCH_PROJECT_ID,
expectedRevision: input.workspace.revision,
selectedConversationId: input.conversationId,
@@ -118,7 +118,9 @@ export async function persistConversation(input: { workspace: WorkspaceRecord |
workspace: { source: "cloud-web-react-workspace-sync", updatedAt: new Date().toISOString() },
updatedByClient: "cloud-web-react"
});
+ return response.ok ? response.data?.workspace ?? input.workspace : input.workspace;
}
+ return input.workspace;
}
export function conversationsToTabs(conversations: ConversationRecord[], activeConversationId: string | null): SessionTab[] {
diff --git a/web/hwlab-cloud-web/src/state/issue853-refresh-regression.test.ts b/web/hwlab-cloud-web/src/state/issue853-refresh-regression.test.ts
new file mode 100644
index 00000000..2706a184
--- /dev/null
+++ b/web/hwlab-cloud-web/src/state/issue853-refresh-regression.test.ts
@@ -0,0 +1,30 @@
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { test } from "bun:test";
+
+const srcRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+
+test("issue 853 persists submitted user and running agent messages before long Code Agent polling", () => {
+ const source = fs.readFileSync(path.join(srcRoot, "state/workbench.ts"), "utf8");
+ const pendingIndex = source.indexOf('dispatch({ type: "message:pending"');
+ const persistIndex = source.indexOf("let currentWorkspace = await persistConversation", pendingIndex);
+ const routeIndex = source.indexOf("const route = steerMode ? api.steerAgentMessage : api.sendAgentMessage", pendingIndex);
+ assert.ok(pendingIndex > 0);
+ assert.ok(persistIndex > pendingIndex);
+ assert.ok(routeIndex > persistIndex);
+ assert.match(source.slice(persistIndex, routeIndex), /messages: \[\.\.\.state\.messages, user, pendingWithRetry\]/u);
+});
+
+test("issue 853 left sidebar collapse only removes the left columns", () => {
+ const css = fs.readFileSync(path.join(srcRoot, "styles/workbench.css"), "utf8");
+ assert.match(css, /\.workbench-shell\.is-left-sidebar-collapsed \.workspace-body \{ grid-template-columns: 0 0 minmax\(0, 1fr\) var\(--resize-handle-width\) var\(--right-sidebar-width\); \}/u);
+ assert.doesNotMatch(css, /\.workbench-shell\.is-left-sidebar-collapsed \.workspace-body \{ grid-template-columns: 0 0 minmax\(420px, 1fr\)/u);
+});
+
+test("issue 853 completed trace panels keep stored open state instead of closing on refresh", () => {
+ const source = fs.readFileSync(path.join(srcRoot, "components/conversation/MessageTracePanel.tsx"), "utf8");
+ assert.doesNotMatch(source, /setOpen\(false\);/u);
+ assert.match(source, /if \(traceRunning\) \{\n\s+setOpen\(true\);\n\s+\}/u);
+});
diff --git a/web/hwlab-cloud-web/src/state/runner-trace.test.ts b/web/hwlab-cloud-web/src/state/runner-trace.test.ts
index 0aa7b7a6..3a353dbc 100644
--- a/web/hwlab-cloud-web/src/state/runner-trace.test.ts
+++ b/web/hwlab-cloud-web/src/state/runner-trace.test.ts
@@ -48,6 +48,35 @@ test("mergeTraceResults keeps trace assistant markdown ahead of flattened termin
assert.match(merged.assistantText ?? "", /\n\n\| devicePodId \| debug \|/u);
});
+test("mergeTraceResults keeps full polled trace events when terminal runnerTrace is compact", () => {
+ const terminal: AgentChatResultResponse = {
+ status: "completed",
+ traceId: "trc_compact_terminal",
+ assistantText: "最终回复",
+ runnerTrace: {
+ traceId: "trc_compact_terminal",
+ status: "completed",
+ eventCount: 97,
+ eventsCompacted: true,
+ fullTraceLoaded: false
+ }
+ };
+ const events: TraceEvent[] = [
+ { seq: 1, label: "agentrun:backend:turn-started", status: "running" },
+ { seq: 42, label: "agentrun:assistant:message", type: "assistant", status: "running", message: "中间回复" },
+ { seq: 97, label: "agentrun:result:completed", type: "result", status: "completed" }
+ ];
+ const trace: TraceSnapshot = { traceId: "trc_compact_terminal", status: "completed", events, eventCount: 97, eventsCompacted: false, lastEventLabel: "agentrun:result:completed" };
+
+ const merged = mergeTraceResults(terminal, trace);
+
+ assert.equal(merged.runnerTrace?.events?.length, 3);
+ assert.equal(merged.runnerTrace?.eventCount, 97);
+ assert.equal(merged.runnerTrace?.eventsCompacted, false);
+ assert.equal(merged.runnerTrace?.fullTraceLoaded, true);
+ assert.equal(merged.traceEvents?.length, 3);
+});
+
test("subscribeToTrace keeps polling result when trace terminal lacks final response text", async () => {
const originalFetch = globalThis.fetch;
const originalSetTimeout = globalThis.window?.setTimeout;
diff --git a/web/hwlab-cloud-web/src/state/runner-trace.ts b/web/hwlab-cloud-web/src/state/runner-trace.ts
index e4c6253f..b3318347 100644
--- a/web/hwlab-cloud-web/src/state/runner-trace.ts
+++ b/web/hwlab-cloud-web/src/state/runner-trace.ts
@@ -76,7 +76,9 @@ export function mergeTraceResults(terminal: AgentChatResultResponse, trace: Trac
if (!trace) return terminal;
const traceEvents = Array.isArray(trace.events) ? trace.events : [];
const terminalEvents = Array.isArray((terminal as { events?: TraceEvent[] }).events) ? ((terminal as { events?: TraceEvent[] }).events ?? []) : [];
- const events = traceEvents.length >= terminalEvents.length ? traceEvents : terminalEvents;
+ const terminalRunnerTrace = terminal.runnerTrace && typeof terminal.runnerTrace === "object" ? terminal.runnerTrace : null;
+ const terminalRunnerEvents = Array.isArray(terminalRunnerTrace?.events) ? terminalRunnerTrace.events : [];
+ const events = longestTraceEvents(traceEvents, terminalEvents, terminalRunnerEvents);
const mergedTrace = { ...trace, events, eventCount: trace.eventCount ?? events.length };
const assistantText = assistantTextFromTraceEvents(events) ?? firstNonEmptyResultText(terminal);
return {
@@ -86,7 +88,7 @@ export function mergeTraceResults(terminal: AgentChatResultResponse, trace: Trac
threadId: trace.threadId ?? terminal.threadId,
agentRun: trace.agentRun ?? terminal.agentRun,
assistantText: assistantText ?? terminal.assistantText,
- runnerTrace: terminal.runnerTrace ?? snapshotToRunnerTrace(mergedTrace),
+ runnerTrace: mergeTerminalRunnerTrace(terminalRunnerTrace, mergedTrace),
traceEvents: terminal.traceEvents ?? events,
events,
eventCount: trace.eventCount ?? events.length,
@@ -94,6 +96,26 @@ export function mergeTraceResults(terminal: AgentChatResultResponse, trace: Trac
} as AgentChatResultResponse;
}
+function longestTraceEvents(...candidates: TraceEvent[][]): TraceEvent[] {
+ return candidates.reduce((best, item) => item.length > best.length ? item : best, [] as TraceEvent[]);
+}
+
+function mergeTerminalRunnerTrace(terminalTrace: AgentChatResultResponse["runnerTrace"] | null, trace: TraceSnapshot): AgentChatResultResponse["runnerTrace"] {
+ const restored = snapshotToRunnerTrace(trace);
+ if (!terminalTrace) return restored;
+ const terminalEvents = Array.isArray(terminalTrace.events) ? terminalTrace.events : [];
+ const restoredEvents = Array.isArray(restored.events) ? restored.events : [];
+ const events = restoredEvents.length >= terminalEvents.length ? restoredEvents : terminalEvents;
+ return {
+ ...terminalTrace,
+ ...restored,
+ events,
+ eventCount: Math.max(Number(terminalTrace.eventCount ?? 0) || 0, Number(restored.eventCount ?? 0) || 0, events.length),
+ eventsCompacted: false,
+ fullTraceLoaded: events.length > 0 || restored.fullTraceLoaded === true || terminalTrace.fullTraceLoaded === true
+ };
+}
+
function firstNonEmptyResultText(result: AgentChatResultResponse): string | null {
const reply = result.reply;
const replyText = typeof reply === "string" ? reply : typeof reply?.content === "string" ? reply.content : null;
diff --git a/web/hwlab-cloud-web/src/state/workbench-reducer.ts b/web/hwlab-cloud-web/src/state/workbench-reducer.ts
index 0036755f..d68d1410 100644
--- a/web/hwlab-cloud-web/src/state/workbench-reducer.ts
+++ b/web/hwlab-cloud-web/src/state/workbench-reducer.ts
@@ -6,6 +6,7 @@ import type { ComposerState, WorkbenchState } from "./workbench-state";
export type Action =
| { type: "hydrate:start" }
| { type: "hydrate:done"; workspace: WorkbenchState["workspace"]; conversations: ConversationRecord[]; messages: ChatMessage[] }
+ | { type: "workspace:sync"; workspace: WorkbenchState["workspace"] }
| { type: "hydrate:error"; error: string }
| { type: "live:set"; live: WorkbenchState["live"]; availability: CodeAgentAvailability | null; selectedDevicePodId: string }
| { type: "device:selected"; devicePodId: string }
@@ -27,6 +28,7 @@ export function workbenchReducer(state: WorkbenchState, action: Action): Workben
switch (action.type) {
case "hydrate:start": return { ...state, loading: true, error: null };
case "hydrate:done": return { ...state, loading: false, workspace: action.workspace, conversations: action.conversations, messages: action.messages, error: null };
+ case "workspace:sync": return { ...state, workspace: action.workspace ?? state.workspace };
case "hydrate:error": return { ...state, loading: false, error: action.error };
case "live:set": return { ...state, live: action.live, codeAgentAvailability: action.availability, selectedDevicePodId: action.selectedDevicePodId };
case "device:selected": return { ...state, selectedDevicePodId: action.devicePodId };
diff --git a/web/hwlab-cloud-web/src/state/workbench.ts b/web/hwlab-cloud-web/src/state/workbench.ts
index d1bfb75c..84ad73d6 100644
--- a/web/hwlab-cloud-web/src/state/workbench.ts
+++ b/web/hwlab-cloud-web/src/state/workbench.ts
@@ -244,9 +244,9 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore {
const pending = makeMessage("agent", "正在处理这次 Code Agent 请求;复杂问题可能需要几分钟。", "running", { traceId, conversationId, sessionId, threadId, title: "Code Agent 处理中" });
const pendingWithRetry: ChatMessage = { ...pending, retryInput: value };
dispatch({ type: "message:pending", user, pending: pendingWithRetry, request: { traceId, conversationId, sessionId, threadId } });
+ let currentWorkspace = await persistConversation({ workspace: state.workspace, conversationId, sessionId, threadId, messages: [...state.messages, user, pendingWithRetry] });
+ if (currentWorkspace) dispatch({ type: "workspace:sync", workspace: currentWorkspace });
const route = steerMode ? api.steerAgentMessage : api.sendAgentMessage;
- // The submit call itself is a "kicked off" event; treat it as activity
- // so the initial POST has a fresh inactivity window.
updateActivity();
const submitActivityRef = buildActivityRef("code-agent-submit", null);
const response = await route({
@@ -259,23 +259,20 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore {
traceId,
providerProfile: state.providerProfile,
gatewayShellTimeoutMs: state.gatewayShellTimeoutMs,
- workspaceId: state.workspace?.workspaceId,
- workspaceRevision: state.workspace?.revision,
+ workspaceId: currentWorkspace?.workspaceId ?? state.workspace?.workspaceId,
+ workspaceRevision: currentWorkspace?.revision ?? state.workspace?.revision,
targetTraceId: steerMode ? traceId : null,
steerTraceId
}, state.codeAgentTimeoutMs, submitActivityRef);
const finalize = async (result: AgentChatResultResponse, availability: CodeAgentAvailability | null): Promise => {
const completed = messageFromAgentResponse(pending.id, pending, result);
dispatch({ type: "message:complete", messageId: pending.id, message: completed, availability: availability ?? result.availability ?? null });
- await persistConversation({ workspace: state.workspace, conversationId, sessionId: completed.sessionId ?? sessionId, threadId: completed.threadId ?? threadId, messages: [...state.messages, user, completed] });
+ currentWorkspace = await persistConversation({ workspace: currentWorkspace ?? state.workspace, conversationId, sessionId: completed.sessionId ?? sessionId, threadId: completed.threadId ?? threadId, messages: [...state.messages, user, completed] });
+ if (currentWorkspace) dispatch({ type: "workspace:sync", workspace: currentWorkspace });
const conversations = await api.conversations(WORKBENCH_PROJECT_ID);
if (conversations.ok) dispatch({ type: "conversation:list", conversations: conversations.data?.conversations ?? [] });
};
if (response.ok && response.data) {
- // HWLAB #802: single unified path. onActivity on every successful
- // poll keeps the per-poll inactivity window from firing on a
- // healthy loop. Persist on both onComplete AND onInfrastructureError
- // so cloud-api records the fail state too.
const subscriptionController = new AbortController();
let lastSnapshot: TraceSnapshot | null = null;
let terminalResult: AgentChatResultResponse | null = null;
@@ -298,7 +295,8 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore {
// the frontend-reported failure, not a silent "running" state.
const failed = makeMessage("agent", `Code Agent 在 ${state.codeAgentTimeoutMs}ms 无活动后中断(${error});可以继续 steer 或查看 trace。`, "failed", { traceId, conversationId, sessionId, threadId, title: "Code Agent 超时" });
dispatch({ type: "message:fail", messageId: pending.id, message: failed });
- void persistConversation({ workspace: state.workspace, conversationId, sessionId, threadId, messages: [...state.messages, user, failed] });
+ void persistConversation({ workspace: currentWorkspace ?? state.workspace, conversationId, sessionId, threadId, messages: [...state.messages, user, failed] })
+ .then((workspace) => { if (workspace) dispatch({ type: "workspace:sync", workspace }); });
subscriptionController.abort();
}
});
@@ -315,6 +313,8 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore {
} else {
const failed = makeMessage("agent", response.error ?? "Code Agent 请求失败", "failed", { traceId, conversationId, sessionId, threadId, title: "Code Agent 请求失败" });
dispatch({ type: "message:fail", messageId: pending.id, message: failed });
+ currentWorkspace = await persistConversation({ workspace: currentWorkspace ?? state.workspace, conversationId, sessionId, threadId, messages: [...state.messages, user, failed] });
+ if (currentWorkspace) dispatch({ type: "workspace:sync", workspace: currentWorkspace });
}
dispatch({ type: "chat:done" });
}, [activeConversationId, composer.conversationId, composer.sessionId, composer.submitMode, composer.targetTraceId, composer.threadId, state.codeAgentTimeoutMs, state.gatewayShellTimeoutMs, state.messages, state.providerProfile, state.workspace]);
diff --git a/web/hwlab-cloud-web/src/styles/workbench.css b/web/hwlab-cloud-web/src/styles/workbench.css
index 258109f2..91fecb1d 100644
--- a/web/hwlab-cloud-web/src/styles/workbench.css
+++ b/web/hwlab-cloud-web/src/styles/workbench.css
@@ -55,9 +55,9 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
.status-chip-label { flex: 0 0 auto; }
.status-chip-meta { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; }
.workspace-body { min-width: 0; min-height: 0; display: grid; grid-template-columns: var(--session-sidebar-width) var(--resize-handle-width) minmax(420px, 1fr) var(--resize-handle-width) var(--right-sidebar-width); grid-template-rows: minmax(0, 1fr); overflow: hidden; }
-.workbench-shell.is-left-sidebar-collapsed .workspace-body { grid-template-columns: 0 0 minmax(420px, 1fr) var(--resize-handle-width) var(--right-sidebar-width); }
+.workbench-shell.is-left-sidebar-collapsed .workspace-body { grid-template-columns: 0 0 minmax(0, 1fr) var(--resize-handle-width) var(--right-sidebar-width); }
.workbench-shell.is-right-sidebar-collapsed .workspace-body { grid-template-columns: var(--session-sidebar-width) var(--resize-handle-width) minmax(420px, 1fr) 0 0; }
-.workbench-shell.is-left-sidebar-collapsed.is-right-sidebar-collapsed .workspace-body { grid-template-columns: 0 0 minmax(420px, 1fr) 0 0; }
+.workbench-shell.is-left-sidebar-collapsed.is-right-sidebar-collapsed .workspace-body { grid-template-columns: 0 0 minmax(0, 1fr) 0 0; }
.workspace-body.is-route-without-sidebars { grid-template-columns: minmax(0, 1fr); }
.activity-rail { min-width: 0; display: grid; grid-template-rows: repeat(4, minmax(54px, auto)) 1fr minmax(54px, auto); gap: 6px; padding: 10px 8px; background: var(--rail); overflow-x: hidden; }
.rail-button { min-width: 0; min-height: 44px; border: 1px solid rgba(255,255,255,0.12); border-radius: 6px; padding: 8px 6px; background: transparent; color: #eef4ed; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
@@ -274,7 +274,8 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
.agent-timeout-control select, .device-pod-picker select { border: 1px solid var(--line); border-radius: 6px; padding: 6px 8px; background: var(--surface); }
.input-shell { min-width: 0; display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: end; gap: 8px; border: 1px solid var(--line); border-radius: 8px; padding: 5px 8px; background: #fff; }
.prompt-mark { color: var(--accent); font-weight: 900; }
-#command-input { min-width: 0; width: 100%; min-height: 30px; max-height: 110px; resize: none; border: 0; outline: none; }
+#command-input { min-width: 0; width: 100%; min-height: 30px; max-height: calc(1.45em * 5 + 8px); line-height: 1.45; resize: none; border: 0; outline: none; overflow-y: hidden; }
+#command-input.is-scrollable { overflow-y: auto; }
.command-bar .command-button { min-height: 32px; padding: 6px 10px; }
.command-drafts-toggle { white-space: nowrap; }