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:
@@ -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" }),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user