Skip to content

Commit 7bcdd43

Browse files
committed
feat: implement question tool UI with submit/dismiss actions
- Add question.asked/replied/rejected SSE event translation to item/tool/requestUserInput - Transform question answers to OpenCode's array-of-arrays format - Add Dismiss button and Enter/Esc keyboard shortcuts - Disable composer input while question UI is active - Wire up onUserInputCompleted for state cleanup on question reply/reject
1 parent 6ec4e4e commit 7bcdd43

File tree

17 files changed

+482
-127
lines changed

17 files changed

+482
-127
lines changed

src-tauri/src/backend/event_translator.rs

Lines changed: 268 additions & 84 deletions
Large diffs are not rendered by default.

src-tauri/src/shared/codex_core.rs

Lines changed: 43 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -835,12 +835,12 @@ pub(crate) async fn send_user_message_core<E: EventSink>(
835835
let parts = build_rest_prompt_parts(text, images, app_mentions).await?;
836836
let _prompt_guard = session.prompt_lock.lock().await;
837837

838-
// Synthesize turn ID and prepare translation state.
838+
// Synthesize turn ID and prepare translation state for this session.
839839
let turn_n = TURN_COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
840840
let turn_id = format!("turn_{turn_n}");
841841
{
842842
let mut ts = session.translation_state.lock().await;
843-
ts.start_turn(turn_id.clone());
843+
ts.start_turn(thread_id.clone(), turn_id.clone());
844844
}
845845

846846
// Emit synthetic turn/started.
@@ -1130,7 +1130,7 @@ pub(crate) async fn model_list_core(
11301130
}
11311131

11321132
// ---------------------------------------------------------------------------
1133-
// Permission response (REST: POST /session/:sid/permissions/:pid)
1133+
// Permission / Question response
11341134
// ---------------------------------------------------------------------------
11351135

11361136
pub(crate) async fn respond_to_server_request_core(
@@ -1141,24 +1141,51 @@ pub(crate) async fn respond_to_server_request_core(
11411141
) -> Result<(), String> {
11421142
let session = get_session_clone(sessions, &workspace_id).await?;
11431143

1144-
// request_id is "sessionId:permissionId" composite string.
11451144
let composite = request_id.as_str().unwrap_or_default();
1146-
let (_session_id, permission_id) = composite
1147-
.split_once(':')
1148-
.unwrap_or((composite, ""));
1149-
1150-
let accept = result
1151-
.get("decision")
1152-
.and_then(|v| v.as_str())
1153-
.map(|d| d == "accept")
1154-
.unwrap_or(true);
1145+
let (_session_id, resource_id) = composite.split_once(':').unwrap_or((composite, ""));
1146+
1147+
if let Some(answers) = result.get("answers") {
1148+
let answers_array = transform_question_answers(answers);
1149+
let body = json!({ "answers": answers_array });
1150+
let path = format!("/question/{resource_id}/reply");
1151+
session.rest_post_bool(&path, body).await?;
1152+
} else if result.get("reject").and_then(|v| v.as_bool()).unwrap_or(false) {
1153+
// Question rejection: POST /question/:id/reject
1154+
let path = format!("/question/{resource_id}/reject");
1155+
session.rest_post_bool(&path, json!({})).await?;
1156+
} else {
1157+
// Permission response: POST /permission/:id/reply
1158+
let accept = result
1159+
.get("decision")
1160+
.and_then(|v| v.as_str())
1161+
.map(|d| d == "accept")
1162+
.unwrap_or(true);
1163+
let body = event_translator::build_permission_response(accept);
1164+
let path = format!("/permission/{resource_id}/reply");
1165+
session.rest_post_bool(&path, body).await?;
1166+
}
11551167

1156-
let body = event_translator::build_permission_response(accept);
1157-
let path = format!("/permission/{permission_id}/reply");
1158-
session.rest_post_bool(&path, body).await?;
11591168
Ok(())
11601169
}
11611170

1171+
fn transform_question_answers(answers: &Value) -> Value {
1172+
let Some(obj) = answers.as_object() else {
1173+
return Value::Array(vec![]);
1174+
};
1175+
1176+
let mut entries: Vec<(usize, Vec<Value>)> = obj
1177+
.iter()
1178+
.filter_map(|(key, val)| {
1179+
let idx = key.parse::<usize>().ok()?;
1180+
let inner = val.get("answers")?.as_array()?;
1181+
Some((idx, inner.clone()))
1182+
})
1183+
.collect();
1184+
1185+
entries.sort_by_key(|(idx, _)| *idx);
1186+
Value::Array(entries.into_iter().map(|(_, arr)| Value::Array(arr)).collect())
1187+
}
1188+
11621189
// ---------------------------------------------------------------------------
11631190
// Account / login stubs (unchanged)
11641191
// ---------------------------------------------------------------------------

src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,6 +559,7 @@ function MainApp() {
559559
handleApprovalDecision,
560560
handleApprovalRemember,
561561
handleUserInputSubmit,
562+
handleUserInputDismiss,
562563
refreshAccountInfo,
563564
refreshAccountRateLimits,
564565
} = useThreads({
@@ -1751,6 +1752,7 @@ function MainApp() {
17511752
handleApprovalDecision,
17521753
handleApprovalRemember,
17531754
handleUserInputSubmit,
1755+
handleUserInputDismiss,
17541756
onPlanAccept: handlePlanAccept,
17551757
onPlanSubmitChanges: handlePlanSubmitChanges,
17561758
onOpenSettings: () => openSettings(),

src/features/app/components/RequestUserInputMessage.tsx

Lines changed: 56 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useMemo, useState } from "react";
1+
import { useCallback, useEffect, useMemo, useState } from "react";
22
import type {
33
RequestUserInputRequest,
44
RequestUserInputResponse,
@@ -12,6 +12,7 @@ type RequestUserInputMessageProps = {
1212
request: RequestUserInputRequest,
1313
response: RequestUserInputResponse,
1414
) => void;
15+
onDismiss: (request: RequestUserInputRequest) => void;
1516
};
1617

1718
type SelectionState = Record<string, number | null>;
@@ -22,6 +23,7 @@ export function RequestUserInputMessage({
2223
activeThreadId,
2324
activeWorkspaceId,
2425
onSubmit,
26+
onDismiss,
2527
}: RequestUserInputMessageProps) {
2628
const activeRequests = useMemo(
2729
() =>
@@ -39,7 +41,10 @@ export function RequestUserInputMessage({
3941
}),
4042
[requests, activeThreadId, activeWorkspaceId],
4143
);
42-
const activeRequest = activeRequests[0];
44+
const activeRequest = activeRequests[0] ?? null;
45+
const questions = activeRequest?.params.questions ?? [];
46+
const totalRequests = activeRequests.length;
47+
4348
const [selections, setSelections] = useState<SelectionState>({});
4449
const [notes, setNotes] = useState<NotesState>({});
4550

@@ -60,14 +65,7 @@ export function RequestUserInputMessage({
6065
setNotes(nextNotes);
6166
}, [activeRequest]);
6267

63-
if (!activeRequest) {
64-
return null;
65-
}
66-
67-
const { questions } = activeRequest.params;
68-
const totalRequests = activeRequests.length;
69-
70-
const buildAnswers = () => {
68+
const buildAnswers = useCallback(() => {
7169
const answers: RequestUserInputResponse["answers"] = {};
7270
questions.forEach((question, index) => {
7371
if (!question.id) {
@@ -97,19 +95,53 @@ export function RequestUserInputMessage({
9795
answers[question.id] = { answers: answerList };
9896
});
9997
return answers;
100-
};
98+
}, [questions, selections, notes]);
10199

102-
const handleSelect = (questionId: string, optionIndex: number) => {
100+
const handleSelect = useCallback((questionId: string, optionIndex: number) => {
103101
setSelections((current) => ({ ...current, [questionId]: optionIndex }));
104-
};
102+
}, []);
105103

106-
const handleNotesChange = (questionId: string, value: string) => {
104+
const handleNotesChange = useCallback((questionId: string, value: string) => {
107105
setNotes((current) => ({ ...current, [questionId]: value }));
108-
};
106+
}, []);
109107

110-
const handleSubmit = () => {
108+
const handleSubmit = useCallback(() => {
109+
if (!activeRequest) return;
111110
onSubmit(activeRequest, { answers: buildAnswers() });
112-
};
111+
}, [activeRequest, onSubmit, buildAnswers]);
112+
113+
const handleDismiss = useCallback(() => {
114+
if (!activeRequest) return;
115+
onDismiss(activeRequest);
116+
}, [activeRequest, onDismiss]);
117+
118+
useEffect(() => {
119+
if (!activeRequest) return;
120+
121+
const handleKeyDown = (event: KeyboardEvent) => {
122+
if (event.target instanceof HTMLTextAreaElement) {
123+
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
124+
event.preventDefault();
125+
handleSubmit();
126+
}
127+
return;
128+
}
129+
if (event.key === "Enter") {
130+
event.preventDefault();
131+
handleSubmit();
132+
} else if (event.key === "Escape") {
133+
event.preventDefault();
134+
handleDismiss();
135+
}
136+
};
137+
138+
window.addEventListener("keydown", handleKeyDown);
139+
return () => window.removeEventListener("keydown", handleKeyDown);
140+
}, [activeRequest, handleSubmit, handleDismiss]);
141+
142+
if (!activeRequest) {
143+
return null;
144+
}
113145

114146
return (
115147
<div className="message request-user-input-message">
@@ -192,6 +224,13 @@ export function RequestUserInputMessage({
192224
<button className="primary" onClick={handleSubmit}>
193225
Submit
194226
</button>
227+
<button className="secondary" onClick={handleDismiss}>
228+
Dismiss
229+
</button>
230+
<div className="request-user-input-shortcuts">
231+
<span><kbd>Enter</kbd> Submit</span>
232+
<span><kbd>Esc</kbd> Dismiss</span>
233+
</div>
195234
</div>
196235
</div>
197236
</div>

src/features/app/hooks/useAppServerEvents.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ type AppServerEventHandlers = {
4444
) => void;
4545
onApprovalRequest?: (request: ApprovalRequest) => void;
4646
onRequestUserInput?: (request: RequestUserInputRequest) => void;
47+
onUserInputCompleted?: (requestId: string | number, workspaceId: string) => void;
4748
onAgentMessageDelta?: (event: AgentDelta) => void;
4849
onAgentMessageCompleted?: (event: AgentCompleted) => void;
4950
onAppServerEvent?: (event: AppServerEvent) => void;
@@ -112,6 +113,7 @@ export const METHODS_ROUTED_IN_USE_APP_SERVER_EVENTS = [
112113
"item/reasoning/textDelta",
113114
"item/started",
114115
"item/tool/requestUserInput",
116+
"item/tool/userInputCompleted",
115117
"thread/name/updated",
116118
"thread/started",
117119
"thread/tokenUsage/updated",
@@ -213,6 +215,15 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) {
213215
return;
214216
}
215217

218+
if (method === "item/tool/userInputCompleted") {
219+
const requestId = params.requestId as string | number | undefined;
220+
const workspaceId = String(params.workspaceId ?? workspace_id ?? "");
221+
if (requestId !== undefined) {
222+
currentHandlers.onUserInputCompleted?.(requestId, workspaceId);
223+
}
224+
return;
225+
}
226+
216227
if (method === "item/agentMessage/delta") {
217228
const threadId = String(params.threadId ?? params.thread_id ?? "");
218229
const itemId = String(params.itemId ?? params.item_id ?? "");

src/features/layout/hooks/layoutNodes/buildPrimaryNodes.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,15 @@ export function buildPrimaryNodes(options: LayoutNodesOptions): PrimaryLayoutNod
3131
? options.threadStatusById[options.activeThreadId] ?? null
3232
: null;
3333

34+
const hasActiveUserInputRequest = Boolean(
35+
options.activeThreadId &&
36+
options.userInputRequests.some(
37+
(req) =>
38+
req.params.thread_id === options.activeThreadId &&
39+
(!options.activeWorkspace?.id || req.workspace_id === options.activeWorkspace.id),
40+
),
41+
);
42+
3443
const sidebarNode = (
3544
<Sidebar
3645
workspaces={options.workspaces}
@@ -102,6 +111,7 @@ export function buildPrimaryNodes(options: LayoutNodesOptions): PrimaryLayoutNod
102111
showMessageFilePath={options.showMessageFilePath}
103112
userInputRequests={options.userInputRequests}
104113
onUserInputSubmit={options.handleUserInputSubmit}
114+
onUserInputDismiss={options.handleUserInputDismiss}
105115
onPlanAccept={options.onPlanAccept}
106116
onPlanSubmitChanges={options.onPlanSubmitChanges}
107117
onOpenThreadLink={options.onOpenThreadLink}
@@ -122,7 +132,7 @@ export function buildPrimaryNodes(options: LayoutNodesOptions): PrimaryLayoutNod
122132
onQueue={options.onQueue}
123133
onStop={options.onStop}
124134
canStop={options.canStop}
125-
disabled={options.isReviewing}
135+
disabled={options.isReviewing || hasActiveUserInputRequest}
126136
isConnected={options.activeWorkspace?.connected ?? false}
127137
onFileAutocompleteActiveChange={options.onFileAutocompleteActiveChange}
128138
contextUsage={options.activeTokenUsage}

src/features/layout/hooks/layoutNodes/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ export type LayoutNodesOptions = {
141141
request: RequestUserInputRequest,
142142
response: RequestUserInputResponse,
143143
) => void;
144+
handleUserInputDismiss: (request: RequestUserInputRequest) => void;
144145
onPlanAccept?: () => void;
145146
onPlanSubmitChanges?: (changes: string) => void;
146147
onOpenSettings: () => void;

src/features/messages/components/Messages.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1089,6 +1089,7 @@ describe("Messages", () => {
10891089
},
10901090
]}
10911091
onUserInputSubmit={vi.fn()}
1092+
onUserInputDismiss={vi.fn()}
10921093
onPlanAccept={onPlanAccept}
10931094
onPlanSubmitChanges={onPlanSubmitChanges}
10941095
/>,

src/features/messages/components/Messages.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ type MessagesProps = {
5555
request: RequestUserInputRequest,
5656
response: RequestUserInputResponse,
5757
) => void;
58+
onUserInputDismiss?: (request: RequestUserInputRequest) => void;
5859
onPlanAccept?: () => void;
5960
onPlanSubmitChanges?: (changes: string) => void;
6061
onOpenThreadLink?: (threadId: string) => void;
@@ -75,6 +76,7 @@ export const Messages = memo(function Messages({
7576
showMessageFilePath = true,
7677
userInputRequests = [],
7778
onUserInputSubmit,
79+
onUserInputDismiss,
7880
onPlanAccept,
7981
onPlanSubmitChanges,
8082
onOpenThreadLink,
@@ -273,14 +275,16 @@ export const Messages = memo(function Messages({
273275
const groupedItems = useMemo(() => buildToolGroups(visibleItems), [visibleItems]);
274276

275277
const hasActiveUserInputRequest = activeUserInputRequestId !== null;
276-
const hasVisibleUserInputRequest = hasActiveUserInputRequest && Boolean(onUserInputSubmit);
278+
const hasVisibleUserInputRequest =
279+
hasActiveUserInputRequest && Boolean(onUserInputSubmit) && Boolean(onUserInputDismiss);
277280
const userInputNode =
278-
hasActiveUserInputRequest && onUserInputSubmit ? (
281+
hasActiveUserInputRequest && onUserInputSubmit && onUserInputDismiss ? (
279282
<RequestUserInputMessage
280283
requests={userInputRequests}
281284
activeThreadId={threadId}
282285
activeWorkspaceId={workspaceId}
283286
onSubmit={onUserInputSubmit}
287+
onDismiss={onUserInputDismiss}
284288
/>
285289
) : null;
286290

src/features/threads/hooks/useThreadEventHandlers.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ export function useThreadEventHandlers({
6464
dispatch,
6565
approvalAllowlistRef,
6666
});
67-
const onRequestUserInput = useThreadUserInputEvents({ dispatch });
67+
const { onRequestUserInput, onUserInputCompleted } = useThreadUserInputEvents({ dispatch });
6868

6969
const {
7070
onAgentMessageDelta,
@@ -145,6 +145,7 @@ export function useThreadEventHandlers({
145145
onWorkspaceConnected,
146146
onApprovalRequest,
147147
onRequestUserInput,
148+
onUserInputCompleted,
148149
onBackgroundThreadAction,
149150
onAppServerEvent,
150151
onAgentMessageDelta,
@@ -172,6 +173,7 @@ export function useThreadEventHandlers({
172173
onWorkspaceConnected,
173174
onApprovalRequest,
174175
onRequestUserInput,
176+
onUserInputCompleted,
175177
onBackgroundThreadAction,
176178
onAppServerEvent,
177179
onAgentMessageDelta,

0 commit comments

Comments
 (0)