Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,18 @@ function subagentConfigurationsMatch(

type ContextDrawerSelection = Exclude<WorkspaceDrawerSelection, { kind: "settings" }>;


type RunActionKind = "correct" | "close" | "interrupt" | "newSession" | "retry";
type RunActionState = { message?: string; status: "error" | "pending" };
type RunActionStates = Partial<Record<RunActionKind, RunActionState>>;
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[];
Expand All @@ -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<Record<string, RunActionStates>>({});
const [subagentAllowedDomains, setSubagentAllowedDomains] = useState<string[]>([]);
const [subagentFeedback, setSubagentFeedback] = useState<string | null>(null);
const [subagentMaxChildren, setSubagentMaxChildren] = useState(2);
Expand Down Expand Up @@ -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<void>) {
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<void>) | 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) {
Expand Down Expand Up @@ -963,8 +1020,8 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention
<section className="personal-recovery-panel" aria-label={t("drawer.recoveryFailed")}>
<strong>{t("drawer.recoveryFailed")}</strong>
<p>{t("drawer.recoveryDescription")}</p>
<button className="personal-primary-action" onClick={() => void callbacks.onRetryResumeRun?.(selection.item)} type="button"><RotateCcw size={16} />{t("drawer.recoveryRetry")}</button>
<button className="personal-secondary-action" onClick={() => void callbacks.onStartNewRunSession?.(selection.item)} type="button"><Play size={16} />{t("drawer.recoveryNewSession")}</button>
<button className="personal-primary-action" disabled={runActionPending("retry")} onClick={runActionHandler("retry", callbacks.onRetryResumeRun)} type="button"><RotateCcw size={16} />{t("drawer.recoveryRetry")}</button>
<button className="personal-secondary-action" disabled={runActionPending("newSession")} onClick={runActionHandler("newSession", callbacks.onStartNewRunSession)} type="button"><Play size={16} />{t("drawer.recoveryNewSession")}</button>
</section>
) : null}
{!readOnly ? <section className="personal-correction-panel">
Expand All @@ -978,18 +1035,26 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention
rows={3}
value={correction}
/>
<button aria-label={t("drawer.correctionSend")} disabled={!correction.trim()} onClick={() => void sendCorrection()} type="button"><Send size={16} /></button>
<button aria-label={t("drawer.correctionSend")} disabled={!correction.trim() || runActionPending("correct")} onClick={() => void sendCorrection()} type="button"><Send size={16} /></button>
</div>
</section> : null}
{!readOnly ? <details className="personal-compact-menu personal-run-more">
<summary><MoreHorizontal size={17} />{t("drawer.moreRunActions")}</summary>
<div>
<button disabled={!selection.item.canInterrupt} onClick={() => void callbacks.onInterruptRun?.(selection.item)} type="button"><Pause size={16} />{t("drawer.runInterrupt")}</button>
<button disabled={selection.item.resumable === false} onClick={() => void callbacks.onRetryResumeRun?.(selection.item)} type="button"><RotateCcw size={16} />{t("drawer.recoveryRetry")}</button>
<button onClick={() => void callbacks.onStartNewRunSession?.(selection.item)} type="button"><Play size={16} />{t("drawer.runNewSession")}</button>
<button onClick={() => void callbacks.onCloseRunSession?.(selection.item)} type="button"><Square size={16} />{t("drawer.runCloseSession")}</button>
<button disabled={!selection.item.canInterrupt || runActionPending("interrupt")} onClick={runActionHandler("interrupt", callbacks.onInterruptRun)} type="button"><Pause size={16} />{t("drawer.runInterrupt")}</button>
<button disabled={selection.item.resumable === false || runActionPending("retry")} onClick={runActionHandler("retry", callbacks.onRetryResumeRun)} type="button"><RotateCcw size={16} />{t("drawer.recoveryRetry")}</button>
<button disabled={runActionPending("newSession")} onClick={runActionHandler("newSession", callbacks.onStartNewRunSession)} type="button"><Play size={16} />{t("drawer.runNewSession")}</button>
<button disabled={runActionPending("close")} onClick={runActionHandler("close", callbacks.onCloseRunSession)} type="button"><Square size={16} />{t("drawer.runCloseSession")}</button>
</div>
</details> : null}
{runActionFeedback.map(([kind, state]) => (
<p className={`personal-run-action-feedback is-${state.status}`} key={kind} role={state.status === "error" ? "alert" : "status"}>
{state.status === "pending" ? <RotateCcw className="personal-spin" size={13} /> : 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 ?? "" })}
</p>
))}
</>
)}
</>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -1473,6 +1475,8 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
"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} 项产出",
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

83 changes: 83 additions & 0 deletions examples/personal-workspace-browser/chat-recovery.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
Loading