diff --git a/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx b/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx index 4a9b32c4b..ddafdcf53 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx @@ -106,6 +106,18 @@ function subagentConfigurationsMatch( type ContextDrawerSelection = Exclude; + +type RunActionKind = "correct" | "close" | "interrupt" | "newSession" | "retry"; +type RunActionState = { message?: string; status: "error" | "pending" }; +type RunActionStates = Partial>; +const RUN_ACTION_LABEL_KEYS = { + close: "drawer.runCloseSession", + correct: "drawer.correctionSend", + interrupt: "drawer.runInterrupt", + newSession: "drawer.runNewSession", + retry: "drawer.recoveryRetry", +} as const; + export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention, callbacks, goalNotifications = [], goals = [], inspectorExpanded = false, larkConnections = [], onClose, onToggleInspectorSize, readOnly = false, runs = [], selection }: { agents: WorkspaceAgentOption[]; attentionHistory?: WorkspaceAttention[]; @@ -126,6 +138,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention const [diagnosticsOpen, setDiagnosticsOpen] = useState(false); const [repositoryCopyState, setRepositoryCopyState] = useState<"idle" | "copied" | "error">("idle"); const [runDrawerTab, setRunDrawerTab] = useState<"record" | "details">("record"); + const [runActions, setRunActions] = useState>({}); const [subagentAllowedDomains, setSubagentAllowedDomains] = useState([]); const [subagentFeedback, setSubagentFeedback] = useState(null); const [subagentMaxChildren, setSubagentMaxChildren] = useState(2); @@ -300,10 +313,54 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention const attentionAge = selection.kind === "attention" ? localizedAttentionAge(selection.item.updatedAt, t) : null; const normalizedTodoResumeWhen = parseTodoResumeCondition(todoResumeWhen); + const selectedRunId = selection.kind === "run" ? selection.item.runId : null; + const selectedRunActions: RunActionStates = selectedRunId ? runActions[selectedRunId] ?? {} : {}; + const runActionPending = (kind: RunActionKind) => selectedRunActions[kind]?.status === "pending"; + const runActionFeedback = (Object.entries(selectedRunActions) as [RunActionKind, RunActionState][]) + .filter(([kind, state]) => state.status === "error" || kind !== "correct"); + + function setRunActionState(runId: string, kind: RunActionKind, state: RunActionState | null) { + setRunActions((current) => { + const { [kind]: _previous, ...rest } = current[runId] ?? {}; + const next: RunActionStates = state ? { ...rest, [kind]: state } : rest; + const { [runId]: _run, ...others } = current; + return Object.keys(next).length > 0 ? { ...others, [runId]: next } : others; + }); + } + + // Run actions reach the Chat service. A rejected request must stay visible in + // the drawer instead of escaping as an unhandled rejection. Each control only + // guards itself: a correction Turn in flight must never block interrupting it. + // State belongs to the Run that issued the request, so a late result can + // never report on, or release the guard of, another Run's action, and it is + // still there when the user returns to that Run. + async function performRunAction(run: WorkspaceRun, kind: RunActionKind, action: () => void | Promise) { + if (runActions[run.runId]?.[kind]?.status === "pending") return false; + setRunActionState(run.runId, kind, { status: "pending" }); + try { + await action(); + setRunActionState(run.runId, kind, null); + return true; + } catch (error) { + setRunActionState(run.runId, kind, { message: error instanceof Error ? error.message : String(error), status: "error" }); + return false; + } + } + + function runActionHandler(kind: RunActionKind, callback: ((run: WorkspaceRun) => void | Promise) | undefined) { + return () => { + if (selection.kind !== "run" || !callback) return; + const run = selection.item; + void performRunAction(run, kind, () => callback(run)); + }; + } + async function sendCorrection() { - if (selection.kind !== "run" || !correction.trim()) return; - await callbacks.onCorrectRun?.(selection.item, correction.trim()); - setCorrection(""); + if (selection.kind !== "run" || !correction.trim() || !callbacks.onCorrectRun) return; + const run = selection.item; + const message = correction.trim(); + const onCorrectRun = callbacks.onCorrectRun; + if (await performRunAction(run, "correct", () => onCorrectRun(run, message))) setCorrection(""); } async function previewTodoTransition(todo: WorkspaceTodo, operation: TodoOperation, label: string, resumeWhen?: string) { @@ -963,8 +1020,8 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention
{t("drawer.recoveryFailed")}

{t("drawer.recoveryDescription")}

- - + +
) : null} {!readOnly ?
@@ -978,18 +1035,26 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention rows={3} value={correction} /> - +
: null} {!readOnly ?
{t("drawer.moreRunActions")}
- - - - + + + +
: null} + {runActionFeedback.map(([kind, state]) => ( +

+ {state.status === "pending" ? : null} + {state.status === "pending" + ? t("drawer.runActionPending", { action: t(RUN_ACTION_LABEL_KEYS[kind]) }) + : t("drawer.runActionFailed", { action: t(RUN_ACTION_LABEL_KEYS[kind]), reason: state.message ?? "" })} +

+ ))} )} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index 5d077767c..9e0c6c7dc 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -303,6 +303,8 @@ const en = { "drawer.runLatest": "View latest execution", "drawer.runNewSession": "Start new Session", "drawer.runCloseSession": "Close Session", + "drawer.runActionFailed": "{action} failed: {reason}", + "drawer.runActionPending": "{action} in progress…", "drawer.runRecordEmpty": "No run record yet. The Agent has not started this execution. Check waiting conditions or resume the Session under Details & actions.", "drawer.runRecordProjected": "No step-by-step record is available. LoopX read {completed}/{total} projected steps{outputs}; inspect the Session under Details & actions.", "drawer.runRecordProjectedOutputs": " and {count} outputs", @@ -1473,6 +1475,8 @@ const zhCN: Record = { "drawer.runLatest": "查看最近执行过程", "drawer.runNewSession": "开始新 Session", "drawer.runCloseSession": "关闭 Session", + "drawer.runActionFailed": "{action}失败:{reason}", + "drawer.runActionPending": "{action}进行中…", "drawer.runRecordEmpty": "还没有运行记录。Agent 尚未开始这次执行;请在“详情与操作”查看等待条件或恢复 Session。", "drawer.runRecordProjected": "当前没有可展示的逐步运行记录。LoopX 已读取到 {completed}/{total} 的投影进度{outputs};请在“详情与操作”检查 Session 状态。", "drawer.runRecordProjectedOutputs": "和 {count} 项产出", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css index 45257e8b7..a3039422f 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css @@ -466,6 +466,8 @@ .personal-subagent-feedback.is-success { color: var(--pw-green) !important; } .personal-subagent-feedback.is-warning { color: #8a6b00 !important; } .personal-subagent-feedback.is-error { color: var(--pw-red) !important; } +.personal-run-action-feedback { display: flex; align-items: flex-start; gap: 7px; margin: 10px 0 0; color: var(--pw-muted); font-size: 12px; overflow-wrap: anywhere; } +.personal-run-action-feedback.is-error { color: var(--pw-red); } .personal-subagent-read-only { margin-bottom: 0; } .personal-settings-page { --pw-bg: #fbfaf7; --pw-card: #ffffff; --pw-line: #eceae3; --pw-line-strong: #e0ddd4; --pw-muted: #82889a; --pw-faint: #aab0bf; --pw-text: #23262e; --pw-blue: #2f66e9; --pw-blue-ink: #2456c8; --pw-blue-soft: #ebf1fe; --pw-red: #c2402f; --pw-red-bg: #fcecea; --pw-green: #2e7d5b; --pw-green-bg: #e6f3ec; display: grid; grid-template-columns: 268px minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); height: 100dvh; min-height: 0; overflow: hidden; background: var(--pw-bg); color: var(--pw-text); font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; } diff --git a/examples/personal-workspace-browser/chat-recovery.mjs b/examples/personal-workspace-browser/chat-recovery.mjs index 12cbc7ba0..7aef72947 100644 --- a/examples/personal-workspace-browser/chat-recovery.mjs +++ b/examples/personal-workspace-browser/chat-recovery.mjs @@ -219,6 +219,17 @@ export const chatRecoveryScenario = { await goalNavigation.getByRole("button", { name: /^(Chat|对话)$/ }).click(); await page.locator(".personal-run-row").first().click(); await page.getByRole("tab", { name: "详情与操作" }).click(); + // The fixture rejects Session deletion. The failed run action must stay + // visible in the drawer and keep the Session usable for the next action. + const unhandledRejections = []; + const recordRejection = (error) => unhandledRejections.push(error.message); + page.on("pageerror", recordRejection); + await page.locator(".personal-run-more > summary").click(); + await page.getByRole("button", { name: "关闭 Session", exact: true }).click(); + await page.getByRole("alert").filter({ hasText: "关闭 Session失败" }).waitFor({ state: "visible" }); + page.off("pageerror", recordRejection); + if (unhandledRejections.length) throw new Error(`Failed run action escaped as an unhandled rejection: ${unhandledRejections.join(" | ")}`); + pass("run-action-feedback", "A rejected Session action is reported in the drawer instead of failing silently"); await page.getByLabel("输入纠偏信息").fill("保持运行,用于验证刷新恢复。 "); await page.getByRole("button", { name: "发送纠偏" }).click(); let recoveryTurn; @@ -260,6 +271,78 @@ export const chatRecoveryScenario = { observations.push(`Refresh recovery failure: ${error.message}`); } + // Run action state belongs to the Run that issued it. Session A's late + // close result must neither report on Session B nor release B's pending + // guard, and A's own failure must still be there when the user returns. + const actionGoalId = new URL(page.url()).searchParams.get("goalId"); + const heldDeletes = new Map(); + await page.route("**/api/chat/sessions/session-run-action-*", async (route) => { + if (route.request().method() !== "DELETE") return route.fallback(); + heldDeletes.set(new URL(route.request().url()).pathname.split("/").at(-1), route); + }); + for (const suffix of ["a", "b"]) { + const sessionId = `session-run-action-${suffix}`; + page.__loopxRuntime.sessions.set(sessionId, { + session_id: sessionId, goal_id: actionGoalId, agent_id: "codex", adapter_kind: "codex", + channel_id: `task.run-action-${suffix}`, status: "ready", active_turn_id: null, last_error_code: null, + created_at: "2026-08-13T01:00:00Z", updated_at: "2026-08-13T01:00:00Z", last_activity_at: "2026-08-13T01:00:00Z", resumable: true, + }); + page.__loopxRuntime.messages.set(sessionId, []); + } + const actionRows = page.locator(".personal-run-row", { hasText: "Agent 执行任务" }); + await actionRows.nth(1).waitFor({ state: "visible", timeout: 15_000 }); + const closeButton = page.getByRole("button", { name: "关闭 Session", exact: true }); + const waitForHeld = async (count) => { + for (let attempt = 0; attempt < 100 && heldDeletes.size < count; attempt += 1) await page.waitForTimeout(50); + if (heldDeletes.size < count) throw new Error("A Session close request never reached the service"); + return [...heldDeletes.keys()].at(-1); + }; + const selectRun = async (row) => { + await row.click(); + await page.getByRole("tab", { name: "详情与操作" }).click(); + if (!(await page.locator(".personal-run-more").evaluate((menu) => menu.open))) await page.locator(".personal-run-more > summary").click(); + }; + const closeRun = async (row, heldCount) => { + await selectRun(row); + await closeButton.click(); + return waitForHeld(heldCount); + }; + const settle = (sessionId, response) => { + const route = heldDeletes.get(sessionId); + heldDeletes.delete(sessionId); + return route.fulfill(response); + }; + const failure = (reason) => ({ contentType: "application/json", json: { ok: false, error: reason }, status: 503 }); + const success = (sessionId) => ({ contentType: "application/json", json: { ok: true, closed: true, session_id: sessionId }, status: 200 }); + const alertWith = (text) => page.getByRole("alert").filter({ hasText: text }); + + const sessionA = await closeRun(actionRows.nth(0), 1); + if (!(await closeButton.isDisabled())) throw new Error("A pending Session close did not guard its own button"); + const sessionB = await closeRun(actionRows.nth(1), 2); + await settle(sessionA, failure("run-action-a-failed")); + await page.waitForTimeout(300); + if (await alertWith("run-action-a-failed").count()) throw new Error("Session A's late close failure was reported on Session B"); + if (!(await closeButton.isDisabled())) throw new Error("Session A's late close failure released Session B's pending guard"); + await settle(sessionB, failure("run-action-b-failed")); + await alertWith("run-action-b-failed").waitFor({ state: "visible" }); + if (await closeButton.isDisabled()) throw new Error("Session B's own close failure left its button disabled"); + await selectRun(actionRows.nth(0)); + await alertWith("run-action-a-failed").waitFor({ state: "visible" }); + if (await alertWith("run-action-b-failed").count()) throw new Error("Session B's close failure was reported on Session A"); + + // A late success from A must not release B's guard either. + await closeButton.click(); + await waitForHeld(1); + if (await alertWith("run-action-a-failed").count()) throw new Error("Retrying a Session close kept its previous failure visible"); + await closeRun(actionRows.nth(1), 2); + await settle(sessionA, success(sessionA)); + await page.waitForTimeout(300); + if (!(await closeButton.isDisabled())) throw new Error("Session A's late close success released Session B's pending guard"); + await settle(sessionB, success(sessionB)); + await page.waitForFunction(() => !document.querySelector(".personal-run-action-feedback")); + if (await closeButton.isDisabled()) throw new Error("Session B's own close success left its button disabled"); + pass("run-action-ownership", "Late Session close results report on, and release the guard of, only the Run that issued them"); + if (failures.length) throw new Error(failures.join(" | ")); } finally { await context.close();