fix(web): unlock v0.2 code agent composer steer (#805)
Co-authored-by: Codex Agent <codex@hwlab.local>
This commit is contained in:
@@ -1262,6 +1262,44 @@ test("hwlab-cli agent composer status detects Web unlocked steer mode", async ()
|
||||
assert.equal(result.payload.route.path, "/v1/agent/chat/steer");
|
||||
});
|
||||
|
||||
test("hwlab-cli agent composer status ignores stale workspace activeTrace after selected conversation completed", async () => {
|
||||
const result = await runHwlabCli([
|
||||
"client",
|
||||
"agent",
|
||||
"composer",
|
||||
"status",
|
||||
"--base-url",
|
||||
"http://web.test",
|
||||
"--cookie",
|
||||
"hwlab_session=session-a"
|
||||
], {
|
||||
fetchImpl: async () => new Response(JSON.stringify({
|
||||
ok: true,
|
||||
workspace: {
|
||||
workspaceId: "wsp_composer",
|
||||
revision: 10,
|
||||
selectedConversationId: "cnv_completed",
|
||||
selectedAgentSessionId: "ses_completed",
|
||||
activeTraceId: "trc_stale_running",
|
||||
workspace: {
|
||||
activeTraceId: "trc_stale_running",
|
||||
threadId: "thread-composer",
|
||||
sessionStatus: "running",
|
||||
messages: [{ role: "agent", status: "completed", traceId: "trc_completed", conversationId: "cnv_completed" }]
|
||||
}
|
||||
}
|
||||
}), { status: 200 })
|
||||
});
|
||||
|
||||
assert.equal(result.exitCode, 0);
|
||||
assert.equal(result.payload.composer.locked, false);
|
||||
assert.equal(result.payload.composer.disabled, false);
|
||||
assert.equal(result.payload.composer.submitMode, "turn");
|
||||
assert.equal(result.payload.composer.route, "/v1/agent/chat");
|
||||
assert.equal(result.payload.composer.targetTraceId, null);
|
||||
assert.equal(result.payload.composer.reason, "terminal-turn");
|
||||
});
|
||||
|
||||
test("hwlab-cli agent composer submit auto routes active Web turn to steer", async () => {
|
||||
const calls: any[] = [];
|
||||
const result = await runHwlabCli([
|
||||
|
||||
@@ -8,6 +8,8 @@ export function computeCodeAgentComposerState(input = {}) {
|
||||
const current = input.currentRequest && typeof input.currentRequest === "object" ? input.currentRequest : null;
|
||||
const workspace = input.workspace && typeof input.workspace === "object" ? input.workspace : null;
|
||||
const explicitStatus = normalizedStatus(input.status ?? input.sessionStatus ?? workspace?.sessionStatus);
|
||||
const latestStatus = normalizedStatus(latest?.status);
|
||||
const effectiveStatus = latestStatus || explicitStatus;
|
||||
const traceId = firstNonEmptyString(
|
||||
input.targetTraceId,
|
||||
current?.traceId,
|
||||
@@ -15,7 +17,7 @@ export function computeCodeAgentComposerState(input = {}) {
|
||||
workspace?.activeTraceId,
|
||||
latest?.status === "running" ? latest?.traceId : null
|
||||
);
|
||||
const active = Boolean(traceId) && (
|
||||
const active = Boolean(traceId) && !terminalStatus(effectiveStatus) && (
|
||||
activeStatus(current?.status) ||
|
||||
activeStatus(latest?.status) ||
|
||||
activeStatus(explicitStatus) ||
|
||||
@@ -51,7 +53,7 @@ export function computeCodeAgentComposerState(input = {}) {
|
||||
threadId: firstNonEmptyString(input.threadId, current?.threadId, latest?.threadId, workspace?.threadId),
|
||||
workspaceId: firstNonEmptyString(input.workspaceId, workspace?.workspaceId),
|
||||
workspaceRevision: integerOrNull(input.workspaceRevision ?? workspace?.revision),
|
||||
reason: active ? "active-turn-steer" : disabledReason ?? (terminalStatus(explicitStatus) ? "terminal-turn" : "idle-turn")
|
||||
reason: active ? "active-turn-steer" : disabledReason ?? (terminalStatus(effectiveStatus) ? "terminal-turn" : "idle-turn")
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ export function App(): ReactElement {
|
||||
{route === "help" ? <HelpView help={help} /> : null}
|
||||
{route === "settings" ? <SettingsView onLogout={auth.logout} /> : null}
|
||||
<DraftsList onPick={pickDraft} />
|
||||
<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={submitWithDraftRecord} onClear={store.clearConversation} pickedDraft={pickedDraft} onPickedDraftConsumed={clearPickedDraft} disabledReason={store.composer.disabledReason} onTyping={noteActivity} />
|
||||
<CommandBar key={store.activeConversationId ?? "default"} disabled={store.composer.disabled} submitMode={store.composer.submitMode} targetTraceId={store.composer.targetTraceId} providerProfile={store.state.providerProfile} codeAgentTimeoutMs={store.state.codeAgentTimeoutMs} gatewayShellTimeoutMs={store.state.gatewayShellTimeoutMs} onProviderProfile={store.setProviderProfile} onCodeAgentTimeout={store.setCodeAgentTimeoutMs} onGatewayShellTimeout={store.setGatewayShellTimeoutMs} onSubmit={submitWithDraftRecord} onClear={store.clearConversation} pickedDraft={pickedDraft} onPickedDraftConsumed={clearPickedDraft} disabledReason={store.composer.disabledReason} onTyping={noteActivity} />
|
||||
</section>
|
||||
<div
|
||||
className="resize-handle right-sidebar-resize"
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
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 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", () => {
|
||||
const source = fs.readFileSync(sourcePath, "utf8");
|
||||
assert.match(source, /const submitted = props\.onSubmit\(next\);/u);
|
||||
assert.match(source, /window\.setTimeout\(\(\) => setSubmitting\(false\), 0\);/u);
|
||||
assert.doesNotMatch(source, /await props\.onSubmit\(next\)/u);
|
||||
assert.doesNotMatch(source, /\.finally\(\(\) => setSubmitting\(false\)\)/u);
|
||||
});
|
||||
@@ -5,6 +5,8 @@ import type { ProviderProfile } from "../../types/domain";
|
||||
|
||||
interface CommandBarProps {
|
||||
disabled: boolean;
|
||||
submitMode: "turn" | "steer";
|
||||
targetTraceId: string | null;
|
||||
providerProfile: ProviderProfile;
|
||||
codeAgentTimeoutMs: number;
|
||||
gatewayShellTimeoutMs: number;
|
||||
@@ -29,7 +31,7 @@ interface CommandBarProps {
|
||||
|
||||
export function CommandBar({ pickedDraft, onPickedDraftConsumed, disabledReason, onTyping, ...props }: CommandBarProps): ReactElement {
|
||||
const [value, setValue] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (pickedDraft) {
|
||||
@@ -41,14 +43,12 @@ export function CommandBar({ pickedDraft, onPickedDraftConsumed, disabledReason,
|
||||
async function submit(event: FormEvent<HTMLFormElement>): Promise<void> {
|
||||
event.preventDefault();
|
||||
const next = value.trim();
|
||||
if (!next || props.disabled || pending) return;
|
||||
setPending(true);
|
||||
try {
|
||||
await props.onSubmit(next);
|
||||
setValue("");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
if (!next || props.disabled || submitting) return;
|
||||
setValue("");
|
||||
setSubmitting(true);
|
||||
const submitted = props.onSubmit(next);
|
||||
window.setTimeout(() => setSubmitting(false), 0);
|
||||
void submitted.catch(() => setSubmitting(false));
|
||||
}
|
||||
|
||||
function keyDown(event: KeyboardEvent<HTMLTextAreaElement>): void {
|
||||
@@ -58,7 +58,7 @@ export function CommandBar({ pickedDraft, onPickedDraftConsumed, disabledReason,
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="command-bar" id="command-form" aria-label="Agent 输入" onSubmit={submit} title={disabledReason ?? undefined}>
|
||||
<form className="command-bar" id="command-form" aria-label="Agent 输入" onSubmit={submit} title={disabledReason ?? steerTitle(props.submitMode, props.targetTraceId)}>
|
||||
<label className="agent-timeout-control" htmlFor="code-agent-provider-profile">
|
||||
<span>模型通道</span>
|
||||
<select id="code-agent-provider-profile" aria-label="Code Agent 模型通道" value={props.providerProfile} onChange={(event) => props.onProviderProfile(event.currentTarget.value as ProviderProfile)}>
|
||||
@@ -93,8 +93,18 @@ export function CommandBar({ pickedDraft, onPickedDraftConsumed, disabledReason,
|
||||
<span className="prompt-mark">hwlab</span>
|
||||
<textarea id="command-input" value={value} placeholder="输入任务,Enter 发送,Shift+Enter 换行" rows={1} onChange={(event) => { setValue(event.currentTarget.value); onTyping(); }} onKeyDown={keyDown} />
|
||||
</div>
|
||||
<button className="command-button" id="command-send" type="submit" disabled={props.disabled || pending}>{pending ? "发送中" : "发送"}</button>
|
||||
<button className="command-button" id="command-send" type="submit" disabled={props.disabled || submitting}>{sendLabel(props.submitMode, submitting)}</button>
|
||||
<button className="command-button secondary" id="command-clear" type="button" onClick={() => { setValue(""); props.onClear(); }}>清空</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function sendLabel(submitMode: "turn" | "steer", submitting: boolean): string {
|
||||
if (submitting) return submitMode === "steer" ? "引导中" : "发送中";
|
||||
return submitMode === "steer" ? "引导" : "发送";
|
||||
}
|
||||
|
||||
function steerTitle(submitMode: "turn" | "steer", targetTraceId: string | null): string | undefined {
|
||||
if (submitMode !== "steer" || !targetTraceId) return undefined;
|
||||
return `引导运行中的 turn: ${targetTraceId}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "bun:test";
|
||||
|
||||
import { composerFromState, workbenchReducer } from "./workbench-reducer";
|
||||
import type { ChatMessage } from "../types/domain";
|
||||
import type { WorkbenchState } from "./workbench-state";
|
||||
|
||||
test("composer stays unlocked and routes running turn input to steer", () => {
|
||||
const state = baseState({
|
||||
workspace: workspace("cnv_active", "trc_active"),
|
||||
messages: [agentMessage({ conversationId: "cnv_active", traceId: "trc_active", status: "running" })],
|
||||
chatPending: true
|
||||
});
|
||||
|
||||
const composer = composerFromState(state, "cnv_active");
|
||||
|
||||
assert.equal(composer.disabled, false);
|
||||
assert.equal(composer.submitMode, "steer");
|
||||
assert.equal(composer.route, "/v1/agent/chat/steer");
|
||||
assert.equal(composer.targetTraceId, "trc_active");
|
||||
assert.equal(composer.conversationId, "cnv_active");
|
||||
});
|
||||
|
||||
test("composer does not carry a running trace into another session", () => {
|
||||
const state = baseState({
|
||||
workspace: workspace("cnv_other", "trc_active"),
|
||||
currentRequest: { traceId: "trc_active", conversationId: "cnv_active", sessionId: "ses_active", threadId: "thread_active", status: "running" },
|
||||
messages: [agentMessage({ conversationId: "cnv_other", traceId: "trc_other_done", status: "completed" })],
|
||||
chatPending: true
|
||||
});
|
||||
|
||||
const composer = composerFromState(state, "cnv_other");
|
||||
|
||||
assert.equal(composer.disabled, false);
|
||||
assert.equal(composer.submitMode, "turn");
|
||||
assert.equal(composer.targetTraceId, null);
|
||||
});
|
||||
|
||||
test("conversation selection scopes chatPending to the selected session messages", () => {
|
||||
const state = baseState({
|
||||
workspace: workspace("cnv_active", "trc_active"),
|
||||
currentRequest: { traceId: "trc_active", conversationId: "cnv_active", sessionId: "ses_active", threadId: "thread_active", status: "running" },
|
||||
messages: [agentMessage({ conversationId: "cnv_active", traceId: "trc_active", status: "running" })],
|
||||
chatPending: true
|
||||
});
|
||||
|
||||
const selected = workbenchReducer(state, {
|
||||
type: "conversation:select",
|
||||
conversation: {
|
||||
conversationId: "cnv_done",
|
||||
sessionId: "ses_done",
|
||||
messages: [agentMessage({ conversationId: "cnv_done", traceId: "trc_done", status: "completed" })]
|
||||
},
|
||||
workspace: workspace("cnv_done", null)
|
||||
});
|
||||
|
||||
assert.equal(selected.chatPending, false);
|
||||
assert.equal(selected.currentRequest, null);
|
||||
assert.equal(composerFromState(selected, "cnv_done").submitMode, "turn");
|
||||
});
|
||||
|
||||
test("conversation selection restores steer mode when switching back to a running session", () => {
|
||||
const state = baseState({ workspace: workspace("cnv_done", null), messages: [], chatPending: false });
|
||||
|
||||
const selected = workbenchReducer(state, {
|
||||
type: "conversation:select",
|
||||
conversation: {
|
||||
conversationId: "cnv_active",
|
||||
sessionId: "ses_active",
|
||||
threadId: "thread_active",
|
||||
messages: [agentMessage({ conversationId: "cnv_active", traceId: "trc_active", status: "running" })]
|
||||
},
|
||||
workspace: workspace("cnv_active", "trc_active")
|
||||
});
|
||||
|
||||
const composer = composerFromState(selected, "cnv_active");
|
||||
assert.equal(selected.chatPending, true);
|
||||
assert.equal(composer.submitMode, "steer");
|
||||
assert.equal(composer.targetTraceId, "trc_active");
|
||||
});
|
||||
|
||||
test("composer routes to steer from workspace active status even before running message is restored", () => {
|
||||
const state = baseState({
|
||||
workspace: workspace("cnv_active", "trc_active"),
|
||||
messages: [],
|
||||
chatPending: false
|
||||
});
|
||||
|
||||
const composer = composerFromState(state, "cnv_active");
|
||||
|
||||
assert.equal(composer.disabled, false);
|
||||
assert.equal(composer.submitMode, "steer");
|
||||
assert.equal(composer.targetTraceId, "trc_active");
|
||||
});
|
||||
|
||||
test("composer disables only when no session or workspace exists", () => {
|
||||
const composer = composerFromState(baseState({ workspace: null, messages: [] }), null);
|
||||
assert.equal(composer.disabled, true);
|
||||
assert.equal(composer.disabledReason, "session_required");
|
||||
assert.equal(composer.submitMode, "turn");
|
||||
});
|
||||
|
||||
function baseState(patch: Partial<WorkbenchState>): WorkbenchState {
|
||||
return {
|
||||
workspace: workspace("cnv_active", null),
|
||||
conversations: [],
|
||||
messages: [],
|
||||
selectedDevicePodId: "device-pod-test",
|
||||
providerProfile: "deepseek",
|
||||
codeAgentTimeoutMs: 180_000,
|
||||
gatewayShellTimeoutMs: 120_000,
|
||||
live: null,
|
||||
codeAgentAvailability: null,
|
||||
loading: false,
|
||||
chatPending: false,
|
||||
currentRequest: null,
|
||||
error: null,
|
||||
...patch
|
||||
};
|
||||
}
|
||||
|
||||
function workspace(selectedConversationId: string, activeTraceId: string | null): WorkbenchState["workspace"] {
|
||||
return {
|
||||
workspaceId: "wsp_test",
|
||||
revision: 1,
|
||||
selectedConversationId,
|
||||
selectedAgentSessionId: "ses_test",
|
||||
activeTraceId,
|
||||
workspace: {
|
||||
selectedConversationId,
|
||||
selectedAgentSessionId: "ses_test",
|
||||
threadId: "thread_test",
|
||||
activeTraceId,
|
||||
sessionStatus: activeTraceId ? "running" : "completed"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function agentMessage(input: { conversationId: string; traceId: string; status: ChatMessage["status"] }): ChatMessage {
|
||||
return {
|
||||
id: `msg_${input.traceId}`,
|
||||
role: "agent",
|
||||
title: "Code Agent",
|
||||
text: "message",
|
||||
status: input.status,
|
||||
createdAt: "2026-06-04T00:00:00.000Z",
|
||||
traceId: input.traceId,
|
||||
conversationId: input.conversationId,
|
||||
sessionId: input.conversationId.replace("cnv", "ses"),
|
||||
threadId: input.conversationId.replace("cnv", "thread")
|
||||
};
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export type Action =
|
||||
| { type: "profile:set"; profile: WorkbenchState["providerProfile"] }
|
||||
| { type: "timeout:set"; codeAgentTimeoutMs: number }
|
||||
| { type: "gateway-timeout:set"; gatewayShellTimeoutMs: number }
|
||||
| { type: "message:pending"; user: ChatMessage; pending: ChatMessage; request: WorkbenchState["currentRequest"] }
|
||||
| { type: "message:pending"; user: ChatMessage; pending: ChatMessage; request: NonNullable<WorkbenchState["currentRequest"]> }
|
||||
| { type: "message:replace"; messageId: string; message: ChatMessage }
|
||||
| { type: "message:trace-status"; messageId: string; status: string }
|
||||
| { type: "message:trace"; messageId: string; trace: NonNullable<ChatMessage["runnerTrace"]> }
|
||||
@@ -33,7 +33,7 @@ export function workbenchReducer(state: WorkbenchState, action: Action): Workben
|
||||
case "profile:set": return { ...state, providerProfile: action.profile };
|
||||
case "timeout:set": return { ...state, codeAgentTimeoutMs: action.codeAgentTimeoutMs };
|
||||
case "gateway-timeout:set": return { ...state, gatewayShellTimeoutMs: action.gatewayShellTimeoutMs };
|
||||
case "message:pending": return { ...state, messages: [...state.messages, action.user, action.pending], chatPending: true, currentRequest: action.request };
|
||||
case "message:pending": return { ...state, messages: [...state.messages, action.user, action.pending], chatPending: true, currentRequest: { ...action.request, status: "running" } };
|
||||
case "message:replace": return { ...state, messages: replaceMessage(state.messages, action.messageId, action.message) };
|
||||
case "message:trace-status": {
|
||||
const updated = state.messages.map((message) => message.id === action.messageId ? { ...message, traceReplayStatus: action.status, updatedAt: new Date().toISOString() } : message);
|
||||
@@ -45,8 +45,11 @@ export function workbenchReducer(state: WorkbenchState, action: Action): Workben
|
||||
}
|
||||
case "message:complete": return { ...state, messages: replaceMessage(state.messages, action.messageId, action.message), codeAgentAvailability: action.availability ?? state.codeAgentAvailability, workspace: action.workspace ?? state.workspace };
|
||||
case "message:fail": return { ...state, messages: replaceMessage(state.messages, action.messageId, action.message) };
|
||||
case "chat:done": return { ...state, chatPending: false, currentRequest: null };
|
||||
case "conversation:select": return { ...state, workspace: action.workspace ?? state.workspace, messages: action.conversation?.messages ?? [], error: null };
|
||||
case "chat:done": return { ...state, chatPending: false, currentRequest: state.currentRequest ? { ...state.currentRequest, status: "completed" } : null };
|
||||
case "conversation:select": {
|
||||
const messages = action.conversation?.messages ?? [];
|
||||
return { ...state, workspace: action.workspace ?? state.workspace, messages, chatPending: hasRunningAgentMessage(messages), currentRequest: null, error: null };
|
||||
}
|
||||
case "conversation:list": return { ...state, conversations: action.conversations };
|
||||
case "conversation:clear": return { ...state, messages: [], currentRequest: null, chatPending: false };
|
||||
default: return state;
|
||||
@@ -58,16 +61,56 @@ function replaceMessage(messages: ChatMessage[], messageId: string, replacement:
|
||||
}
|
||||
|
||||
export function composerFromState(state: WorkbenchState, activeConversationId: string | null): ComposerState {
|
||||
const activeTraceId = firstNonEmptyString(state.currentRequest?.traceId, state.workspace?.activeTraceId, state.workspace?.workspace?.activeTraceId);
|
||||
const sessionStatus = firstNonEmptyString(state.messages.at(-1)?.status, state.workspace?.workspace?.sessionStatus);
|
||||
const canSteer = Boolean(activeTraceId && state.chatPending && sessionStatus !== "completed" && sessionStatus !== "failed");
|
||||
const latestMessage = latestConversationMessage(state.messages, activeConversationId);
|
||||
const runningMessage = findRunningAgentMessage(state.messages, activeConversationId);
|
||||
const currentRequestInConversation = state.currentRequest && state.currentRequest.conversationId === activeConversationId ? state.currentRequest : null;
|
||||
const activeTraceId = firstNonEmptyString(currentRequestInConversation?.traceId, runningMessage?.traceId, runningMessage?.runnerTrace?.traceId, state.workspace?.activeTraceId, state.workspace?.workspace?.activeTraceId);
|
||||
const workspaceStatus = firstNonEmptyString(state.workspace?.workspace?.sessionStatus);
|
||||
const sessionStatus = firstNonEmptyString(runningMessage?.status, currentRequestInConversation?.status, latestMessage?.status, workspaceStatus);
|
||||
const latestTerminal = isTerminalStatus(latestMessage?.status);
|
||||
const terminal = latestTerminal || isTerminalStatus(currentRequestInConversation?.status) || (!latestMessage && isTerminalStatus(workspaceStatus));
|
||||
const activeByStatus = isActiveStatus(runningMessage?.status) || isActiveStatus(runningMessage?.runnerTrace?.status) || isActiveStatus(currentRequestInConversation?.status) || isActiveStatus(workspaceStatus) || state.chatPending;
|
||||
const canSteer = Boolean(activeConversationId && activeTraceId && !terminal && activeByStatus);
|
||||
const conversationId = activeConversationId;
|
||||
const sessionId = firstNonEmptyString(runningMessage?.sessionId, currentRequestInConversation?.sessionId, state.workspace?.selectedAgentSessionId, state.workspace?.workspace?.selectedAgentSessionId);
|
||||
const threadId = firstNonEmptyString(runningMessage?.threadId, currentRequestInConversation?.threadId, state.workspace?.workspace?.threadId);
|
||||
if (canSteer) {
|
||||
return { disabled: false, disabledReason: null, submitMode: "steer", route: "/v1/agent/chat/steer", targetTraceId: activeTraceId };
|
||||
return { disabled: false, disabledReason: null, submitMode: "steer", route: "/v1/agent/chat/steer", targetTraceId: activeTraceId, conversationId, sessionId, threadId };
|
||||
}
|
||||
if (!activeConversationId && !state.workspace?.workspaceId) {
|
||||
return { disabled: true, disabledReason: "session_required", submitMode: "turn", route: "/v1/agent/chat", targetTraceId: null };
|
||||
return { disabled: true, disabledReason: "session_required", submitMode: "turn", route: "/v1/agent/chat", targetTraceId: null, conversationId, sessionId, threadId };
|
||||
}
|
||||
return { disabled: false, disabledReason: null, submitMode: "turn", route: "/v1/agent/chat", targetTraceId: null };
|
||||
return { disabled: false, disabledReason: null, submitMode: "turn", route: "/v1/agent/chat", targetTraceId: null, conversationId, sessionId, threadId };
|
||||
}
|
||||
|
||||
function findRunningAgentMessage(messages: ChatMessage[], activeConversationId: string | null): ChatMessage | null {
|
||||
for (const message of [...messages].reverse()) {
|
||||
if (message.role !== "agent") continue;
|
||||
if (message.status !== "running" && message.runnerTrace?.status !== "running") continue;
|
||||
if (activeConversationId && message.conversationId && message.conversationId !== activeConversationId) continue;
|
||||
return message;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function latestConversationMessage(messages: ChatMessage[], activeConversationId: string | null): ChatMessage | null {
|
||||
for (const message of [...messages].reverse()) {
|
||||
if (activeConversationId && message.conversationId && message.conversationId !== activeConversationId) continue;
|
||||
return message;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasRunningAgentMessage(messages: ChatMessage[]): boolean {
|
||||
return Boolean(findRunningAgentMessage(messages, null));
|
||||
}
|
||||
|
||||
function isActiveStatus(value: unknown): boolean {
|
||||
return ["running", "pending", "accepted", "processing"].includes(String(value ?? "").trim().toLowerCase());
|
||||
}
|
||||
|
||||
function isTerminalStatus(value: unknown): boolean {
|
||||
return ["completed", "failed", "blocked", "timeout", "canceled", "cancelled"].includes(String(value ?? "").trim().toLowerCase());
|
||||
}
|
||||
|
||||
export function availabilityFromLive(live: LiveSurface): CodeAgentAvailability | null {
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface WorkbenchState {
|
||||
codeAgentAvailability: CodeAgentAvailability | null;
|
||||
loading: boolean;
|
||||
chatPending: boolean;
|
||||
currentRequest: { traceId: string; conversationId: string; sessionId: string | null; threadId: string | null } | null;
|
||||
currentRequest: { traceId: string; conversationId: string; sessionId: string | null; threadId: string | null; status?: "running" | "completed" } | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,9 @@ export interface ComposerState {
|
||||
submitMode: "turn" | "steer";
|
||||
route: "/v1/agent/chat" | "/v1/agent/chat/steer";
|
||||
targetTraceId: string | null;
|
||||
conversationId: string | null;
|
||||
sessionId: string | null;
|
||||
threadId: string | null;
|
||||
}
|
||||
|
||||
export interface WorkbenchRuntimeContext {
|
||||
|
||||
@@ -229,15 +229,17 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore {
|
||||
const submitMessage = useCallback(async (text: string) => {
|
||||
const value = text.trim();
|
||||
if (!value) return;
|
||||
const traceId = nextProtocolId("trc");
|
||||
const conversationId = activeConversationId ?? nextProtocolId("cnv");
|
||||
const sessionId = state.workspace?.selectedAgentSessionId ?? state.workspace?.workspace?.selectedAgentSessionId ?? null;
|
||||
const threadId = state.workspace?.workspace?.threadId ?? null;
|
||||
const steerMode = composer.submitMode === "steer";
|
||||
const traceId = steerMode && composer.targetTraceId ? composer.targetTraceId : nextProtocolId("trc");
|
||||
const steerTraceId = steerMode ? nextProtocolId("trc_steer") : null;
|
||||
const conversationId = composer.conversationId ?? activeConversationId ?? nextProtocolId("cnv");
|
||||
const sessionId = composer.sessionId ?? state.workspace?.selectedAgentSessionId ?? state.workspace?.workspace?.selectedAgentSessionId ?? null;
|
||||
const threadId = composer.threadId ?? state.workspace?.workspace?.threadId ?? null;
|
||||
const user = makeMessage("user", value, "sent", { traceId, conversationId, sessionId, threadId });
|
||||
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 } });
|
||||
const route = composer.submitMode === "steer" ? api.steerAgentMessage : api.sendAgentMessage;
|
||||
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();
|
||||
@@ -254,7 +256,8 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore {
|
||||
gatewayShellTimeoutMs: state.gatewayShellTimeoutMs,
|
||||
workspaceId: state.workspace?.workspaceId,
|
||||
workspaceRevision: state.workspace?.revision,
|
||||
targetTraceId: composer.targetTraceId
|
||||
targetTraceId: steerMode ? traceId : null,
|
||||
steerTraceId
|
||||
}, state.codeAgentTimeoutMs, submitActivityRef);
|
||||
const finalize = async (result: AgentChatResultResponse, availability: CodeAgentAvailability | null): Promise<void> => {
|
||||
const completed = messageFromAgentResponse(pending.id, pending, result);
|
||||
@@ -287,7 +290,7 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore {
|
||||
dispatch({ type: "message:fail", messageId: pending.id, message: failed });
|
||||
}
|
||||
dispatch({ type: "chat:done" });
|
||||
}, [activeConversationId, composer.submitMode, composer.targetTraceId, state.codeAgentTimeoutMs, state.gatewayShellTimeoutMs, state.messages, state.providerProfile, state.workspace]);
|
||||
}, [activeConversationId, composer.conversationId, composer.sessionId, composer.submitMode, composer.targetTraceId, composer.threadId, state.codeAgentTimeoutMs, state.gatewayShellTimeoutMs, state.messages, state.providerProfile, state.workspace]);
|
||||
|
||||
const cancelAgentMessage = useCallback(async (messageId: string) => {
|
||||
return cancelAgentMessageAction(messageId, {
|
||||
|
||||
Reference in New Issue
Block a user