fix(web): v0.2 round-3 workbench: external right-rail toggle + drag-resize + command input own row + Code Agent result polling (#774)

Fixes HWLAB #759 round-3 v0.2 WebUI issues introduced by the React migration:

1. device-pod right sidebar could not be re-expanded because the toggle button
   was nested inside the right sidebar which is fully hidden when collapsed.
   Move the toggle button to be a direct child of the right sidebar via a
   new anchor button + content wrapper. Add --right-collapsed-width and a
   vertical-rl text rotation so the toggle stays visible and accessible
   when the right sidebar is collapsed.

2. The Session sidebar and right sidebar resize handles had no event
   handlers after the React migration; dragging did nothing. Add a new
   useSidebarResize hook with pointer/keyboard handlers, dynamic bounds
   based on viewport and opposite sidebar width, localStorage
   persistence, and CSS variable injection on the workbench shell.

3. document.querySelector("#command-form > div") (the input shell) was on
   the same row as the timeout selects. Restore the original layout by
   switching the command-bar to grid-template-areas with the input shell
   on its own row and the three selects on a second row.

4. Code Agent was unusable from the React UI because the 202 response has
   status=running and no reply text; the React UI marked the message as
   completed with the fallback empty body. Add a polling loop in the
   workbench store that follows resultUrl until status reaches a terminal
   value, and surface assistantText / reply.content from the polled
   AgentChatResultResponse. Add AgentChatResultResponse, AgentChatReply
   and AgentRunProvenance types plus a getAgentChatResult API method.

Verified locally on G14:web/hwlab-cloud-web: bun run check (12 fresh
dist files), bun run scripts/tsc-check.ts (strict React TSX, 0 explicit
any), bun test (2 pass / 0 fail).

Co-authored-by: HWLAB <ci@hwlab.local>
This commit is contained in:
Lyon
2026-06-03 20:27:28 +08:00
committed by GitHub
parent 0cf9a8c607
commit 3729372e14
8 changed files with 437 additions and 24 deletions
+95 -4
View File
@@ -1,7 +1,8 @@
import type { ReactElement } from "react";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "./hooks/useAuth";
import { useSidebarResize } from "./hooks/useSidebarResize";
import { useWorkbenchStore } from "./state/workbench";
import type { RouteId } from "./types/domain";
import { LoginShell } from "./components/auth/LoginShell";
@@ -14,6 +15,15 @@ import { SettingsView } from "./components/settings/SettingsView";
import { SkillsView } from "./components/skills/SkillsView";
import { fetchText } from "./services/api/client";
const SESSION_SIDEBAR_STORAGE_KEY = "hwlab.workbench.sessionSidebarWidth.v1";
const SESSION_SIDEBAR_DEFAULT_WIDTH = 330;
const SESSION_SIDEBAR_MIN_WIDTH = 180;
const SESSION_SIDEBAR_MAX_WIDTH = 640;
const RIGHT_SIDEBAR_STORAGE_KEY = "hwlab.workbench.rightSidebarWidth.v1";
const RIGHT_SIDEBAR_DEFAULT_WIDTH = 728;
const RIGHT_SIDEBAR_MIN_WIDTH = 560;
const RIGHT_SIDEBAR_MAX_WIDTH = 900;
export function App(): ReactElement {
const auth = useAuth();
const store = useWorkbenchStore(auth.authState === "authenticated");
@@ -21,6 +31,28 @@ export function App(): ReactElement {
const [leftCollapsed, setLeftCollapsed] = useState(false);
const [rightCollapsed, setRightCollapsed] = useState(false);
const [help, setHelp] = useState("加载中");
const shellRef = useRef<HTMLElement | null>(null);
const narrow = useMatchMedia("(max-width: 1240px)");
const sessionResize = useSidebarResize({
storageKey: SESSION_SIDEBAR_STORAGE_KEY,
defaultWidth: SESSION_SIDEBAR_DEFAULT_WIDTH,
bounds: { min: SESSION_SIDEBAR_MIN_WIDTH, max: SESSION_SIDEBAR_MAX_WIDTH },
side: "left",
disabled: narrow || leftCollapsed,
shellElement: shellRef.current,
cssVariable: "--session-sidebar-width"
});
const rightResize = useSidebarResize({
storageKey: RIGHT_SIDEBAR_STORAGE_KEY,
defaultWidth: RIGHT_SIDEBAR_DEFAULT_WIDTH,
bounds: { min: RIGHT_SIDEBAR_MIN_WIDTH, max: RIGHT_SIDEBAR_MAX_WIDTH },
side: "right",
disabled: narrow || rightCollapsed,
shellElement: shellRef.current,
cssVariable: "--right-sidebar-width"
});
useEffect(() => {
const listener = (): void => setRoute(routeFromLocation());
@@ -44,10 +76,36 @@ export function App(): ReactElement {
if (auth.authState === "login") return <LoginShell error={auth.error} onLogin={auth.login} />;
return (
<main className={`workbench-shell${leftCollapsed ? " is-left-sidebar-collapsed" : ""}${rightCollapsed ? " is-right-sidebar-collapsed" : ""}`} data-app-shell data-help-route-policy="non-default-internal-help" data-internal-help-path="/help.md">
<main
ref={shellRef}
className={`workbench-shell${leftCollapsed ? " is-left-sidebar-collapsed" : ""}${rightCollapsed ? " is-right-sidebar-collapsed" : ""}${sessionResize.isDragging || rightResize.isDragging ? " is-resizing" : ""}`}
data-app-shell
data-help-route-policy="non-default-internal-help"
data-internal-help-path="/help.md"
>
<ActivityRail route={route} collapsed={leftCollapsed} onRoute={navigate} onToggle={() => setLeftCollapsed((value) => !value)} />
<SessionSidebar tabs={store.sessionTabs} loading={store.state.loading} modelLabel={modelLabel} onCreate={() => void store.createSession()} onDelete={() => void store.deleteCurrentSession()} onSelect={(tab) => void store.selectConversation(tab)} />
<div className="resize-handle session-sidebar-resize" id="session-sidebar-resize" role="separator" tabIndex={0} aria-label="拖拽调整 Session sidebar 宽度" aria-controls="session-sidebar" aria-orientation="vertical" aria-valuemin={148} aria-valuemax={320} aria-valuenow={220} title="拖拽调整 Session sidebar 宽度;用左右方向键微调,Home/End 到最小或最大。" />
<div
className="resize-handle session-sidebar-resize"
id="session-sidebar-resize"
role="separator"
tabIndex={narrow || leftCollapsed ? -1 : 0}
aria-disabled={narrow || leftCollapsed}
aria-hidden={narrow || leftCollapsed}
aria-label="拖拽调整 Session sidebar 宽度"
aria-controls="session-sidebar"
aria-orientation="vertical"
aria-valuemin={sessionResize.bounds.min}
aria-valuemax={sessionResize.bounds.max}
aria-valuenow={sessionResize.ariaValueNow}
aria-valuetext={sessionResize.ariaValueText}
title="拖拽调整 Session sidebar 宽度;用左右方向键微调,Home/End 到最小或最大。"
onPointerDown={sessionResize.onPointerDown}
onPointerMove={sessionResize.onPointerMove}
onPointerUp={sessionResize.onPointerUp}
onPointerCancel={sessionResize.onPointerCancel}
onKeyDown={sessionResize.onKeyDown}
/>
<section className="center-workspace">
{route === "workspace" ? <ConversationPanel messages={store.state.messages} availability={store.state.codeAgentAvailability} chatPending={store.state.chatPending} onCopySession={() => void navigator.clipboard?.writeText(store.activeConversationId ?? "")} /> : null}
{route === "skills" ? <SkillsView /> : null}
@@ -56,12 +114,45 @@ export function App(): ReactElement {
{route === "settings" ? <SettingsView onLogout={auth.logout} /> : null}
<CommandBar disabled={store.composer.disabled} providerProfile={store.state.providerProfile} codeAgentTimeoutMs={store.state.codeAgentTimeoutMs} gatewayShellTimeoutMs={store.state.gatewayShellTimeoutMs} onProviderProfile={store.setProviderProfile} onCodeAgentTimeout={store.setCodeAgentTimeoutMs} onGatewayShellTimeout={store.setGatewayShellTimeoutMs} onSubmit={store.submitMessage} onClear={store.clearConversation} />
</section>
<div className="resize-handle right-sidebar-resize" id="right-sidebar-resize" role="separator" tabIndex={0} aria-disabled="false" aria-hidden="false" aria-label="拖拽调整右侧 Device Pod 看板宽度" aria-controls="device-pod-sidebar" aria-orientation="vertical" aria-valuemin={560} aria-valuemax={740} aria-valuenow={728} aria-valuetext="右侧 Device Pod 看板宽度 728 像素" title="拖拽调整右侧 Device Pod 看板宽度;用左右方向键微调,Home/End 到最小或最大。" />
<div
className="resize-handle right-sidebar-resize"
id="right-sidebar-resize"
role="separator"
tabIndex={narrow || rightCollapsed ? -1 : 0}
aria-disabled={narrow || rightCollapsed}
aria-hidden={narrow || rightCollapsed}
aria-label="拖拽调整右侧 Device Pod 看板宽度"
aria-controls="device-pod-sidebar"
aria-orientation="vertical"
aria-valuemin={rightResize.bounds.min}
aria-valuemax={rightResize.bounds.max}
aria-valuenow={rightResize.ariaValueNow}
aria-valuetext={rightResize.ariaValueText}
title="拖拽调整右侧 Device Pod 看板宽度;用左右方向键微调,Home/End 到最小或最大。"
onPointerDown={rightResize.onPointerDown}
onPointerMove={rightResize.onPointerMove}
onPointerUp={rightResize.onPointerUp}
onPointerCancel={rightResize.onPointerCancel}
onKeyDown={rightResize.onKeyDown}
/>
<DevicePodSidebar live={store.state.live} selectedDevicePodId={store.state.selectedDevicePodId} onSelect={store.selectDevicePod} collapsed={rightCollapsed} onToggle={() => setRightCollapsed((value) => !value)} />
</main>
);
}
function useMatchMedia(query: string): boolean {
const [match, setMatch] = useState<boolean>(() => typeof window !== "undefined" && window.matchMedia(query).matches);
useEffect(() => {
if (typeof window === "undefined") return;
const media = window.matchMedia(query);
const listener = (event: MediaQueryListEvent): void => setMatch(event.matches);
setMatch(media.matches);
media.addEventListener("change", listener);
return () => media.removeEventListener("change", listener);
}, [query]);
return match;
}
function AuthLoadingShell(): ReactElement {
return (
<section className="login-shell auth-loading-shell" id="auth-loading-shell" aria-live="polite" aria-busy="true">
@@ -23,13 +23,14 @@ export function DevicePodSidebar({ live, selectedDevicePodId, onSelect, collapse
const summary = status?.summary ?? selected.summary ?? {};
return (
<aside className="right-sidebar" id="device-pod-sidebar" aria-label="Device Pod 摘要看板">
<aside className={`right-sidebar${collapsed ? " is-device-pod-collapsed" : ""}`} id="device-pod-sidebar" aria-label="Device Pod 摘要看板" aria-hidden={collapsed}>
<button className="right-sidebar-toggle right-sidebar-toggle--anchor" id="right-sidebar-toggle" type="button" aria-controls="device-pod-sidebar" aria-expanded={!collapsed} aria-label={collapsed ? "展开右侧 Device Pod 看板" : "折叠右侧 Device Pod 看板"} onClick={onToggle}>{collapsed ? "展开看板" : "收起看板"}</button>
<div className="right-sidebar-content">
<header className="right-sidebar-head">
<div>
<p className="eyebrow">Device Pod</p>
<h2></h2>
</div>
<button className="right-sidebar-toggle" id="right-sidebar-toggle" type="button" aria-controls="device-pod-sidebar" aria-expanded={!collapsed} aria-label={collapsed ? "展开右侧 Device Pod 看板" : "折叠右侧 Device Pod 看板"} onClick={onToggle}>{collapsed ? "展开看板" : "收起看板"}</button>
</header>
<label className="device-pod-picker">
<span>Device Pod</span>
@@ -64,6 +65,7 @@ export function DevicePodSidebar({ live, selectedDevicePodId, onSelect, collapse
<button value="close" type="button" onClick={() => setDialog(null)}></button>
</form>
</dialog>
</div>
</aside>
);
}
@@ -0,0 +1,186 @@
import { useCallback, useEffect, useMemo, useState } from "react";
export interface SidebarResizeBounds {
min: number;
max: number;
}
export interface SidebarResizeOptions {
storageKey: string;
defaultWidth: number;
bounds: SidebarResizeBounds;
side: "left" | "right";
disabled: boolean;
shellElement: HTMLElement | null;
cssVariable: string;
}
export interface SidebarResizeState {
width: number;
setWidth(next: number, options?: { persist?: boolean }): number;
onPointerDown(event: React.PointerEvent<HTMLDivElement>): void;
onPointerMove(event: React.PointerEvent<HTMLDivElement>): void;
onPointerUp(event: React.PointerEvent<HTMLDivElement>): void;
onPointerCancel(event: React.PointerEvent<HTMLDivElement>): void;
onKeyDown(event: React.KeyboardEvent<HTMLDivElement>): void;
bounds: SidebarResizeBounds;
isDragging: boolean;
ariaValueNow: number;
ariaValueText: string;
}
interface DragState {
pointerId: number;
startX: number;
startWidth: number;
}
const NARROW_BREAKPOINT_PX = 1240;
export function useSidebarResize(options: SidebarResizeOptions): SidebarResizeState {
const { storageKey, defaultWidth, bounds, side, disabled, shellElement, cssVariable } = options;
const [width, setWidthState] = useState<number>(() => readStoredWidth(storageKey, defaultWidth, bounds));
const [drag, setDrag] = useState<DragState | null>(null);
const [dynamicBounds, setDynamicBounds] = useState<SidebarResizeBounds>(bounds);
const applyWidth = useCallback((next: number) => {
if (shellElement) {
shellElement.style.setProperty(cssVariable, `${next}px`);
}
return next;
}, [shellElement, cssVariable]);
const setWidth = useCallback((next: number, options: { persist?: boolean } = {}) => {
const target = clampWidth(next, dynamicBounds, defaultWidth);
setWidthState(target);
applyWidth(target);
if (options.persist !== false) writeStoredWidth(storageKey, target);
return target;
}, [applyWidth, defaultWidth, dynamicBounds, storageKey]);
useEffect(() => {
if (disabled) return;
applyWidth(width);
}, [applyWidth, disabled, width]);
useEffect(() => {
if (disabled) return;
const shell: HTMLElement | null = shellElement;
if (!shell) return;
function recompute(): void {
const target: HTMLElement = shell as HTMLElement;
const next = computeBounds(bounds, side, target, defaultWidth, width);
setDynamicBounds(next);
const clamped = clampWidth(width, next, defaultWidth);
if (clamped !== width) {
setWidthState(clamped);
applyWidth(clamped);
}
}
recompute();
window.addEventListener("resize", recompute);
return () => window.removeEventListener("resize", recompute);
}, [applyWidth, bounds, defaultWidth, disabled, shellElement, side, width]);
const onPointerDown = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
if (disabled) return;
event.preventDefault();
const target = event.currentTarget;
target.setPointerCapture?.(event.pointerId);
setDrag({ pointerId: event.pointerId, startX: event.clientX, startWidth: width });
}, [disabled, width]);
const onPointerMove = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
if (!drag || drag.pointerId !== event.pointerId) return;
const delta = event.clientX - drag.startX;
const direction = side === "right" ? -1 : 1;
setWidth(drag.startWidth + delta * direction, { persist: false });
}, [drag, setWidth, side]);
const finishDrag = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
if (!drag || drag.pointerId !== event.pointerId) return;
setDrag(null);
writeStoredWidth(storageKey, width);
}, [drag, storageKey, width]);
const onPointerUp = useCallback((event: React.PointerEvent<HTMLDivElement>) => finishDrag(event), [finishDrag]);
const onPointerCancel = useCallback((event: React.PointerEvent<HTMLDivElement>) => finishDrag(event), [finishDrag]);
const onKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
if (disabled) return;
const step = event.shiftKey ? 32 : 8;
if (side === "right") {
if (event.key === "ArrowLeft") { event.preventDefault(); setWidth(width + step); }
else if (event.key === "ArrowRight") { event.preventDefault(); setWidth(width - step); }
} else {
if (event.key === "ArrowLeft") { event.preventDefault(); setWidth(width - step); }
else if (event.key === "ArrowRight") { event.preventDefault(); setWidth(width + step); }
}
if (event.key === "Home") { event.preventDefault(); setWidth(dynamicBounds.min); }
if (event.key === "End") { event.preventDefault(); setWidth(dynamicBounds.max); }
}, [disabled, dynamicBounds.max, dynamicBounds.min, setWidth, side, width]);
const ariaValueText = useMemo(() => side === "right" ? `右侧 Device Pod 看板宽度 ${width} 像素` : `Session sidebar 宽度 ${width} 像素`, [side, width]);
return {
width,
setWidth,
onPointerDown,
onPointerMove,
onPointerUp,
onPointerCancel,
onKeyDown,
bounds: dynamicBounds,
isDragging: drag !== null,
ariaValueNow: width,
ariaValueText
};
}
function readStoredWidth(key: string, fallback: number, bounds: SidebarResizeBounds): number {
if (typeof window === "undefined") return fallback;
try {
const raw = window.localStorage.getItem(key);
const parsed = Number(raw);
if (!Number.isFinite(parsed)) return fallback;
return clampWidth(parsed, bounds, fallback);
} catch {
return fallback;
}
}
function writeStoredWidth(key: string, value: number): void {
if (typeof window === "undefined") return;
try { window.localStorage.setItem(key, String(value)); } catch { /* ignore */ }
}
function clampWidth(value: number, bounds: SidebarResizeBounds, fallback: number): number {
const numeric = Number.isFinite(Number(value)) ? Number(value) : fallback;
return Math.min(Math.max(Math.round(numeric), bounds.min), bounds.max);
}
function computeBounds(base: SidebarResizeBounds, side: "left" | "right", shell: HTMLElement, fallback: number, currentWidth: number): SidebarResizeBounds {
if (typeof window === "undefined") return base;
if (window.matchMedia(`(max-width: ${NARROW_BREAKPOINT_PX}px)`).matches) {
return { min: 0, max: Math.max(0, window.innerWidth) };
}
const railWidth = readCssPixel(shell, "--rail-width", 92);
const collapsedRail = readCssPixel(shell, "--rail-collapsed-width", 44);
const collapsed = shell.classList.contains("is-left-sidebar-collapsed");
const effectiveRail = collapsed ? collapsedRail : railWidth;
const sessionWidth = readCssPixel(shell, "--session-sidebar-width", 220);
const minCenter = 420;
if (side === "right") {
const dynamicMax = Math.max(base.min, window.innerWidth - effectiveRail - sessionWidth - minCenter - 4);
return { min: base.min, max: Math.min(base.max, dynamicMax) };
}
const rightWidth = currentWidth > 0 ? readCssPixel(shell, "--right-sidebar-width", currentWidth) : readCssPixel(shell, "--right-sidebar-width", 728);
const dynamicMax = Math.max(base.min, window.innerWidth - effectiveRail - rightWidth - minCenter - 4);
return { min: base.min, max: Math.min(base.max, dynamicMax) };
}
function readCssPixel(shell: HTMLElement, name: string, fallback: number): number {
const raw = window.getComputedStyle(shell).getPropertyValue(name);
const parsed = Number.parseFloat(raw);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
@@ -1,5 +1,6 @@
import type {
AgentChatResponse,
AgentChatResultResponse,
ApiResult,
ConversationRecord,
DevicePodEventsResponse,
@@ -98,6 +99,7 @@ export const api = {
deleteConversation: (conversationId: string, payload: Record<string, unknown>): Promise<ApiResult<{ workspace?: WorkspaceRecord }>> => fetchJson(`/v1/agent/conversations/${encodeURIComponent(conversationId)}`, { method: "DELETE", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "delete conversation" }),
saveConversation: (conversationId: string, payload: Record<string, unknown>): Promise<ApiResult<{ ok?: boolean }>> => fetchJson(`/v1/agent/conversations/${encodeURIComponent(conversationId)}`, { method: "PUT", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "save conversation" }),
sendAgentMessage: (payload: Record<string, unknown>, timeoutMs: number): Promise<ApiResult<AgentChatResponse>> => fetchJson("/v1/agent/chat", { method: "POST", body: JSON.stringify(payload), timeoutMs, timeoutName: "Code Agent" }),
getAgentChatResult: (resultUrl: string, timeoutMs = 8000): Promise<ApiResult<AgentChatResultResponse>> => fetchJson(resultUrl, { timeoutMs, timeoutName: "Code Agent result" }),
steerAgentMessage: (payload: Record<string, unknown>, timeoutMs: number): Promise<ApiResult<AgentChatResponse>> => fetchJson("/v1/agent/chat/steer", { method: "POST", body: JSON.stringify(payload), timeoutMs, timeoutName: "Code Agent steer" }),
skills: (): Promise<ApiResult<SkillsResponse>> => fetchJson("/v1/skills", { timeoutMs: 12000, timeoutName: "skills" }),
uploadSkills: (files: Array<{ relativePath: string; sizeBytes: number; contentBase64: string }>): Promise<ApiResult<{ skill?: { id?: string } }>> => fetchJson("/v1/skills/uploads", { method: "POST", body: JSON.stringify({ files }), timeoutMs: 60000, timeoutName: "skill upload" }),
+23 -6
View File
@@ -1,5 +1,5 @@
import { api } from "../services/api/client";
import type { AgentChatResponse, ChatMessage, ConversationRecord, SessionTab, WorkspaceRecord } from "../types/domain";
import type { AgentChatReply, AgentChatResponse, AgentChatResultResponse, ChatMessage, ConversationRecord, SessionTab, WorkspaceRecord } from "../types/domain";
import { firstNonEmptyString, nextProtocolId, nonEmptyString, relativeUpdatedLabel, shortToken } from "../utils";
import { WORKBENCH_PROJECT_ID } from "./constants";
@@ -14,15 +14,26 @@ export function makeMessage(role: "user" | "agent", text: string, status: ChatMe
return { id: nextProtocolId("msg"), role, title: meta.title ?? (role === "user" ? "用户" : "Code Agent"), text, status, traceId: meta.traceId, conversationId: meta.conversationId, sessionId: meta.sessionId, threadId: meta.threadId, createdAt: new Date().toISOString() };
}
export function messageFromAgentResponse(messageId: string, pending: ChatMessage, response: AgentChatResponse): ChatMessage {
export function messageFromAgentResponse(messageId: string, pending: ChatMessage, response: AgentChatResponse | AgentChatResultResponse): ChatMessage {
const errorObject = typeof response.error === "object" && response.error ? response.error : null;
const failed = Boolean(response.error) || ["failed", "blocked", "timeout"].includes(String(response.status ?? ""));
const failed = Boolean(response.error) || ["failed", "blocked", "timeout", "cancelled"].includes(String(response.status ?? ""));
const replyText = replyContentText(response.reply);
const text = firstNonEmptyString(
response.assistantText,
replyText,
response.reply,
response.text,
response.summary,
errorObject?.message,
typeof response.error === "string" ? response.error : null
);
const normalizedStatus = failed ? "failed" : response.status === "running" ? "running" : "completed";
return {
...pending,
id: messageId,
title: failed ? "Code Agent 返回阻塞" : "Code Agent 回复",
text: firstNonEmptyString(response.reply, response.text, response.summary, errorObject?.message, response.error) ?? "后端没有返回可显示正文。",
status: failed ? "failed" : "completed",
title: failed ? "Code Agent 返回阻塞" : normalizedStatus === "running" ? "Code Agent 处理中" : "Code Agent 回复",
text: text ?? (failed ? "后端没有返回可显示正文。" : "Code Agent 仍在处理,可以继续 steer 或等待 trace 完成。"),
status: normalizedStatus,
updatedAt: new Date().toISOString(),
traceId: response.traceId ?? pending.traceId,
conversationId: response.conversationId ?? pending.conversationId,
@@ -34,6 +45,12 @@ export function messageFromAgentResponse(messageId: string, pending: ChatMessage
};
}
function replyContentText(reply: AgentChatReply | string | undefined): string | null {
if (!reply) return null;
if (typeof reply === "string") return reply;
return firstNonEmptyString(reply.content, reply.messageId);
}
export async function ensureWorkspace(current: WorkspaceRecord | null): Promise<WorkspaceRecord | null> {
if (current?.workspaceId) return current;
const response = await api.workspace(WORKBENCH_PROJECT_ID);
+49 -3
View File
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useReducer } from "react";
import { api } from "../services/api/client";
import type {
AgentChatResponse,
AgentChatResultResponse,
ApiResult,
ChatMessage,
CodeAgentAvailability,
@@ -227,12 +228,23 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore {
workspaceRevision: state.workspace?.revision,
targetTraceId: composer.targetTraceId
}, state.codeAgentTimeoutMs);
if (response.ok && response.data) {
const completed = messageFromAgentResponse(pending.id, pending, response.data);
dispatch({ type: "message:complete", messageId: pending.id, message: completed, availability: response.data.availability ?? null });
const finalize = async (result: AgentChatResultResponse, availability: CodeAgentAvailability | null): Promise<void> => {
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] });
const conversations = await api.conversations(WORKBENCH_PROJECT_ID);
if (conversations.ok) dispatch({ type: "conversation:list", conversations: conversations.data?.conversations ?? [] });
};
if (response.ok && response.data) {
const terminal = await waitForAgentResult(response.data, state.codeAgentTimeoutMs);
if (terminal) {
await finalize(terminal, response.data.availability ?? null);
} else if (isResultUrlStatus(response.data) && isTerminalStatus(response.data.status)) {
await finalize(response.data, response.data.availability ?? null);
} else {
const failed = makeMessage("agent", "Code Agent 在超时内未返回可显示正文;可以继续 steer 或查看 trace。", "failed", { traceId, conversationId, sessionId, threadId, title: "Code Agent 超时" });
dispatch({ type: "message:fail", messageId: pending.id, message: failed });
}
} 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 });
@@ -308,6 +320,40 @@ function conversationFromTab(tab: SessionTab): ConversationRecord {
return { conversationId: tab.conversationId ?? tab.key, sessionId: tab.sessionId, threadId: tab.threadId, status: tab.status, messages: [] };
}
async function waitForAgentResult(initial: AgentChatResponse, totalTimeoutMs: number): Promise<AgentChatResultResponse | null> {
if (!isResultUrlStatus(initial) || !initial.resultUrl) {
return isTerminalStatus(initial.status) ? initial : null;
}
if (isTerminalStatus(initial.status)) return initial;
const pollIntervalMs = 1500;
const startedAt = Date.now();
let attempt = 0;
while (Date.now() - startedAt < totalTimeoutMs) {
attempt += 1;
await new Promise<void>((resolve) => window.setTimeout(resolve, pollIntervalMs));
if (Date.now() - startedAt >= totalTimeoutMs) break;
const polled = await api.getAgentChatResult(initial.resultUrl, Math.min(8000, Math.max(4000, totalTimeoutMs - (Date.now() - startedAt))));
if (polled.ok && polled.data) {
if (isTerminalStatus(polled.data.status)) return polled.data;
if (attempt > 120) return null; // hard cap at ~3 minutes of polling
} else if (!polled.ok && polled.status >= 500) {
// server hiccup; keep polling until totalTimeoutMs
} else if (!polled.ok) {
return null;
}
}
return null;
}
function isResultUrlStatus(response: AgentChatResponse): boolean {
return Boolean(response.resultUrl) && (response.status === "running" || response.status === "accepted" || isTerminalStatus(response.status));
}
function isTerminalStatus(status: string | undefined): boolean {
if (!status) return false;
return ["completed", "failed", "blocked", "timeout", "cancelled"].includes(String(status));
}
function readStoredString(key: string): string | null {
try { return nonEmptyString(window.localStorage.getItem(key)); } catch { return null; }
}
+26 -9
View File
@@ -19,6 +19,8 @@
--right-sidebar-width: 728px;
--right-sidebar-min-width: 560px;
--right-sidebar-max-width: 740px;
--right-collapsed-width: 46px;
--resize-handle-width: 8px;
}
* { box-sizing: border-box; }
@@ -38,9 +40,9 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
.login-submit, .logout-button, .command-button { border: 0; border-radius: 6px; padding: 10px 14px; color: #fff; background: var(--accent); font-weight: 800; }
.login-error { color: var(--bad); margin: 0; }
.workbench-shell { height: 100%; width: 100%; display: grid; grid-template-columns: var(--rail-width) var(--session-sidebar-width) 4px minmax(420px, 1fr) 4px var(--right-sidebar-width); grid-template-rows: minmax(0, 1fr); overflow: hidden; background: var(--panel); }
.workbench-shell.is-left-sidebar-collapsed { grid-template-columns: var(--rail-collapsed-width) 0 0 minmax(420px, 1fr) 4px var(--right-sidebar-width); }
.workbench-shell.is-right-sidebar-collapsed { grid-template-columns: var(--rail-width) var(--session-sidebar-width) 4px minmax(420px, 1fr) 0 0; }
.workbench-shell { height: 100%; width: 100%; display: grid; grid-template-columns: var(--rail-width) 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; background: var(--panel); }
.workbench-shell.is-left-sidebar-collapsed { grid-template-columns: var(--rail-collapsed-width) 0 0 minmax(420px, 1fr) var(--resize-handle-width) var(--right-sidebar-width); }
.workbench-shell.is-right-sidebar-collapsed { grid-template-columns: var(--rail-width) var(--session-sidebar-width) var(--resize-handle-width) minmax(420px, 1fr) 0 var(--right-collapsed-width); }
.activity-rail { min-width: 0; display: grid; grid-template-rows: repeat(4, minmax(54px, auto)) 1fr minmax(54px, auto) 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; }
.rail-button.active { color: #1d2b23; background: var(--rail-active); border-color: var(--rail-active); }
@@ -58,7 +60,11 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
.session-tab-label, .session-tab-subtitle { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.session-tab-subtitle, .session-tab-count, .session-status, .device-pod-meta, .message-meta { color: var(--muted); font-size: 12px; }
.session-sidebar-foot { display: grid; gap: 8px; }
.resize-handle { width: 4px; background: #d2dad5; }
.resize-handle { position: relative; width: var(--resize-handle-width); background: transparent; cursor: col-resize; touch-action: none; }
.resize-handle::before { content: ""; position: absolute; top: 12px; bottom: 12px; left: 50%; transform: translateX(-50%); width: 2px; border-radius: 2px; background: #d2dad5; }
.resize-handle:hover::before, .resize-handle:focus-visible::before, .workbench-shell.is-resizing .resize-handle::before { background: var(--accent); }
.resize-handle[aria-disabled="true"] { cursor: not-allowed; }
.resize-handle[aria-disabled="true"]::before { background: #e3e7e3; }
.center-workspace { min-width: 0; min-height: 0; display: grid; grid-template-rows: minmax(0, 1fr) auto; overflow: hidden; }
.view { min-width: 0; min-height: 0; overflow: auto; padding: 14px; }
@@ -88,15 +94,26 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
.message-trace { margin-top: 10px; border-top: 1px solid var(--line); padding-top: 8px; }
.message-trace-events { max-height: 260px; overflow: auto; display: grid; gap: 8px; }
.command-bar { display: grid; grid-template-columns: auto auto auto minmax(160px, 1fr) auto auto; gap: 8px; align-items: end; padding: 10px 14px; border-top: 1px solid var(--line); background: #f7f8f4; }
.command-bar { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; grid-template-areas: "input send clear" "profile agent gateway"; gap: 8px; align-items: stretch; padding: 10px 14px; border-top: 1px solid var(--line); background: #f7f8f4; }
.command-bar .input-shell { grid-area: input; }
.command-bar #command-send { grid-area: send; }
.command-bar #command-clear { grid-area: clear; }
.command-bar .agent-timeout-control[for="code-agent-provider-profile"] { grid-area: profile; }
.command-bar .agent-timeout-control[for="code-agent-timeout"] { grid-area: agent; }
.command-bar .agent-timeout-control[for="gateway-shell-timeout"] { grid-area: gateway; }
.agent-timeout-control { display: grid; gap: 4px; min-width: 126px; font-size: 12px; font-weight: 700; }
.agent-timeout-control select, .device-pod-picker select { border: 1px solid var(--line); border-radius: 6px; padding: 8px; background: var(--surface); }
.input-shell { min-width: 0; display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 8px; border: 1px solid var(--line); border-radius: 8px; padding: 6px 8px; background: #fff; }
.prompt-mark { color: var(--accent); font-weight: 900; }
#command-input { min-width: 0; width: 100%; min-height: 40px; max-height: 120px; resize: none; border: 0; outline: none; }
.right-sidebar { min-width: 0; min-height: 0; display: grid; grid-template-rows: auto auto auto auto minmax(0, 1fr); gap: 10px; padding: 14px; background: #fbfcf8; border-left: 1px solid var(--line); overflow-y: hidden; }
.is-right-sidebar-collapsed .right-sidebar, .is-right-sidebar-collapsed .right-sidebar-resize { display: none; }
.right-sidebar { min-width: 0; min-height: 0; display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 0; padding: 0; background: #fbfcf8; border-left: 1px solid var(--line); overflow: hidden; }
.right-sidebar-content { min-width: 0; min-height: 0; display: grid; grid-template-rows: auto auto auto auto minmax(0, 1fr); gap: 10px; padding: 14px; overflow-y: hidden; }
.right-sidebar-toggle--anchor { margin: 8px; padding: 8px 6px; min-height: 32px; align-self: start; writing-mode: horizontal-tb; }
.is-right-sidebar-collapsed .right-sidebar-content { display: none; }
.is-right-sidebar-collapsed .right-sidebar-resize { display: none; }
.is-right-sidebar-collapsed .right-sidebar { grid-template-rows: auto; padding: 0; }
.is-right-sidebar-collapsed .right-sidebar-toggle--anchor { writing-mode: vertical-rl; min-height: 100%; margin: 6px 4px; padding: 10px 4px; letter-spacing: 0.06em; }
.device-pod-picker { display: grid; gap: 6px; font-weight: 800; }
.device-pod-status { display: grid; gap: 10px; }
.device-pod-summary, .device-pod-interfaces { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
@@ -153,9 +170,9 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
.right-sidebar { grid-column: 2; grid-row: 2; padding: 10px; }
.view { padding: 8px; }
.conversation-panel { padding: 10px; }
.command-bar { grid-template-columns: minmax(0, 1fr) auto; align-items: stretch; }
.command-bar { grid-template-columns: minmax(0, 1fr) auto auto; grid-template-areas: "input send clear" "profile profile profile" "agent agent agent" "gateway gateway gateway"; align-items: stretch; }
.agent-timeout-control { display: none; }
.input-shell { grid-column: 1 / span 2; }
.input-shell { grid-column: 1 / span 3; }
.command-button { min-height: 40px; }
.device-pod-summary, .device-pod-interfaces { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.device-pod-meta { display: none; }
+52
View File
@@ -182,6 +182,58 @@ export interface AgentChatResponse {
availability?: CodeAgentAvailability;
runnerTrace?: RunnerTrace;
traceEvents?: TraceEvent[];
assistantText?: string;
agentRun?: AgentRunProvenance;
resultUrl?: string;
traceUrl?: string;
accepted?: boolean;
shortConnection?: boolean;
fullBodyAvailable?: boolean;
error?: string | { code?: string; message?: string; providerStatus?: number; [key: string]: unknown };
[key: string]: unknown;
}
export interface AgentChatReply {
messageId?: string;
role?: "assistant" | "user" | "system";
content?: string;
createdAt?: string;
[key: string]: unknown;
}
export interface AgentRunProvenance {
adapter?: string;
runId?: string;
commandId?: string;
attemptId?: string;
runnerId?: string;
jobName?: string;
namespace?: string;
backendProfile?: string;
status?: string;
runStatus?: string;
commandState?: string;
terminalStatus?: string;
reuseEligible?: boolean;
valuesPrinted?: boolean;
[key: string]: unknown;
}
export interface AgentChatResultResponse {
status?: string;
traceId?: string;
conversationId?: string;
sessionId?: string | null;
threadId?: string | null;
accepted?: boolean;
shortConnection?: boolean;
assistantText?: string;
reply?: AgentChatReply | string;
agentRun?: AgentRunProvenance;
runnerTrace?: RunnerTrace;
traceEvents?: TraceEvent[];
availability?: CodeAgentAvailability;
fullBodyAvailable?: boolean;
error?: string | { code?: string; message?: string; providerStatus?: number; [key: string]: unknown };
[key: string]: unknown;
}