diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts
index ef5e416cc..f701e4ebf 100644
--- a/apps/desktop/src/main/services/chat/agentChatService.test.ts
+++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts
@@ -11632,6 +11632,53 @@ describe("createAgentChatService", () => {
expect(persisted.reasoningEffort).toBe("xhigh");
});
+ it("applies fresh Codex thread effective sandbox when it differs from requested flags", async () => {
+ vi.mocked(mapPermissionToCodex).mockImplementation((mode) => {
+ if (mode === "default") return { approvalPolicy: "on-request", sandbox: "workspace-write" };
+ return { approvalPolicy: "on-request", sandbox: "read-only" };
+ });
+ mockState.codexResponseOverrides.set("thread/start", () => ({
+ thread: { id: "thread-effective-start-readonly" },
+ approvalPolicy: "onRequest",
+ sandbox: "read-only",
+ }));
+
+ const { service } = createService();
+ const session = await service.createSession({
+ laneId: "lane-1",
+ provider: "codex",
+ model: "gpt-5.4",
+ permissionMode: "default",
+ });
+
+ await service.sendMessage({
+ sessionId: session.id,
+ text: "Inspect the repo.",
+ });
+
+ await vi.waitFor(() => {
+ expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(true);
+ });
+
+ const threadStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "thread/start");
+ const threadStartParams = threadStartRequest?.params as { approvalPolicy?: unknown; sandbox?: unknown } | undefined;
+ expect(threadStartParams?.approvalPolicy).toBe("on-request");
+ expect(threadStartParams?.sandbox).toBe("workspace-write");
+
+ const turnStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "turn/start");
+ const turnStartParams = turnStartRequest?.params as {
+ approvalPolicy?: unknown;
+ sandboxPolicy?: { type?: unknown };
+ } | undefined;
+ expect(turnStartParams?.approvalPolicy).toBe("on-request");
+ expect(turnStartParams?.sandboxPolicy?.type).toBe("readOnly");
+
+ const summary = await service.getSessionSummary(session.id);
+ expect(summary?.codexApprovalPolicy).toBe("on-request");
+ expect(summary?.codexSandbox).toBe("read-only");
+ expect(summary?.permissionMode).toBe("plan");
+ });
+
it("re-resumes Codex threads when permission mode changes mid-session", async () => {
vi.mocked(mapPermissionToCodex).mockImplementation((mode) => {
if (mode === "full-auto") {
@@ -11675,6 +11722,11 @@ describe("createAgentChatService", () => {
});
mockState.codexRequestPayloads = [];
+ mockState.codexResponseOverrides.set("thread/resume", () => ({
+ thread: { id: "thread-after-mode-switch" },
+ approvalPolicy: "onRequest",
+ sandbox: "read-only",
+ }));
await service.updateSession({
sessionId: session.id,
@@ -12141,7 +12193,7 @@ describe("createAgentChatService", () => {
expect(sessionService.reopen).toHaveBeenCalledWith(session.id);
});
- it("keeps requested Codex reasoning effort while applying effective policy on resume", async () => {
+ it("keeps requested Codex policy and reasoning effort across resume", async () => {
mockState.codexResponseOverrides.set("thread/resume", () => ({
thread: { id: "thread-effective-resume" },
approvalPolicy: "onFailure",
@@ -12175,15 +12227,15 @@ describe("createAgentChatService", () => {
expect(resumeParams?.effort).toBe("xhigh");
expect(resumeParams?.reasoningEffort).toBeUndefined();
expect(resumeParams?.reasoning_effort).toBeUndefined();
- expect(resumed.codexApprovalPolicy).toBe("on-failure");
- expect(resumed.codexSandbox).toBe("workspace-write");
- expect(resumed.permissionMode).toBe("default");
+ expect(resumed.codexApprovalPolicy).toBe("never");
+ expect(resumed.codexSandbox).toBe("danger-full-access");
+ expect(resumed.permissionMode).toBe("full-auto");
expect(resumed.reasoningEffort).toBe("xhigh");
const persistedAfter = readPersistedChatState(session.id);
expect(persistedAfter.threadId).toBe("thread-effective-resume");
- expect(persistedAfter.codexApprovalPolicy).toBe("on-failure");
- expect(persistedAfter.codexSandbox).toBe("workspace-write");
+ expect(persistedAfter.codexApprovalPolicy).toBe("never");
+ expect(persistedAfter.codexSandbox).toBe("danger-full-access");
expect(persistedAfter.reasoningEffort).toBe("xhigh");
});
diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts
index 4b522b061..7aae46338 100644
--- a/apps/desktop/src/main/services/chat/agentChatService.ts
+++ b/apps/desktop/src/main/services/chat/agentChatService.ts
@@ -3465,10 +3465,26 @@ function normalizeCodexRuntimeSandbox(value: unknown): AgentChatCodexSandbox | u
return typeof type === "string" ? CODEX_SANDBOX_CAMEL_CASE_ALIASES[type] : undefined;
}
+function shouldPreserveRequestedCodexPolicy(
+ requested: { approvalPolicy: AgentChatCodexApprovalPolicy; sandbox: AgentChatCodexSandbox } | null,
+ runtime: { approvalPolicy?: AgentChatCodexApprovalPolicy; sandbox?: AgentChatCodexSandbox },
+): boolean {
+ if (!requested) return false;
+ // Resume responses can echo stale thread policy from before ADE re-sent the
+ // picker/config flags. Fresh starts still adopt the runtime-reported policy.
+ if (runtime.sandbox && runtime.sandbox !== requested.sandbox) return true;
+ if (!runtime.approvalPolicy || runtime.approvalPolicy === requested.approvalPolicy) return false;
+ return requested.approvalPolicy === "never" || requested.approvalPolicy === "untrusted";
+}
+
function applyCodexEffectiveThreadState(
managed: ManagedChatSession,
response: CodexThreadLifecycleResponse | null | undefined,
options: {
+ requestedCodexPolicy?: {
+ approvalPolicy: AgentChatCodexApprovalPolicy;
+ sandbox: AgentChatCodexSandbox;
+ } | null;
requestedReasoningEffort?: string | null;
onReasoningMismatch?: (mismatch: {
requestedReasoningEffort: string;
@@ -3478,16 +3494,25 @@ function applyCodexEffectiveThreadState(
): void {
if (!response) return;
+ const requestedCodexPolicy = options.requestedCodexPolicy ?? null;
const approvalPolicy = normalizePersistedCodexApprovalPolicy(response.approvalPolicy);
- if (approvalPolicy) {
- managed.session.codexApprovalPolicy = approvalPolicy;
- }
-
const sandbox = normalizeCodexRuntimeSandbox(response.sandbox);
- if (sandbox) {
- managed.session.codexSandbox = sandbox;
+ const runtime = {
+ ...(approvalPolicy ? { approvalPolicy } : {}),
+ ...(sandbox ? { sandbox } : {}),
+ };
+ const preserveRequestedPolicy = shouldPreserveRequestedCodexPolicy(requestedCodexPolicy, runtime);
+
+ if (preserveRequestedPolicy && requestedCodexPolicy) {
+ managed.session.codexApprovalPolicy = requestedCodexPolicy.approvalPolicy;
+ managed.session.codexSandbox = requestedCodexPolicy.sandbox;
+ } else {
+ if (approvalPolicy) managed.session.codexApprovalPolicy = approvalPolicy;
+ if (sandbox) managed.session.codexSandbox = sandbox;
}
+ managed.session.permissionMode = syncLegacyPermissionMode(managed.session) ?? managed.session.permissionMode;
+
const reasoningEffort = validateReasoningEffort(
"codex",
normalizeReasoningEffort(
@@ -19258,6 +19283,7 @@ export function createAgentChatService(args: {
persistExtendedHistory: true
});
applyCodexEffectiveThreadState(managed, resumeResponse, {
+ requestedCodexPolicy: codexPolicy,
requestedReasoningEffort: resumeReasoningEffort,
onReasoningMismatch: (mismatch) => logger.warn("agent_chat.codex_reasoning_runtime_mismatch", {
sessionId: managed.session.id,
@@ -20246,6 +20272,7 @@ export function createAgentChatService(args: {
persistExtendedHistory: true
});
applyCodexEffectiveThreadState(managed, resumeResponse, {
+ requestedCodexPolicy: codexPolicy,
requestedReasoningEffort: managed.session.reasoningEffort,
onReasoningMismatch: (mismatch) => logger.warn("agent_chat.codex_reasoning_runtime_mismatch", {
sessionId: managed.session.id,
diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx
index 985b3e51c..c127d5bac 100644
--- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx
+++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx
@@ -1515,6 +1515,84 @@ describe("AgentChatMessageList transcript rendering", () => {
// "renders structured question blocks" tests removed: tested specific
// CSS classes and rendering details that change with UI iterations.
+ it("renders completed Codex plan markdown without requiring expansion", () => {
+ renderMessageList([
+ {
+ sessionId: "session-1",
+ timestamp: "2026-03-17T10:00:00.000Z",
+ event: {
+ type: "plan",
+ itemId: "plan-1",
+ turnId: "turn-1",
+ state: "complete",
+ steps: [],
+ streamingText: [
+ "# Plan",
+ "",
+ "- Inspect the app-server wiring.",
+ "- Patch the native plan handoff.",
+ ].join("\n"),
+ },
+ },
+ ]);
+
+ expect(screen.getByText("Plan")).toBeTruthy();
+ expect(screen.getByText("Inspect the app-server wiring.")).toBeTruthy();
+ expect(screen.getByText("Patch the native plan handoff.")).toBeTruthy();
+ });
+
+ it("does not duplicate completed Codex plan markdown when structured steps exist", () => {
+ renderMessageList([
+ {
+ sessionId: "session-1",
+ timestamp: "2026-03-17T10:00:00.000Z",
+ event: {
+ type: "plan",
+ itemId: "plan-1",
+ turnId: "turn-1",
+ state: "complete",
+ steps: [{ text: "Inspect once", status: "pending" }],
+ streamingText: "- Inspect once",
+ },
+ },
+ ]);
+
+ expect(screen.getAllByText("Inspect once")).toHaveLength(1);
+ });
+
+ it("renders plan approval request bodies in the transcript", () => {
+ renderMessageList([
+ {
+ sessionId: "session-1",
+ timestamp: "2026-03-17T10:00:00.000Z",
+ event: {
+ type: "approval_request",
+ itemId: "approval-plan",
+ kind: "tool_call",
+ description: "Plan ready for approval",
+ turnId: "turn-1",
+ detail: {
+ request: {
+ requestId: "approval-plan",
+ itemId: "approval-plan",
+ source: "codex",
+ kind: "plan_approval",
+ title: "Plan Ready for Review",
+ description: "# Plan\n\n- Show the plan body.",
+ questions: [],
+ allowsFreeform: true,
+ blocking: true,
+ canProceedWithoutAnswer: false,
+ },
+ },
+ },
+ },
+ ]);
+
+ expect(screen.getByText("Presenting plan for approval")).toBeTruthy();
+ expect(screen.getByText("Show the plan body.")).toBeTruthy();
+ });
+
it("renders structured ask-user requests inline and submits option answers", () => {
const onApproval = vi.fn();
renderMessageList([
diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx
index 241f63f4b..01449f50d 100644
--- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx
+++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx
@@ -2817,6 +2817,11 @@ function renderEvent(
>
)}
+ {isPlanApproval && bodyText.trim().length > 0 ? (
+
+
+
+ ) : null}
);
}
diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx
index 63d64bfbb..116f84e20 100644
--- a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx
+++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx
@@ -708,8 +708,10 @@ function renderAutoCreateDraftPane(args?: {
) => void | Promise;
workDraftKind?: "chat" | "cli";
onLaunchCliSession?: React.ComponentProps["onLaunchCliSession"];
+ onLaneChange?: React.ComponentProps["onLaneChange"];
+ lanes?: any[];
}) {
- const lanes = [
+ const lanes = args?.lanes ?? [
{
id: "lane-primary",
name: "Primary",
@@ -745,7 +747,7 @@ function renderAutoCreateDraftPane(args?: {
embeddedWorkLayout
workDraftKind={args?.workDraftKind}
availableLanes={lanes}
- onLaneChange={vi.fn()}
+ onLaneChange={args?.onLaneChange ?? vi.fn()}
onSessionCreated={args?.onSessionCreated}
onLaunchCliSession={args?.onLaunchCliSession}
/>
@@ -1484,6 +1486,9 @@ describe("AgentChatPane submit recovery", () => {
renderPane(session);
expect(await screen.findByRole("button", { name: "Implement" })).toBeTruthy();
+ expect((await screen.findAllByText("Inspect")).length).toBeGreaterThan(0);
+ expect(screen.getAllByText("Patch").length).toBeGreaterThan(0);
+ expect(screen.getAllByText("Verify").length).toBeGreaterThan(0);
const textbox = screen.getByRole("textbox") as HTMLTextAreaElement;
expect(textbox.disabled).toBe(false);
@@ -2843,6 +2848,77 @@ describe("AgentChatPane submit recovery", () => {
});
});
+ it("routes Work sidebar draft insertions into the visible draft composer", async () => {
+ installAdeMocks({ sessions: [] });
+
+ render(
+
+
+ ,
+ );
+
+ const textbox = await screen.findByRole("textbox") as HTMLTextAreaElement;
+ window.dispatchEvent(new CustomEvent("ade:agent-chat:insert-draft", {
+ detail: {
+ draftTargetId: "work:draft:lane-other:chat",
+ text: "wrong draft",
+ },
+ }));
+ expect(textbox.value).toBe("");
+
+ window.dispatchEvent(new CustomEvent("ade:agent-chat:insert-draft", {
+ detail: {
+ draftTargetId: "work:draft:lane-1:chat",
+ text: "Use the selected browser context.",
+ },
+ }));
+
+ await waitFor(() => {
+ expect(textbox.value).toBe("Use the selected browser context.");
+ });
+ });
+
+ it("keeps Auto-create selected while reporting Primary as the Work tools lane", async () => {
+ installAdeMocks({ sessions: [] });
+ const onLaneChange = vi.fn();
+ renderAutoCreateDraftPane({ onLaneChange });
+
+ fireEvent.click(await screen.findByRole("button", { name: "Select lane" }));
+ fireEvent.click(await screen.findByRole("button", { name: /Auto-create lane/i }));
+
+ expect(onLaneChange).toHaveBeenCalledWith("lane-primary");
+ expect(await screen.findByText("Auto-create lane")).toBeTruthy();
+ expect(await screen.findByText("Tools use Primary until the lane is created.")).toBeTruthy();
+ });
+
+ it("falls back to the first available Work tools lane when Auto-create has no Primary lane", async () => {
+ installAdeMocks({ sessions: [] });
+ const onLaneChange = vi.fn();
+ renderAutoCreateDraftPane({
+ onLaneChange,
+ lanes: [
+ {
+ id: "lane-worktree",
+ name: "current-lane",
+ laneType: "worktree",
+ branchRef: "refs/heads/current-lane",
+ worktreePath: "/tmp/project-under-test/current-lane",
+ },
+ ],
+ });
+
+ fireEvent.click(await screen.findByRole("button", { name: "Select lane" }));
+ fireEvent.click(await screen.findByRole("button", { name: /Auto-create lane/i }));
+
+ expect(onLaneChange).toHaveBeenCalledWith("lane-worktree");
+ expect(await screen.findByText("Tools use current-lane until the lane is created.")).toBeTruthy();
+ });
+
it("background auto-create reports the new chat without stealing focus and shows a dismissible notice", async () => {
const onSessionCreated = vi.fn();
const { createLane, suggestLaneName } = installAdeMocks({ sessions: [] });
diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx
index ff33ac447..e6e62972e 100644
--- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx
+++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx
@@ -1839,6 +1839,7 @@ export function AgentChatPane({
isTileActive = true,
isTileVisible = isTileActive,
shouldAutofocusComposer = false,
+ draftContextTargetId = null,
initialLinearIssueContext = null,
initialLinearIssueContextSource = "lane_link",
initialModelId = null,
@@ -1873,6 +1874,8 @@ export function AgentChatPane({
/** Visible grid tiles hydrate transcripts even when they are not the focused tile. */
isTileVisible?: boolean;
shouldAutofocusComposer?: boolean;
+ /** Stable Work-sidebar target id for an unsaved draft composer. */
+ draftContextTargetId?: string | null;
initialLinearIssueContext?: LaneLinearIssue | null;
initialLinearIssueContextSource?: "manual" | "lane_link";
initialModelId?: string | null;
@@ -4621,37 +4624,52 @@ export function AgentChatPane({
const matchesThisChat = (sessionId: unknown): boolean => (
typeof sessionId === "string" && sessionId === selectedSessionIdRef.current
);
+ const matchesThisDraft = (targetId: unknown): boolean => (
+ selectedSessionIdRef.current == null
+ && forceDraft
+ && typeof targetId === "string"
+ && targetId === draftContextTargetId
+ );
+ const matchesThisComposer = (detail: { sessionId?: unknown; draftTargetId?: unknown } | undefined): boolean => (
+ matchesThisChat(detail?.sessionId) || matchesThisDraft(detail?.draftTargetId)
+ );
+
+ type ComposerEventDetail = { sessionId?: unknown; draftTargetId?: unknown; [key: string]: unknown };
+ const composerDetail = (event: Event): ComposerEventDetail | undefined => {
+ const detail = (event as CustomEvent).detail;
+ return matchesThisComposer(detail) ? detail : undefined;
+ };
const onAddAttachment = (event: Event) => {
- const detail = (event as CustomEvent<{ sessionId?: unknown; attachment?: unknown }>).detail;
- if (!matchesThisChat(detail?.sessionId)) return;
+ const detail = composerDetail(event);
+ if (!detail) return;
const attachment = detail.attachment as AgentChatFileRef | undefined;
if (!attachment?.path) return;
addAttachment(attachment);
};
const onInsertDraft = (event: Event) => {
- const detail = (event as CustomEvent<{ sessionId?: unknown; text?: unknown }>).detail;
- if (!matchesThisChat(detail?.sessionId) || typeof detail.text !== "string") return;
+ const detail = composerDetail(event);
+ if (!detail || typeof detail.text !== "string") return;
insertComposerDraft(detail.text);
};
const onAddIosContext = (event: Event) => {
- const detail = (event as CustomEvent<{ sessionId?: unknown; item?: unknown }>).detail;
- if (!matchesThisChat(detail?.sessionId) || !detail.item) return;
+ const detail = composerDetail(event);
+ if (!detail?.item) return;
addIosElementContext(detail.item as IosElementContextItem);
};
const onAddAppControlContext = (event: Event) => {
- const detail = (event as CustomEvent<{ sessionId?: unknown; item?: unknown }>).detail;
- if (!matchesThisChat(detail?.sessionId) || !detail.item) return;
+ const detail = composerDetail(event);
+ if (!detail?.item) return;
addAppControlContext(detail.item as AppControlContextItem);
};
const onAddBuiltInBrowserContext = (event: Event) => {
- const detail = (event as CustomEvent<{ sessionId?: unknown; item?: unknown }>).detail;
- if (!matchesThisChat(detail?.sessionId) || !detail.item) return;
+ const detail = composerDetail(event);
+ if (!detail?.item) return;
void addBuiltInBrowserContext(detail.item);
};
const onAddMacosVmContext = (event: Event) => {
- const detail = (event as CustomEvent<{ sessionId?: unknown; item?: unknown }>).detail;
- if (!matchesThisChat(detail?.sessionId) || !detail.item) return;
+ const detail = composerDetail(event);
+ if (!detail?.item) return;
addMacosVmContext(detail.item as MacosVmContextItem);
};
// Plan-panel annotation events (goal.md §10.7). The popover composes an
@@ -4685,7 +4703,16 @@ export function AgentChatPane({
window.removeEventListener("ade:agent-chat:add-macos-vm-context", onAddMacosVmContext);
window.removeEventListener("ade:agent-chat:add-plan-annotation", onAddPlanAnnotation);
};
- }, [addAppControlContext, addAttachment, addBuiltInBrowserContext, addIosElementContext, addMacosVmContext, insertComposerDraft]);
+ }, [
+ addAppControlContext,
+ addAttachment,
+ addBuiltInBrowserContext,
+ addIosElementContext,
+ addMacosVmContext,
+ draftContextTargetId,
+ forceDraft,
+ insertComposerDraft,
+ ]);
const removeAttachment = useCallback((attachmentPath: string) => {
linkedIosAttachmentPathsRef.current.delete(attachmentPath);
@@ -6527,15 +6554,22 @@ export function AgentChatPane({
: (availableLanes ?? []),
[availableLanes, showDraftLaunchControls],
);
+ const primaryDraftLane = useMemo(() => (
+ availableLanes?.find((candidate) => candidate.laneType === "primary")
+ ?? availableLanes?.find((candidate) => candidate.name.trim().toLowerCase() === "primary")
+ ?? null
+ ), [availableLanes]);
+ const autoCreateToolsLane = primaryDraftLane ?? availableLanes?.[0] ?? null;
const draftLaneSelectorValue = draftLaunchTargetIsAutoCreate ? AUTO_CREATE_LANE_OPTION_ID : (laneId ?? "");
const handleDraftLaneSelectionChange = useCallback((nextLaneId: string) => {
if (nextLaneId === AUTO_CREATE_LANE_OPTION_ID) {
setDraftLaunchTargetId(AUTO_CREATE_LANE_OPTION_ID);
+ if (autoCreateToolsLane) onLaneChange?.(autoCreateToolsLane.id);
return;
}
setDraftLaunchTargetId(null);
onLaneChange?.(nextLaneId);
- }, [onLaneChange]);
+ }, [autoCreateToolsLane, onLaneChange]);
useEffect(() => {
if (!showDraftLaunchControls && draftLaunchTargetId) {
@@ -8135,31 +8169,38 @@ export function AgentChatPane({
className="flex justify-center"
exit={{ opacity: 0, transition: { duration: 0.15 } }}
>
-
-
- {onOpenShellSession ? (
-
-