authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-06 17:16:38-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-06 17:16:38-07:00
log7112697e8be2d6f231726a91eb565e00ddbeebec
treea5536a5c8ba180076b21abca9511fcc935cf4996
parentec36176e4f25fac7e3c380f6a4b646116e4ccf3d
signature Signed by PGP key B5690EEEBB952194

feat(threads): dismiss async questions without replying (#10431)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

18 files changed, 369 insertions(+), 6 deletions(-)

apps/mobile/src/features/threads/PendingUserInputCard.tsx+14
...@@ -66,6 +66,8 @@ export interface PendingUserInputCardProps {...@@ -66,6 +66,8 @@ export interface PendingUserInputCardProps {
66 customAnswer: string,66 customAnswer: string,
67 ) => void;67 ) => void;
68 readonly onSubmit: () => Promise<unknown>;68 readonly onSubmit: () => Promise<unknown>;
69 /** Closes an async question without a reply. Hidden for native callback questions. */
70 readonly onDismiss: () => Promise<unknown>;
69}71}
7072
71/**73/**
...@@ -338,6 +340,18 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {...@@ -338,6 +340,18 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
338 Submit answers340 Submit answers
339 </Text>341 </Text>
340 </Pressable>342 </Pressable>
343 {props.pendingUserInput.dismissible ? (
344 <Pressable
345 accessibilityRole="button"
346 className="items-center justify-center rounded-2xl px-4 py-2.5 active:opacity-70"
347 disabled={props.respondingUserInputId === props.pendingUserInput.requestId}
348 onPress={() => void props.onDismiss()}
349 >
350 <Text className="font-t3-bold text-sm text-foreground-muted">
351 Dismiss without answering
352 </Text>
353 </Pressable>
354 ) : null}
341 </Animated.View>355 </Animated.View>
342 ) : null;356 ) : null;
343 return (357 return (
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+2
...@@ -158,6 +158,7 @@ export interface ThreadDetailScreenProps {...@@ -158,6 +158,7 @@ export interface ThreadDetailScreenProps {
158 customAnswer: string,158 customAnswer: string,
159 ) => void;159 ) => void;
160 readonly onSubmitUserInput: () => Promise<unknown>;160 readonly onSubmitUserInput: () => Promise<unknown>;
161 readonly onDismissUserInput: () => Promise<unknown>;
161 readonly showContent?: boolean;162 readonly showContent?: boolean;
162}163}
163164
...@@ -909,6 +910,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -909,6 +910,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
909 onSelectOption={props.onSelectUserInputOption}910 onSelectOption={props.onSelectUserInputOption}
910 onChangeCustomAnswer={props.onChangeUserInputCustomAnswer}911 onChangeCustomAnswer={props.onChangeUserInputCustomAnswer}
911 onSubmit={props.onSubmitUserInput}912 onSubmit={props.onSubmitUserInput}
913 onDismiss={props.onDismissUserInput}
912 />914 />
913 ) : null}915 ) : null}
914 </Animated.View>916 </Animated.View>
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+1
...@@ -826,6 +826,7 @@ function ThreadRouteContent(...@@ -826,6 +826,7 @@ function ThreadRouteContent(
826 onSelectUserInputOption={requests.onSelectUserInputOption}826 onSelectUserInputOption={requests.onSelectUserInputOption}
827 onChangeUserInputCustomAnswer={requests.onChangeUserInputCustomAnswer}827 onChangeUserInputCustomAnswer={requests.onChangeUserInputCustomAnswer}
828 onSubmitUserInput={requests.onSubmitUserInput}828 onSubmitUserInput={requests.onSubmitUserInput}
829 onDismissUserInput={requests.onDismissUserInput}
829 />830 />
830 </View>831 </View>
831 </>832 </>
apps/mobile/src/lib/threadActivity.test.ts+1
...@@ -100,6 +100,7 @@ describe("pending user input answers", () => {...@@ -100,6 +100,7 @@ describe("pending user input answers", () => {
100 {100 {
101 requestId: "interaction_1",101 requestId: "interaction_1",
102 createdAt: requested.createdAt,102 createdAt: requested.createdAt,
103 dismissible: false,
103 questions: [nativeQuestion, singleSelectQuestion],104 questions: [nativeQuestion, singleSelectQuestion],
104 },105 },
105 ]);106 ]);
apps/mobile/src/state/use-selected-thread-requests.ts+25
...@@ -73,6 +73,10 @@ export function useSelectedThreadRequests() {...@@ -73,6 +73,10 @@ export function useSelectedThreadRequests() {
73 threadEnvironment.respondToUserInput,73 threadEnvironment.respondToUserInput,
74 "thread user input response",74 "thread user input response",
75 );75 );
76 const dismissUserInput = useAtomCommand(
77 threadEnvironment.dismissUserInput,
78 "thread user input dismissal",
79 );
76 const { selectedThread: selectedThreadShell } = useThreadSelection();80 const { selectedThread: selectedThreadShell } = useThreadSelection();
77 const selectedThread = useSelectedThreadDetail();81 const selectedThread = useSelectedThreadDetail();
78 const userInputDraftsByRequestKey = useAtomValue(userInputDraftsByRequestKeyAtom);82 const userInputDraftsByRequestKey = useAtomValue(userInputDraftsByRequestKeyAtom);
...@@ -170,6 +174,26 @@ export function useSelectedThreadRequests() {...@@ -170,6 +174,26 @@ export function useSelectedThreadRequests() {
170 selectedThreadShell,174 selectedThreadShell,
171 ]);175 ]);
172176
177 // Closes an async question without messaging the agent.
178 const onDismissUserInput = useCallback(async () => {
179 if (!selectedThreadShell || !activePendingUserInput) {
180 return;
181 }
182
183 setRespondingUserInputId(activePendingUserInput.requestId);
184 const result = await dismissUserInput({
185 environmentId: selectedThreadShell.environmentId,
186 input: {
187 threadId: selectedThreadShell.id,
188 requestId: activePendingUserInput.requestId,
189 },
190 });
191 setRespondingUserInputId((current) =>
192 current === activePendingUserInput.requestId ? null : current,
193 );
194 return result;
195 }, [activePendingUserInput, dismissUserInput, selectedThreadShell]);
196
173 return {197 return {
174 activePendingApproval,198 activePendingApproval,
175 activePendingUserInput,199 activePendingUserInput,
...@@ -181,5 +205,6 @@ export function useSelectedThreadRequests() {...@@ -181,5 +205,6 @@ export function useSelectedThreadRequests() {
181 onSelectUserInputOption,205 onSelectUserInputOption,
182 onChangeUserInputCustomAnswer,206 onChangeUserInputCustomAnswer,
183 onSubmitUserInput,207 onSubmitUserInput,
208 onDismissUserInput,
184 };209 };
185}210}
apps/server/src/orchestration/Layers/OrchestrationEngine.ts+2-1
...@@ -215,7 +215,8 @@ const makeOrchestrationEngine = Effect.gen(function* () {...@@ -215,7 +215,8 @@ const makeOrchestrationEngine = Effect.gen(function* () {
215 // Command snapshots omit activities at startup and cap them while running.215 // Command snapshots omit activities at startup and cap them while running.
216 // Read this request's durable state before deciding how to send the answer.216 // Read this request's durable state before deciding how to send the answer.
217 const userInputActivity =217 const userInputActivity =
218 envelope.command.type === "thread.user-input.respond"218 envelope.command.type === "thread.user-input.respond" ||
219 envelope.command.type === "thread.user-input.dismiss"
219 ? yield* projectionSnapshotQuery.getUserInputActivity(envelope.command)220 ? yield* projectionSnapshotQuery.getUserInputActivity(envelope.command)
220 : Option.none();221 : Option.none();
221 const eventBase = yield* decideOrchestrationCommand({222 const eventBase = yield* decideOrchestrationCommand({
apps/server/src/orchestration/decider.ts+45
...@@ -1289,6 +1289,51 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -1289,6 +1289,51 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
1289 };1289 };
1290 }1290 }
12911291
1292 case "thread.user-input.dismiss": {
1293 yield* requireThread({
1294 readModel,
1295 command,
1296 threadId: command.threadId,
1297 });
1298 const request = userInputActivity;
1299 if (request === undefined || request.kind !== "user-input.requested") {
1300 return yield* new OrchestrationCommandInvariantError({
1301 commandType: command.type,
1302 detail: "This question has already been answered.",
1303 });
1304 }
1305 // Only async questions can be dropped silently. A native callback
1306 // question leaves the provider blocked until it gets a reply, so it
1307 // still needs an answer or an interrupted turn.
1308 if (!Predicate.isObject(request.payload) || request.payload.responseMode !== "message") {
1309 return yield* new OrchestrationCommandInvariantError({
1310 commandType: command.type,
1311 detail: "This question needs an answer. Answer it or stop the turn.",
1312 });
1313 }
1314 return {
1315 ...(yield* withEventBase({
1316 aggregateKind: "thread",
1317 aggregateId: command.threadId,
1318 occurredAt: command.createdAt,
1319 commandId: command.commandId,
1320 })),
1321 type: "thread.activity-appended",
1322 payload: {
1323 threadId: command.threadId,
1324 activity: {
1325 id: EventId.make(`async-dismiss:${command.requestId}`),
1326 kind: "user-input.resolved",
1327 summary: "User input dismissed",
1328 tone: "info",
1329 turnId: request.turnId,
1330 createdAt: command.createdAt,
1331 payload: { requestId: command.requestId, responseMode: "message" },
1332 },
1333 },
1334 };
1335 }
1336
1292 case "thread.checkpoint.revert": {1337 case "thread.checkpoint.revert": {
1293 yield* requireThread({1338 yield* requireThread({
1294 readModel,1339 readModel,
apps/server/src/orchestration/decider.userInputDismiss.test.ts created+142
...@@ -0,0 +1,142 @@
1import {
2 CommandId,
3 EventId,
4 ProjectId,
5 ProviderInstanceId,
6 ThreadId,
7 ApprovalRequestId,
8 type OrchestrationReadModel,
9 type OrchestrationThreadActivity,
10} from "@t3tools/contracts";
11import * as NodeServices from "@effect/platform-node/NodeServices";
12import { expect, it } from "@effect/vitest";
13import * as Effect from "effect/Effect";
14
15import { decideOrchestrationCommand } from "./decider.ts";
16import { projectEvent } from "./projector.ts";
17
18const NOW = "2026-01-01T00:00:00.000Z";
19const threadId = ThreadId.make("thread-1");
20const requestId = ApprovalRequestId.make("question-1");
21
22function makeRequest(responseMode: "message" | undefined): OrchestrationThreadActivity {
23 return {
24 id: EventId.make(requestId),
25 kind: "user-input.requested",
26 summary: "Question",
27 tone: "approval",
28 turnId: null,
29 createdAt: NOW,
30 payload: {
31 requestId,
32 ...(responseMode === undefined ? {} : { responseMode }),
33 questions: [{ id: "0", header: "Q", question: "Continue?", options: [] }],
34 },
35 };
36}
37
38function makeReadModel(
39 activities: ReadonlyArray<OrchestrationThreadActivity>,
40): OrchestrationReadModel {
41 return {
42 snapshotSequence: 0,
43 projects: [],
44 threads: [
45 {
46 id: threadId,
47 projectId: ProjectId.make("project-1"),
48 title: "Thread",
49 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
50 runtimeMode: "full-access",
51 interactionMode: "default",
52 branch: null,
53 worktreePath: null,
54 latestTurn: null,
55 createdAt: NOW,
56 updatedAt: NOW,
57 archivedAt: null,
58 settledOverride: null,
59 settledAt: null,
60 snoozedUntil: null,
61 snoozedAt: null,
62 pinnedAt: null,
63 deletedAt: null,
64 messages: [],
65 proposedPlans: [],
66 activities: [...activities],
67 checkpoints: [],
68 session: null,
69 },
70 ],
71 updatedAt: NOW,
72 };
73}
74
75const command = {
76 type: "thread.user-input.dismiss" as const,
77 commandId: CommandId.make("dismiss-1"),
78 threadId,
79 requestId,
80 createdAt: NOW,
81};
82
83it.layer(NodeServices.layer)("user input dismiss decider", (it) => {
84 it.effect("closes an async question without sending a message or starting a turn", () =>
85 Effect.gen(function* () {
86 const request = makeRequest("message");
87 const readModel = makeReadModel([request]);
88 const result = yield* decideOrchestrationCommand({
89 command,
90 readModel,
91 userInputActivity: request,
92 });
93 const events = Array.isArray(result) ? result : [result];
94 expect(events.map((event) => event.type)).toEqual(["thread.activity-appended"]);
95 expect(events[0]?.payload).toMatchObject({
96 threadId,
97 activity: {
98 kind: "user-input.resolved",
99 summary: "User input dismissed",
100 payload: { requestId, responseMode: "message" },
101 },
102 });
103 const projected = yield* projectEvent(readModel, { ...events[0]!, sequence: 1 });
104 expect(projected.threads[0]?.messages).toEqual([]);
105 expect(projected.threads[0]?.latestTurn).toBeNull();
106 }),
107 );
108
109 it.effect("rejects dismissing a native callback question", () =>
110 Effect.gen(function* () {
111 const request = makeRequest(undefined);
112 const result = yield* decideOrchestrationCommand({
113 command,
114 readModel: makeReadModel([request]),
115 userInputActivity: request,
116 }).pipe(Effect.flip);
117 expect(result).toMatchObject({
118 _tag: "OrchestrationCommandInvariantError",
119 detail: "This question needs an answer. Answer it or stop the turn.",
120 });
121 }),
122 );
123
124 it.effect("rejects dismissing a question that was already resolved", () =>
125 Effect.gen(function* () {
126 const resolved: OrchestrationThreadActivity = {
127 ...makeRequest("message"),
128 id: EventId.make("resolved"),
129 kind: "user-input.resolved",
130 };
131 const result = yield* decideOrchestrationCommand({
132 command,
133 readModel: makeReadModel([makeRequest("message"), resolved]),
134 userInputActivity: resolved,
135 }).pipe(Effect.flip);
136 expect(result).toMatchObject({
137 _tag: "OrchestrationCommandInvariantError",
138 detail: "This question has already been answered.",
139 });
140 }),
141 );
142});
apps/web/src/components/ChatView.tsx+30
...@@ -1423,6 +1423,9 @@ export default function ChatView(props: ChatViewProps) {...@@ -1423,6 +1423,9 @@ export default function ChatView(props: ChatViewProps) {
1423 const respondToThreadUserInput = useAtomCommand(threadEnvironment.respondToUserInput, {1423 const respondToThreadUserInput = useAtomCommand(threadEnvironment.respondToUserInput, {
1424 reportFailure: false,1424 reportFailure: false,
1425 });1425 });
1426 const dismissThreadUserInput = useAtomCommand(threadEnvironment.dismissUserInput, {
1427 reportFailure: false,
1428 });
1426 const revertThreadCheckpoint = useAtomCommand(threadEnvironment.revertCheckpoint, {1429 const revertThreadCheckpoint = useAtomCommand(threadEnvironment.revertCheckpoint, {
1427 reportFailure: false,1430 reportFailure: false,
1428 });1431 });
...@@ -6960,6 +6963,32 @@ export default function ChatView(props: ChatViewProps) {...@@ -6960,6 +6963,32 @@ export default function ChatView(props: ChatViewProps) {
6960 [activeThreadId, environmentId, respondToThreadUserInput, setThreadError],6963 [activeThreadId, environmentId, respondToThreadUserInput, setThreadError],
6961 );6964 );
69626965
6966 // Closes an async question without messaging the agent. The server records
6967 // the dismissal so every client releases the composer.
6968 const onDismissUserInput = useCallback(
6969 async (requestId: ApprovalRequestId) => {
6970 if (!activeThreadId) return;
6971
6972 setRespondingUserInputRequestIds((existing) =>
6973 existing.includes(requestId) ? existing : [...existing, requestId],
6974 );
6975 const result = await dismissThreadUserInput({
6976 environmentId,
6977 input: { threadId: activeThreadId, requestId },
6978 });
6979 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
6980 const error = squashAtomCommandFailure(result);
6981 setThreadError(
6982 activeThreadId,
6983 error instanceof Error ? error.message : "Failed to dismiss the question.",
6984 );
6985 }
6986 setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId));
6987 return result;
6988 },
6989 [activeThreadId, dismissThreadUserInput, environmentId, setThreadError],
6990 );
6991
6963 const setActivePendingUserInputQuestionIndex = useCallback(6992 const setActivePendingUserInputQuestionIndex = useCallback(
6964 (nextQuestionIndex: number) => {6993 (nextQuestionIndex: number) => {
6965 if (!activePendingUserInput) {6994 if (!activePendingUserInput) {
...@@ -8055,6 +8084,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -8055,6 +8084,7 @@ export default function ChatView(props: ChatViewProps) {
8055 onSelectActivePendingUserInputOption8084 onSelectActivePendingUserInputOption
8056 }8085 }
8057 onAdvanceActivePendingUserInput={onAdvanceActivePendingUserInput}8086 onAdvanceActivePendingUserInput={onAdvanceActivePendingUserInput}
8087 onDismissActivePendingUserInput={onDismissUserInput}
8058 onPreviousActivePendingUserInputQuestion={8088 onPreviousActivePendingUserInputQuestion={
8059 onPreviousActivePendingUserInputQuestion8089 onPreviousActivePendingUserInputQuestion
8060 }8090 }
apps/web/src/components/chat/ChatComposer.tsx+4
...@@ -1284,6 +1284,7 @@ export interface ChatComposerProps {...@@ -1284,6 +1284,7 @@ export interface ChatComposerProps {
1284 ) => Promise<unknown>;1284 ) => Promise<unknown>;
1285 onSelectActivePendingUserInputOption: (questionId: string, optionValue: string) => void;1285 onSelectActivePendingUserInputOption: (questionId: string, optionValue: string) => void;
1286 onAdvanceActivePendingUserInput: () => void;1286 onAdvanceActivePendingUserInput: () => void;
1287 onDismissActivePendingUserInput: (requestId: ApprovalRequestId) => void;
1287 onPreviousActivePendingUserInputQuestion: () => void;1288 onPreviousActivePendingUserInputQuestion: () => void;
1288 onChangeActivePendingUserInputCustomAnswer: (1289 onChangeActivePendingUserInputCustomAnswer: (
1289 questionId: string,1290 questionId: string,
...@@ -1384,6 +1385,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1384,6 +1385,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1384 onRespondToApproval,1385 onRespondToApproval,
1385 onSelectActivePendingUserInputOption,1386 onSelectActivePendingUserInputOption,
1386 onAdvanceActivePendingUserInput,1387 onAdvanceActivePendingUserInput,
1388 onDismissActivePendingUserInput,
1387 onPreviousActivePendingUserInputQuestion,1389 onPreviousActivePendingUserInputQuestion,
1388 onChangeActivePendingUserInputCustomAnswer,1390 onChangeActivePendingUserInputCustomAnswer,
1389 onProviderModelSelect,1391 onProviderModelSelect,
...@@ -4911,6 +4913,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4911,6 +4913,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4911 questionIndex={activePendingQuestionIndex}4913 questionIndex={activePendingQuestionIndex}
4912 onToggleOption={onSelectActivePendingUserInputOption}4914 onToggleOption={onSelectActivePendingUserInputOption}
4913 onAdvance={onAdvanceActivePendingUserInput}4915 onAdvance={onAdvanceActivePendingUserInput}
4916 onDismiss={onDismissActivePendingUserInput}
4914 />4917 />
4915 ) : !isComposerCollapsedMobile && showPlanFollowUpPrompt && activeProposedPlan ? (4918 ) : !isComposerCollapsedMobile && showPlanFollowUpPrompt && activeProposedPlan ? (
4916 <ComposerPlanFollowUpBanner4919 <ComposerPlanFollowUpBanner
...@@ -4926,6 +4929,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4926,6 +4929,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4926 questionIndex={activePendingQuestionIndex}4929 questionIndex={activePendingQuestionIndex}
4927 onToggleOption={onSelectActivePendingUserInputOption}4930 onToggleOption={onSelectActivePendingUserInputOption}
4928 onAdvance={onAdvanceActivePendingUserInput}4931 onAdvance={onAdvanceActivePendingUserInput}
4932 onDismiss={onDismissActivePendingUserInput}
4929 />4933 />
4930 {!isChoiceOnlyPendingQuestion ||4934 {!isChoiceOnlyPendingQuestion ||
4931 activePendingProgress?.activeQuestion?.multiSelect ? (4935 activePendingProgress?.activeQuestion?.multiSelect ? (
apps/web/src/components/chat/ComposerPendingUserInputPanel.test.tsx+11-2
...@@ -20,17 +20,19 @@ const prompt: PendingUserInput = {...@@ -20,17 +20,19 @@ const prompt: PendingUserInput = {
20 multiSelect: false,20 multiSelect: false,
21 },21 },
22 ],22 ],
23 dismissible: true,
23};24};
2425
25function renderPanel() {26function renderPanel(pendingUserInput: PendingUserInput = prompt) {
26 return renderToStaticMarkup(27 return renderToStaticMarkup(
27 <ComposerPendingUserInputPanel28 <ComposerPendingUserInputPanel
28 pendingUserInputs={[prompt]}29 pendingUserInputs={[pendingUserInput]}
29 respondingRequestIds={[]}30 respondingRequestIds={[]}
30 answers={{}}31 answers={{}}
31 questionIndex={0}32 questionIndex={0}
32 onToggleOption={() => {}}33 onToggleOption={() => {}}
33 onAdvance={() => {}}34 onAdvance={() => {}}
35 onDismiss={() => {}}
34 />,36 />,
35 );37 );
36}38}
...@@ -50,6 +52,13 @@ describe("ComposerPendingUserInputPanel", () => {...@@ -50,6 +52,13 @@ describe("ComposerPendingUserInputPanel", () => {
50 expect(markup).toMatch(new RegExp(`<div[^>]*\\sid="${controlledId}"`));52 expect(markup).toMatch(new RegExp(`<div[^>]*\\sid="${controlledId}"`));
51 });53 });
5254
55 it("offers dismiss only for async questions", () => {
56 expect(renderPanel()).toContain("data-pending-user-input-dismiss");
57 expect(renderPanel({ ...prompt, dismissible: false })).not.toContain(
58 "data-pending-user-input-dismiss",
59 );
60 });
61
53 it("starts expanded so the question and its options are visible", () => {62 it("starts expanded so the question and its options are visible", () => {
54 const markup = renderPanel();63 const markup = renderPanel();
5564
apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx+27
...@@ -17,6 +17,7 @@ interface PendingUserInputPanelProps {...@@ -17,6 +17,7 @@ interface PendingUserInputPanelProps {
17 questionIndex: number;17 questionIndex: number;
18 onToggleOption: (questionId: string, optionValue: string) => void;18 onToggleOption: (questionId: string, optionValue: string) => void;
19 onAdvance: () => void;19 onAdvance: () => void;
20 onDismiss: (requestId: ApprovalRequestId) => void;
20}21}
2122
22export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPanel({23export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPanel({
...@@ -26,6 +27,7 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn...@@ -26,6 +27,7 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn
26 questionIndex,27 questionIndex,
27 onToggleOption,28 onToggleOption,
28 onAdvance,29 onAdvance,
30 onDismiss,
29}: PendingUserInputPanelProps) {31}: PendingUserInputPanelProps) {
30 if (pendingUserInputs.length === 0) return null;32 if (pendingUserInputs.length === 0) return null;
31 const activePrompt = pendingUserInputs[0];33 const activePrompt = pendingUserInputs[0];
...@@ -40,6 +42,7 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn...@@ -40,6 +42,7 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn
40 questionIndex={questionIndex}42 questionIndex={questionIndex}
41 onToggleOption={onToggleOption}43 onToggleOption={onToggleOption}
42 onAdvance={onAdvance}44 onAdvance={onAdvance}
45 onDismiss={onDismiss}
43 />46 />
44 );47 );
45});48});
...@@ -51,6 +54,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(...@@ -51,6 +54,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
51 questionIndex,54 questionIndex,
52 onToggleOption,55 onToggleOption,
53 onAdvance,56 onAdvance,
57 onDismiss,
54}: {58}: {
55 prompt: PendingUserInput;59 prompt: PendingUserInput;
56 isResponding: boolean;60 isResponding: boolean;
...@@ -58,6 +62,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(...@@ -58,6 +62,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
58 questionIndex: number;62 questionIndex: number;
59 onToggleOption: (questionId: string, optionValue: string) => void;63 onToggleOption: (questionId: string, optionValue: string) => void;
60 onAdvance: () => void;64 onAdvance: () => void;
65 onDismiss: (requestId: ApprovalRequestId) => void;
61}) {66}) {
62 const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex);67 const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex);
63 const activeQuestion = progress.activeQuestion;68 const activeQuestion = progress.activeQuestion;
...@@ -198,6 +203,28 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(...@@ -198,6 +203,28 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
198 </span>203 </span>
199 ) : null}204 ) : null}
200 <ComposerBanner.ToggleIcon expanded={!isCollapsed} />205 <ComposerBanner.ToggleIcon expanded={!isCollapsed} />
206 {prompt.dismissible ? (
207 // Sits inside the trigger button, so stop the click from toggling
208 // the disclosure. Dismiss closes the question without a reply.
209 <ComposerBanner.Dismiss
210 render={<span role="button" tabIndex={0} />}
211 aria-label="Dismiss question without answering"
212 title="Dismiss question without answering"
213 disabled={isResponding}
214 data-pending-user-input-dismiss
215 onClick={(event) => {
216 event.preventDefault();
217 event.stopPropagation();
218 onDismiss(prompt.requestId);
219 }}
220 onKeyDown={(event) => {
221 if (event.key !== "Enter" && event.key !== " ") return;
222 event.preventDefault();
223 event.stopPropagation();
224 onDismiss(prompt.requestId);
225 }}
226 />
227 ) : null}
201 </ComposerBanner.Actions>228 </ComposerBanner.Actions>
202 </CollapsibleTrigger>229 </CollapsibleTrigger>
203 <CollapsiblePanel>230 <CollapsiblePanel>
docs/user/providers-codex.md+3-1
...@@ -54,7 +54,9 @@ contains a copied Codex setup. Use a fresh shadow directory and sign in again....@@ -54,7 +54,9 @@ contains a copied Codex setup. Use a fresh shadow directory and sign in again.
54Codex can ask a question and keep working. Answer it in the thread's question54Codex can ask a question and keep working. Answer it in the thread's question
55panel. The answer becomes a new message: it reaches the active turn, or starts55panel. The answer becomes a new message: it reaches the active turn, or starts
56another turn if Codex has finished. Unanswered questions survive reconnects.56another turn if Codex has finished. Unanswered questions survive reconnects.
57This requires a Codex version that supports async questions.57If you do not want to answer, dismiss the question from its panel. Dismissing
58closes it without sending anything to Codex. This requires a Codex version that
59supports async questions.
5860
59## Approve app access61## Approve app access
6062
packages/client-runtime/src/operations/commands.ts+12
...@@ -50,6 +50,7 @@ export type StartThreadTurnInput = CommandInput<"thread.turn.start">;...@@ -50,6 +50,7 @@ export type StartThreadTurnInput = CommandInput<"thread.turn.start">;
50export type InterruptThreadTurnInput = CommandInput<"thread.turn.interrupt">;50export type InterruptThreadTurnInput = CommandInput<"thread.turn.interrupt">;
51export type RespondToThreadApprovalInput = CommandInput<"thread.approval.respond">;51export type RespondToThreadApprovalInput = CommandInput<"thread.approval.respond">;
52export type RespondToThreadUserInputInput = CommandInput<"thread.user-input.respond">;52export type RespondToThreadUserInputInput = CommandInput<"thread.user-input.respond">;
53export type DismissThreadUserInputInput = CommandInput<"thread.user-input.dismiss">;
53export type RevertThreadCheckpointInput = CommandInput<"thread.checkpoint.revert">;54export type RevertThreadCheckpointInput = CommandInput<"thread.checkpoint.revert">;
54export type StopThreadSessionInput = CommandInput<"thread.session.stop">;55export type StopThreadSessionInput = CommandInput<"thread.session.stop">;
5556
...@@ -320,6 +321,17 @@ export const respondToThreadUserInput: (input: RespondToThreadUserInputInput) =>...@@ -320,6 +321,17 @@ export const respondToThreadUserInput: (input: RespondToThreadUserInputInput) =>
320 });321 });
321 });322 });
322323
324export const dismissThreadUserInput: (input: DismissThreadUserInputInput) => CommandEffect =
325 Effect.fn("EnvironmentCommands.dismissThreadUserInput")(function* (input) {
326 const metadata = yield* timestampedCommandMetadata(input);
327 return yield* dispatch({
328 ...input,
329 type: "thread.user-input.dismiss",
330 commandId: metadata.commandId,
331 createdAt: metadata.createdAt,
332 });
333 });
334
323export const revertThreadCheckpoint: (input: RevertThreadCheckpointInput) => CommandEffect =335export const revertThreadCheckpoint: (input: RevertThreadCheckpointInput) => CommandEffect =
324 Effect.fn("EnvironmentCommands.revertThreadCheckpoint")(function* (input) {336 Effect.fn("EnvironmentCommands.revertThreadCheckpoint")(function* (input) {
325 const metadata = yield* timestampedCommandMetadata(input);337 const metadata = yield* timestampedCommandMetadata(input);
packages/client-runtime/src/pendingRequests.test.ts+20-1
...@@ -282,7 +282,25 @@ describe("pending questions", () => {...@@ -282,7 +282,25 @@ describe("pending questions", () => {
282 payload: { requestId: "async-1", responseMode: "message", questions: [question] },282 payload: { requestId: "async-1", responseMode: "message", questions: [question] },
283 }),283 }),
284 ];284 ];
285 expect(derivePendingRequests(activities).userInputs[0]?.questions).toEqual([question]);285 expect(derivePendingRequests(activities).userInputs[0]).toMatchObject({
286 questions: [question],
287 dismissible: true,
288 });
289 });
290
291 it("only marks async questions as dismissible", () => {
292 const question = {
293 id: "0",
294 header: "Question",
295 question: "Continue?",
296 options: [{ label: "Yes", description: "" }],
297 multiSelect: false,
298 };
299 const native = makeActivity({
300 kind: "user-input.requested",
301 payload: { requestId: "native-1", questions: [question] },
302 });
303 expect(derivePendingRequests([native]).userInputs[0]?.dismissible).toBe(false);
286 });304 });
287305
288 it("preserves native choice values and the custom-answer restriction", () => {306 it("preserves native choice values and the custom-answer restriction", () => {
...@@ -378,6 +396,7 @@ describe("pending questions", () => {...@@ -378,6 +396,7 @@ describe("pending questions", () => {
378 {396 {
379 requestId: "req-user-input-1",397 requestId: "req-user-input-1",
380 createdAt: "2026-02-23T00:00:01.000Z",398 createdAt: "2026-02-23T00:00:01.000Z",
399 dismissible: false,
381 questions: [400 questions: [
382 {401 {
383 id: "sandbox_mode",402 id: "sandbox_mode",
packages/client-runtime/src/pendingRequests.ts+8-1
...@@ -22,6 +22,8 @@ export interface PendingUserInput {...@@ -22,6 +22,8 @@ export interface PendingUserInput {
22 readonly requestId: ApprovalRequestId;22 readonly requestId: ApprovalRequestId;
23 readonly createdAt: string;23 readonly createdAt: string;
24 readonly questions: ReadonlyArray<UserInputQuestion>;24 readonly questions: ReadonlyArray<UserInputQuestion>;
25 /** Async questions can be dismissed without a reply; native callbacks cannot. */
26 readonly dismissible: boolean;
25}27}
2628
27const isRequestId = Schema.is(ApprovalRequestId);29const isRequestId = Schema.is(ApprovalRequestId);
...@@ -160,7 +162,12 @@ export function derivePendingRequests(activities: ReadonlyArray<OrchestrationThr...@@ -160,7 +162,12 @@ export function derivePendingRequests(activities: ReadonlyArray<OrchestrationThr
160 if (closedUserInputs.has(requestId)) continue;162 if (closedUserInputs.has(requestId)) continue;
161 const questions = parseQuestions(payload.questions);163 const questions = parseQuestions(payload.questions);
162 if (questions.length === 0) continue;164 if (questions.length === 0) continue;
163 userInputs.set(requestId, { requestId, createdAt: activity.createdAt, questions });165 userInputs.set(requestId, {
166 requestId,
167 createdAt: activity.createdAt,
168 questions,
169 dismissible: payload.responseMode === "message",
170 });
164 } else if (171 } else if (
165 activity.kind === "approval.resolved" ||172 activity.kind === "approval.resolved" ||
166 (activity.kind === "provider.approval.respond.failed" &&173 (activity.kind === "provider.approval.respond.failed" &&
packages/client-runtime/src/state/threadCommands.ts+9
...@@ -14,6 +14,7 @@ import {...@@ -14,6 +14,7 @@ import {
14 type InterruptThreadTurnInput,14 type InterruptThreadTurnInput,
15 type RespondToThreadApprovalInput,15 type RespondToThreadApprovalInput,
16 type RespondToThreadUserInputInput,16 type RespondToThreadUserInputInput,
17 type DismissThreadUserInputInput,
17 type RevertThreadCheckpointInput,18 type RevertThreadCheckpointInput,
18 type SetThreadInteractionModeInput,19 type SetThreadInteractionModeInput,
19 type SetThreadRuntimeModeInput,20 type SetThreadRuntimeModeInput,
...@@ -35,6 +36,7 @@ import {...@@ -35,6 +36,7 @@ import {
35 interruptThreadTurn,36 interruptThreadTurn,
36 respondToThreadApproval,37 respondToThreadApproval,
37 respondToThreadUserInput,38 respondToThreadUserInput,
39 dismissThreadUserInput,
38 revertThreadCheckpoint,40 revertThreadCheckpoint,
39 setThreadInteractionMode,41 setThreadInteractionMode,
40 setThreadRuntimeMode,42 setThreadRuntimeMode,
...@@ -60,6 +62,7 @@ export type {...@@ -60,6 +62,7 @@ export type {
60 InterruptThreadTurnInput,62 InterruptThreadTurnInput,
61 RespondToThreadApprovalInput,63 RespondToThreadApprovalInput,
62 RespondToThreadUserInputInput,64 RespondToThreadUserInputInput,
65 DismissThreadUserInputInput,
63 RevertThreadCheckpointInput,66 RevertThreadCheckpointInput,
64 SetThreadInteractionModeInput,67 SetThreadInteractionModeInput,
65 SetThreadRuntimeModeInput,68 SetThreadRuntimeModeInput,
...@@ -201,6 +204,12 @@ export function createThreadEnvironmentAtoms<R, E>(...@@ -201,6 +204,12 @@ export function createThreadEnvironmentAtoms<R, E>(
201 scheduler,204 scheduler,
202 concurrency,205 concurrency,
203 }),206 }),
207 dismissUserInput: createEnvironmentCommand(runtime, {
208 label: "environment-data:commands:thread:dismiss-user-input",
209 execute: (input: DismissThreadUserInputInput) => dismissThreadUserInput(input),
210 scheduler,
211 concurrency,
212 }),
204 revertCheckpoint: createEnvironmentCommand(runtime, {213 revertCheckpoint: createEnvironmentCommand(runtime, {
205 label: "environment-data:commands:thread:revert-checkpoint",214 label: "environment-data:commands:thread:revert-checkpoint",
206 execute: (input: RevertThreadCheckpointInput) => revertThreadCheckpoint(input),215 execute: (input: RevertThreadCheckpointInput) => revertThreadCheckpoint(input),
packages/contracts/src/orchestration.ts+13
...@@ -1033,6 +1033,17 @@ const ThreadUserInputRespondCommand = Schema.Struct({...@@ -1033,6 +1033,17 @@ const ThreadUserInputRespondCommand = Schema.Struct({
1033 createdAt: IsoDateTime,1033 createdAt: IsoDateTime,
1034});1034});
10351035
1036// Closes an async question without answering it. The agent is not messaged;
1037// the composer is simply released. Native callback questions cannot be dismissed
1038// this way because the provider is blocked waiting on a reply.
1039const ThreadUserInputDismissCommand = Schema.Struct({
1040 type: Schema.Literal("thread.user-input.dismiss"),
1041 commandId: CommandId,
1042 threadId: ThreadId,
1043 requestId: ApprovalRequestId,
1044 createdAt: IsoDateTime,
1045});
1046
1036const ThreadCheckpointRevertCommand = Schema.Struct({1047const ThreadCheckpointRevertCommand = Schema.Struct({
1037 type: Schema.Literal("thread.checkpoint.revert"),1048 type: Schema.Literal("thread.checkpoint.revert"),
1038 commandId: CommandId,1049 commandId: CommandId,
...@@ -1077,6 +1088,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([...@@ -1077,6 +1088,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([
1077 ThreadTurnInterruptCommand,1088 ThreadTurnInterruptCommand,
1078 ThreadApprovalRespondCommand,1089 ThreadApprovalRespondCommand,
1079 ThreadUserInputRespondCommand,1090 ThreadUserInputRespondCommand,
1091 ThreadUserInputDismissCommand,
1080 ThreadCheckpointRevertCommand,1092 ThreadCheckpointRevertCommand,
1081 ThreadSessionStopCommand,1093 ThreadSessionStopCommand,
1082]);1094]);
...@@ -1106,6 +1118,7 @@ export const ClientOrchestrationCommand = Schema.Union([...@@ -1106,6 +1118,7 @@ export const ClientOrchestrationCommand = Schema.Union([
1106 ThreadTurnInterruptCommand,1118 ThreadTurnInterruptCommand,
1107 ThreadApprovalRespondCommand,1119 ThreadApprovalRespondCommand,
1108 ThreadUserInputRespondCommand,1120 ThreadUserInputRespondCommand,
1121 ThreadUserInputDismissCommand,
1109 ThreadCheckpointRevertCommand,1122 ThreadCheckpointRevertCommand,
1110 ThreadSessionStopCommand,1123 ThreadSessionStopCommand,
1111]);1124]);