authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-03 10:02:38-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-05 23:00:41-08:00
log1c2ed7593712b54bf4aa649739ebd5c64d4a44ad
tree3493b0b801db6e4af18fbe14b1371e09227dfce5
parent17e6cad479f0a0ac862a1f9cf8bd8d370ec9f10f

Normalize approval request kinds and persist draft provider state

- map canonical approval request types to `requestKind` across ingestion and web pending approvals - auto-approve Cursor permission requests when approval policy is `never` - persist per-thread composer provider selection in draft store and use it in ChatView - disable Cursor traits picker while model selection is locked

9 files changed, 412 insertions(+), 73 deletions(-)

apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+59
......@@ -1,5 +1,6 @@
11import type { ProviderRuntimeEvent } from "@t3tools/contracts";
22import {
3 ApprovalRequestId,
34 CommandId,
45 EventId,
56 MessageId,
......@@ -804,6 +805,64 @@ describe("ProviderRuntimeIngestion", () => {
804805 expect(completionEvents).toHaveLength(1);
805806 });
806807
808 it("maps canonical request events into approval activities with requestKind", async () => {
809 const harness = await createHarness();
810 const now = new Date().toISOString();
811
812 harness.emit({
813 type: "request.opened",
814 eventId: asEventId("evt-request-opened"),
815 provider: "codex",
816 sessionId: asSessionId("sess-1"),
817 createdAt: now,
818 requestId: ApprovalRequestId.makeUnsafe("req-open"),
819 payload: {
820 requestType: "command_execution_approval",
821 detail: "pwd",
822 },
823 });
824
825 harness.emit({
826 type: "request.resolved",
827 eventId: asEventId("evt-request-resolved"),
828 provider: "codex",
829 sessionId: asSessionId("sess-1"),
830 createdAt: now,
831 requestId: ApprovalRequestId.makeUnsafe("req-open"),
832 payload: {
833 requestType: "command_execution_approval",
834 decision: "accept",
835 },
836 });
837
838 await waitForThread(
839 harness.engine,
840 (entry) =>
841 entry.activities.some((activity) => activity.kind === "approval.requested") &&
842 entry.activities.some((activity) => activity.kind === "approval.resolved"),
843 );
844
845 const readModel = await Effect.runPromise(harness.engine.getReadModel());
846 const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
847 expect(thread).toBeDefined();
848
849 const requested = thread?.activities.find((activity) => activity.id === "evt-request-opened");
850 const requestedPayload =
851 requested?.payload && typeof requested.payload === "object"
852 ? (requested.payload as Record<string, unknown>)
853 : undefined;
854 expect(requestedPayload?.requestKind).toBe("command");
855 expect(requestedPayload?.requestType).toBe("command_execution_approval");
856
857 const resolved = thread?.activities.find((activity) => activity.id === "evt-request-resolved");
858 const resolvedPayload =
859 resolved?.payload && typeof resolved.payload === "object"
860 ? (resolved.payload as Record<string, unknown>)
861 : undefined;
862 expect(resolvedPayload?.requestKind).toBe("command");
863 expect(resolvedPayload?.requestType).toBe("command_execution_approval");
864 });
865
807866 it("maps runtime.error into errored session state", async () => {
808867 const harness = await createHarness();
809868 const now = new Date().toISOString();
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+21-2
......@@ -133,6 +133,21 @@ function runtimeErrorMessageFromEvent(event: ProviderRuntimeEvent): string | und
133133 return payloadMessage ?? legacyMessage;
134134}
135135
136function requestKindFromCanonicalRequestType(
137 requestType: string | undefined,
138): "command" | "file-change" | undefined {
139 switch (requestType) {
140 case "command_execution_approval":
141 case "exec_command_approval":
142 return "command";
143 case "file_change_approval":
144 case "apply_patch_approval":
145 return "file-change";
146 default:
147 return undefined;
148 }
149}
150
136151function isToolLifecycleItemType(itemType: string): boolean {
137152 return (
138153 itemType === "command_execution" ||
......@@ -151,6 +166,7 @@ function runtimeEventToActivities(
151166 const maybeSequence = event.sessionSequence !== undefined ? { sequence: event.sessionSequence } : {};
152167 switch (event.type) {
153168 case "request.opened": {
169 const requestKind = requestKindFromCanonicalRequestType(event.payload.requestType);
154170 return [
155171 {
156172 id: event.eventId,
......@@ -158,13 +174,14 @@ function runtimeEventToActivities(
158174 tone: "approval",
159175 kind: "approval.requested",
160176 summary:
161 event.payload.requestType === "command_execution_approval"
177 requestKind === "command"
162178 ? "Command approval requested"
163 : event.payload.requestType === "file_change_approval"
179 : requestKind === "file-change"
164180 ? "File-change approval requested"
165181 : "Approval requested",
166182 payload: {
167183 requestId: event.requestId,
184 ...(requestKind ? { requestKind } : {}),
168185 requestType: event.payload.requestType,
169186 ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),
170187 },
......@@ -175,6 +192,7 @@ function runtimeEventToActivities(
175192 }
176193
177194 case "request.resolved": {
195 const requestKind = requestKindFromCanonicalRequestType(event.payload.requestType);
178196 return [
179197 {
180198 id: event.eventId,
......@@ -184,6 +202,7 @@ function runtimeEventToActivities(
184202 summary: "Approval resolved",
185203 payload: {
186204 requestId: event.requestId,
205 ...(requestKind ? { requestKind } : {}),
187206 requestType: event.payload.requestType,
188207 ...(event.payload.decision ? { decision: event.payload.decision } : {}),
189208 },
apps/server/src/provider/Layers/CursorAdapter.test.ts+35
......@@ -406,4 +406,39 @@ describe("CursorAdapterLive", () => {
406406 assert.equal(fake.lastPermissionSelection, "allow-always");
407407 }).pipe(Effect.provide(layer));
408408 });
409
410 it.effect("auto-approves cursor permission requests when approval policy is never", () => {
411 const fake = new FakeCursorAcpProcess();
412 const layer = makeCursorAdapterLive({
413 createProcess: () => fake as never,
414 });
415
416 return Effect.gen(function* () {
417 const adapter = yield* CursorAdapter;
418
419 yield* adapter.startSession({
420 provider: "cursor",
421 approvalPolicy: "never",
422 });
423
424 // consume startup events
425 yield* Stream.take(adapter.streamEvents, 6).pipe(Stream.runDrain);
426
427 fake.emitPermissionRequest();
428
429 const resolved = yield* Stream.runHead(adapter.streamEvents);
430 assert.equal(resolved._tag, "Some");
431 if (resolved._tag !== "Some") {
432 return;
433 }
434
435 assert.equal(resolved.value.type, "request.resolved");
436 if (resolved.value.type !== "request.resolved") {
437 return;
438 }
439
440 assert.equal(resolved.value.payload.decision, "acceptForSession");
441 assert.equal(fake.lastPermissionSelection, "allow-always");
442 }).pipe(Effect.provide(layer));
443 });
409444});
apps/server/src/provider/Layers/CursorAdapter.ts+109-13
......@@ -15,6 +15,7 @@ import {
1515 EventId,
1616 type CanonicalItemType,
1717 type CanonicalRequestType,
18 type ProviderApprovalPolicy,
1819 ProviderSessionId,
1920 type ProviderRuntimeEvent,
2021 type ProviderSession,
......@@ -73,6 +74,7 @@ interface CursorTurnState {
7374
7475interface CursorSessionContext {
7576 session: ProviderSession;
77 approvalPolicy: ProviderApprovalPolicy;
7678 readonly child: ChildProcessWithoutNullStreams;
7779 readonly output: readline.Interface;
7880 readonly pending: Map<string, PendingRequest>;
......@@ -185,6 +187,43 @@ function normalizeRequestType(toolCall: unknown): CanonicalRequestType {
185187 return "unknown";
186188}
187189
190function selectCursorPermissionOption(
191 options: ReadonlyArray<{ optionId: string }>,
192 decision: "acceptForSession" | "accept" | "decline" | "cancel",
193): string | undefined {
194 const allowAlways = options.find((option) => option.optionId === "allow-always");
195 const allowOnce = options.find((option) => option.optionId === "allow-once");
196 const rejectOnce = options.find((option) => option.optionId === "reject-once");
197
198 if (decision === "acceptForSession") {
199 return allowAlways?.optionId ?? allowOnce?.optionId;
200 }
201 if (decision === "accept") {
202 return allowOnce?.optionId ?? allowAlways?.optionId;
203 }
204 return rejectOnce?.optionId ?? options[0]?.optionId;
205}
206
207function selectCursorAutoApprovalOption(
208 options: ReadonlyArray<{ optionId: string }>,
209): { optionId: string; decision: "acceptForSession" | "accept" } | undefined {
210 const allowAlways = options.find((option) => option.optionId === "allow-always");
211 if (allowAlways) {
212 return {
213 optionId: allowAlways.optionId,
214 decision: "acceptForSession",
215 };
216 }
217 const allowOnce = options.find((option) => option.optionId === "allow-once");
218 if (allowOnce) {
219 return {
220 optionId: allowOnce.optionId,
221 decision: "accept",
222 };
223 }
224 return undefined;
225}
226
188227function titleForItemType(itemType: CanonicalItemType): string {
189228 switch (itemType) {
190229 case "command_execution":
......@@ -459,14 +498,79 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
459498 return Effect.gen(function* () {
460499 const requestId = ApprovalRequestId.makeUnsafe(randomUUID());
461500 const requestType = normalizeRequestType(decoded.params.toolCall);
501 const options = decoded.params.options.map((entry) => ({ optionId: entry.optionId }));
502 const detail = asString(asObject(decoded.params.toolCall)?.title);
503
504 if (context.approvalPolicy === "never") {
505 const selection =
506 selectCursorAutoApprovalOption(options) ??
507 (options[0]
508 ? {
509 optionId: options[0].optionId,
510 decision: "accept",
511 }
512 : undefined);
513 if (!selection) {
514 return yield* emitRuntimeWarning(
515 context,
516 "Cursor ACP permission request contained no selectable options.",
517 decoded.params,
518 );
519 }
520
521 writeCursorMessage(context, {
522 jsonrpc: "2.0",
523 id: decoded.id,
524 result: {
525 outcome: {
526 outcome: "selected",
527 optionId: selection.optionId,
528 },
529 },
530 });
531
532 const stamp = yield* makeEventStamp();
533 yield* offerRuntimeEvent({
534 type: "request.resolved",
535 eventId: stamp.eventId,
536 provider: PROVIDER,
537 sessionId: context.session.sessionId,
538 createdAt: stamp.createdAt,
539 ...(context.session.threadId ? { threadId: context.session.threadId } : {}),
540 ...(context.turnState ? { turnId: context.turnState.turnId } : {}),
541 requestId,
542 payload: {
543 requestType,
544 decision: selection.decision,
545 resolution: {
546 optionId: selection.optionId,
547 autoApproved: true,
548 },
549 },
550 providerRefs: {
551 providerSessionId: context.session.sessionId,
552 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),
553 ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}),
554 providerRequestId: String(decoded.id),
555 },
556 raw: {
557 source: "cursor.acp.response",
558 method: "session/request_permission",
559 payload: {
560 optionId: selection.optionId,
561 autoApproved: true,
562 },
563 },
564 });
565 return;
566 }
462567
463568 context.pendingPermissions.set(requestId, {
464569 jsonRpcId: decoded.id,
465570 requestType,
466 options: decoded.params.options.map((entry) => ({ optionId: entry.optionId })),
571 options,
467572 });
468573
469 const detail = asString(asObject(decoded.params.toolCall)?.title);
470574 const stamp = yield* makeEventStamp();
471575 yield* offerRuntimeEvent({
472576 type: "request.opened",
......@@ -891,6 +995,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
891995
892996 const context: CursorSessionContext = {
893997 session,
998 approvalPolicy: input.approvalPolicy ?? "on-request",
894999 child,
8951000 output,
8961001 pending: new Map(),
......@@ -1323,16 +1428,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
13231428 });
13241429 }
13251430
1326 const allowAlways = pending.options.find((option) => option.optionId === "allow-always");
1327 const allowOnce = pending.options.find((option) => option.optionId === "allow-once");
1328 const rejectOnce = pending.options.find((option) => option.optionId === "reject-once");
1329
1330 const optionId =
1331 decision === "acceptForSession"
1332 ? allowAlways?.optionId ?? allowOnce?.optionId
1333 : decision === "accept"
1334 ? allowOnce?.optionId ?? allowAlways?.optionId
1335 : rejectOnce?.optionId ?? pending.options[0]?.optionId;
1431 const optionId = selectCursorPermissionOption(pending.options, decision);
13361432
13371433 if (!optionId) {
13381434 return yield* new ProviderAdapterRequestError({
......@@ -1376,7 +1472,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
13761472 providerSessionId: context.session.sessionId,
13771473 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),
13781474 ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}),
1379 providerRequestId: requestId,
1475 providerRequestId: String(pending.jsonRpcId),
13801476 },
13811477 raw: {
13821478 source: "cursor.acp.response",
apps/web/src/components/ChatView.tsx+67-56
......@@ -520,6 +520,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
520520 const composerImages = composerDraft.images;
521521 const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds;
522522 const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);
523 const setComposerDraftProvider = useComposerDraftStore((store) => store.setProvider);
523524 const setComposerDraftModel = useComposerDraftStore((store) => store.setModel);
524525 const setComposerDraftEffort = useComposerDraftStore((store) => store.setEffort);
525526 const addComposerDraftImage = useComposerDraftStore((store) => store.addImage);
......@@ -549,12 +550,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
549550 const [sendPhase, setSendPhase] = useState<SendPhase>("idle");
550551 const [isConnecting, _setIsConnecting] = useState(false);
551552 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);
552 const [selectedProviderByThread, setSelectedProviderByThread] = useState<
553 Partial<Record<ThreadId, ProviderKind>>
554 >({});
555 const [selectedModelByThread, setSelectedModelByThread] = useState<
556 Partial<Record<ThreadId, ModelSlug>>
557 >({});
558553 const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false);
559554 const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]);
560555 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});
......@@ -679,9 +674,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
679674 ]);
680675
681676 const sessionProvider = activeThread?.session?.provider ?? null;
682 const selectedProviderByThreadId = activeThread?.id
683 ? selectedProviderByThread[activeThread.id]
684 : undefined;
677 const selectedProviderByThreadId = composerDraft.provider;
685678 const hasThreadStarted = Boolean(
686679 activeThread &&
687680 (activeThread.latestTurn !== null || activeThread.messages.length > 0 || activeThread.session !== null),
......@@ -696,11 +689,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
696689 : null;
697690 const baseThreadModel = resolveModelSlugForProvider(
698691 selectedProvider,
699 (activeThread?.id ? selectedModelByThread[activeThread.id] : undefined) ??
700 activeThread?.model ??
701 activeProject?.model ??
702 getDefaultModel(selectedProvider) ??
703 DEFAULT_MODEL,
692 activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider) ?? DEFAULT_MODEL,
704693 );
705694 const selectedModel = resolveModelSlugForProvider(selectedProvider, composerDraft.model ?? baseThreadModel);
706695 const selectedEffort = composerDraft.effort ?? getDefaultReasoningEffort(selectedProvider);
......@@ -741,29 +730,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
741730 ),
742731 [lockedProvider],
743732 );
744 useEffect(() => {
745 if (!activeThread?.id || !sessionProvider) return;
746 setSelectedProviderByThread((existing) => {
747 if (existing[activeThread.id] === sessionProvider) return existing;
748 return { ...existing, [activeThread.id]: sessionProvider };
749 });
750 }, [activeThread?.id, sessionProvider]);
751 useEffect(() => {
752 if (!activeThread?.id) return;
753 setSelectedModelByThread((existing) => {
754 if (existing[activeThread.id]) return existing;
755 return {
756 ...existing,
757 [activeThread.id]: resolveModelSlugForProvider(
758 selectedProvider,
759 activeThread.model ??
760 activeProject?.model ??
761 getDefaultModel(selectedProvider) ??
762 DEFAULT_MODEL,
763 ),
764 };
765 });
766 }, [activeProject?.model, activeThread?.id, activeThread?.model, selectedProvider]);
767733 const phase = derivePhase(activeThread?.session ?? null);
768734 const isSendBusy = sendPhase !== "idle";
769735 const isPreparingWorktree = sendPhase === "preparing-worktree";
......@@ -2348,14 +2314,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
23482314 provider === "cursor"
23492315 ? resolveCursorModelFromSelection(parseCursorModelSelection(model))
23502316 : resolveModelSlugForProvider(provider, model);
2351 setSelectedProviderByThread((existing) => ({
2352 ...existing,
2353 [activeThread.id]: provider,
2354 }));
2355 setSelectedModelByThread((existing) => ({
2356 ...existing,
2357 [activeThread.id]: resolvedModel,
2358 }));
2317 setComposerDraftProvider(activeThread.id, provider);
2318 setComposerDraftModel(activeThread.id, resolvedModel);
23592319 if (api && isServerThread) {
23602320 void api.orchestration.dispatchCommand({
23612321 type: "thread.meta.update",
......@@ -2372,6 +2332,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
23722332 isServerThread,
23732333 lockedProvider,
23742334 scheduleComposerFocus,
2335 setComposerDraftModel,
2336 setComposerDraftProvider,
23752337 ],
23762338 );
23772339 const onCursorReasoningSelect = useCallback(
......@@ -2888,13 +2850,37 @@ export default function ChatView({ threadId }: ChatViewProps) {
28882850 )}
28892851
28902852 {selectedCursorModel && selectedCursorModelCapabilities && hasSelectedCursorTraits && (
2891 <CursorTraitsPicker
2892 selection={selectedCursorModel}
2893 capabilities={selectedCursorModelCapabilities}
2894 onReasoningChange={onCursorReasoningSelect}
2895 onFastModeChange={onCursorFastModeChange}
2896 onThinkingModeChange={onCursorThinkingModeChange}
2897 />
2853 <>
2854 {cursorModelSelectionLockedReason ? (
2855 <Tooltip>
2856 <TooltipTrigger
2857 render={
2858 <span className="inline-flex">
2859 <CursorTraitsPicker
2860 selection={selectedCursorModel}
2861 capabilities={selectedCursorModelCapabilities}
2862 disabled
2863 onReasoningChange={onCursorReasoningSelect}
2864 onFastModeChange={onCursorFastModeChange}
2865 onThinkingModeChange={onCursorThinkingModeChange}
2866 />
2867 </span>
2868 }
2869 />
2870 <TooltipPopup side="top" className="max-w-72 whitespace-normal leading-tight">
2871 {cursorModelSelectionLockedReason}
2872 </TooltipPopup>
2873 </Tooltip>
2874 ) : (
2875 <CursorTraitsPicker
2876 selection={selectedCursorModel}
2877 capabilities={selectedCursorModelCapabilities}
2878 onReasoningChange={onCursorReasoningSelect}
2879 onFastModeChange={onCursorFastModeChange}
2880 onThinkingModeChange={onCursorThinkingModeChange}
2881 />
2882 )}
2883 </>
28982884 )}
28992885 </>
29002886 ) : supportsReasoningEffort ? (
......@@ -4238,10 +4224,12 @@ const ReasoningEffortPicker = memo(function ReasoningEffortPicker(props: {
42384224const CursorTraitsPicker = memo(function CursorTraitsPicker(props: {
42394225 selection: ReturnType<typeof parseCursorModelSelection>;
42404226 capabilities: ReturnType<typeof getCursorModelCapabilities>;
4227 disabled?: boolean;
42414228 onReasoningChange: (reasoning: CursorReasoningOption) => void;
42424229 onFastModeChange: (enabled: boolean) => void;
42434230 onThinkingModeChange: (enabled: boolean) => void;
42444231}) {
4232 const [isMenuOpen, setIsMenuOpen] = useState(false);
42454233 const reasoningLabelByOption: Record<CursorReasoningOption, string> = {
42464234 low: "Low",
42474235 normal: "Normal",
......@@ -4258,9 +4246,25 @@ const CursorTraitsPicker = memo(function CursorTraitsPicker(props: {
42584246 const triggerLabel = traitSummary.length > 0 ? traitSummary.join(" · ") : "Traits";
42594247
42604248 return (
4261 <Menu>
4249 <Menu
4250 open={isMenuOpen}
4251 onOpenChange={(open) => {
4252 if (props.disabled) {
4253 setIsMenuOpen(false);
4254 return;
4255 }
4256 setIsMenuOpen(open);
4257 }}
4258 >
42624259 <MenuTrigger
4263 render={<Button size="sm" variant="ghost" className="shrink-0 whitespace-nowrap px-2 sm:px-3" />}
4260 render={
4261 <Button
4262 size="sm"
4263 variant="ghost"
4264 className="shrink-0 whitespace-nowrap px-2 sm:px-3"
4265 disabled={props.disabled}
4266 />
4267 }
42644268 >
42654269 <span>{triggerLabel}</span>
42664270 <ChevronDownIcon aria-hidden="true" className="size-3 opacity-60" />
......@@ -4272,6 +4276,7 @@ const CursorTraitsPicker = memo(function CursorTraitsPicker(props: {
42724276 <MenuRadioGroup
42734277 value={props.selection.reasoning}
42744278 onValueChange={(value) => {
4279 if (props.disabled) return;
42754280 if (!value) return;
42764281 const nextReasoning = CURSOR_REASONING_OPTIONS.find((option) => option === value);
42774282 if (!nextReasoning) return;
......@@ -4294,7 +4299,10 @@ const CursorTraitsPicker = memo(function CursorTraitsPicker(props: {
42944299 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Fast Mode</div>
42954300 <MenuRadioGroup
42964301 value={props.selection.fast ? "on" : "off"}
4297 onValueChange={(value) => props.onFastModeChange(value === "on")}
4302 onValueChange={(value) => {
4303 if (props.disabled) return;
4304 props.onFastModeChange(value === "on");
4305 }}
42984306 >
42994307 <MenuRadioItem value="off">off</MenuRadioItem>
43004308 <MenuRadioItem value="on">on</MenuRadioItem>
......@@ -4307,7 +4315,10 @@ const CursorTraitsPicker = memo(function CursorTraitsPicker(props: {
43074315 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Thinking</div>
43084316 <MenuRadioGroup
43094317 value={props.selection.thinking ? "on" : "off"}
4310 onValueChange={(value) => props.onThinkingModeChange(value === "on")}
4318 onValueChange={(value) => {
4319 if (props.disabled) return;
4320 props.onThinkingModeChange(value === "on");
4321 }}
43114322 >
43124323 <MenuRadioItem value="off">off</MenuRadioItem>
43134324 <MenuRadioItem value="on">on</MenuRadioItem>
apps/web/src/composerDraftStore.test.ts+29
......@@ -348,3 +348,32 @@ describe("composerDraftStore setModel", () => {
348348 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.model).toBe("gpt-5.3-codex");
349349 });
350350});
351
352describe("composerDraftStore setProvider", () => {
353 const threadId = ThreadId.makeUnsafe("thread-provider");
354
355 beforeEach(() => {
356 useComposerDraftStore.setState({
357 draftsByThreadId: {},
358 draftThreadsByThreadId: {},
359 projectDraftThreadIdByProjectId: {},
360 });
361 });
362
363 it("persists provider-only selection even when prompt/model are empty", () => {
364 const store = useComposerDraftStore.getState();
365
366 store.setProvider(threadId, "cursor");
367
368 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.provider).toBe("cursor");
369 });
370
371 it("removes empty provider-only draft when provider is reset", () => {
372 const store = useComposerDraftStore.getState();
373
374 store.setProvider(threadId, "cursor");
375 store.setProvider(threadId, null);
376
377 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();
378 });
379});
apps/web/src/composerDraftStore.ts+49-2
......@@ -5,6 +5,7 @@ import {
55 ThreadId,
66 normalizeModelSlug,
77 type CodexReasoningEffort,
8 type ProviderKind,
89} from "@t3tools/contracts";
910import type { ChatImageAttachment } from "./types";
1011import { create } from "zustand";
......@@ -29,6 +30,7 @@ export interface ComposerImageAttachment extends Omit<ChatImageAttachment, "prev
2930interface PersistedComposerThreadDraftState {
3031 prompt: string;
3132 attachments: PersistedComposerImageAttachment[];
33 provider?: ProviderKind | null;
3234 model?: string | null;
3335 effort?: CodexReasoningEffort | null;
3436}
......@@ -52,6 +54,7 @@ interface ComposerThreadDraftState {
5254 images: ComposerImageAttachment[];
5355 nonPersistedImageIds: string[];
5456 persistedAttachments: PersistedComposerImageAttachment[];
57 provider: ProviderKind | null;
5558 model: string | null;
5659 effort: CodexReasoningEffort | null;
5760}
......@@ -98,6 +101,7 @@ interface ComposerDraftStoreState {
98101 clearProjectDraftThreadById: (projectId: ProjectId, threadId: ThreadId) => void;
99102 clearDraftThread: (threadId: ThreadId) => void;
100103 setPrompt: (threadId: ThreadId, prompt: string) => void;
104 setProvider: (threadId: ThreadId, provider: ProviderKind | null | undefined) => void;
101105 setModel: (threadId: ThreadId, model: string | null | undefined) => void;
102106 setEffort: (threadId: ThreadId, effort: CodexReasoningEffort | null | undefined) => void;
103107 addImage: (threadId: ThreadId, image: ComposerImageAttachment) => void;
......@@ -129,6 +133,7 @@ const EMPTY_THREAD_DRAFT = Object.freeze({
129133 images: EMPTY_IMAGES,
130134 nonPersistedImageIds: EMPTY_IDS,
131135 persistedAttachments: EMPTY_PERSISTED_ATTACHMENTS,
136 provider: null,
132137 model: null,
133138 effort: null,
134139}) as ComposerThreadDraftState;
......@@ -143,6 +148,7 @@ function createEmptyThreadDraft(): ComposerThreadDraftState {
143148 images: [],
144149 nonPersistedImageIds: [],
145150 persistedAttachments: [],
151 provider: null,
146152 model: null,
147153 effort: null,
148154 };
......@@ -159,11 +165,16 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean {
159165 draft.prompt.length === 0 &&
160166 draft.images.length === 0 &&
161167 draft.persistedAttachments.length === 0 &&
168 draft.provider === null &&
162169 draft.model === null &&
163170 draft.effort === null
164171 );
165172}
166173
174function normalizeProviderKind(value: unknown): ProviderKind | null {
175 return value === "codex" || value === "claudeCode" || value === "cursor" ? value : null;
176}
177
167178function revokeObjectPreviewUrl(previewUrl: string): void {
168179 if (typeof URL === "undefined") {
169180 return;
......@@ -307,20 +318,24 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer
307318 return normalized ? [normalized] : [];
308319 })
309320 : [];
321 const provider = normalizeProviderKind(draftCandidate.provider);
310322 const model =
311 typeof draftCandidate.model === "string" ? normalizeModelSlug(draftCandidate.model) : null;
323 typeof draftCandidate.model === "string"
324 ? normalizeModelSlug(draftCandidate.model, provider ?? "codex")
325 : null;
312326 const effortCandidate =
313327 typeof draftCandidate.effort === "string" ? draftCandidate.effort : null;
314328 const effort =
315329 effortCandidate && REASONING_EFFORT_VALUES.has(effortCandidate as CodexReasoningEffort)
316330 ? (effortCandidate as CodexReasoningEffort)
317331 : null;
318 if (prompt.length === 0 && attachments.length === 0 && !model && !effort) {
332 if (prompt.length === 0 && attachments.length === 0 && !provider && !model && !effort) {
319333 continue;
320334 }
321335 nextDraftsByThreadId[threadId as ThreadId] = {
322336 prompt,
323337 attachments,
338 ...(provider ? { provider } : {}),
324339 ...(model ? { model } : {}),
325340 ...(effort ? { effort } : {}),
326341 };
......@@ -424,6 +439,7 @@ function toHydratedThreadDraft(
424439 images: hydrateImagesFromPersisted(persistedDraft.attachments),
425440 nonPersistedImageIds: [],
426441 persistedAttachments: persistedDraft.attachments,
442 provider: persistedDraft.provider ?? null,
427443 model: persistedDraft.model ?? null,
428444 effort: persistedDraft.effort ?? null,
429445 };
......@@ -676,6 +692,33 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
676692 return { draftsByThreadId: nextDraftsByThreadId };
677693 });
678694 },
695 setProvider: (threadId, provider) => {
696 if (threadId.length === 0) {
697 return;
698 }
699 const normalizedProvider = normalizeProviderKind(provider);
700 set((state) => {
701 const existing = state.draftsByThreadId[threadId];
702 if (!existing && normalizedProvider === null) {
703 return state;
704 }
705 const base = existing ?? createEmptyThreadDraft();
706 if (base.provider === normalizedProvider) {
707 return state;
708 }
709 const nextDraft: ComposerThreadDraftState = {
710 ...base,
711 provider: normalizedProvider,
712 };
713 const nextDraftsByThreadId = { ...state.draftsByThreadId };
714 if (shouldRemoveDraft(nextDraft)) {
715 delete nextDraftsByThreadId[threadId];
716 } else {
717 nextDraftsByThreadId[threadId] = nextDraft;
718 }
719 return { draftsByThreadId: nextDraftsByThreadId };
720 });
721 },
679722 setModel: (threadId, model) => {
680723 if (threadId.length === 0) {
681724 return;
......@@ -967,6 +1010,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
9671010 if (
9681011 draft.prompt.length === 0 &&
9691012 draft.persistedAttachments.length === 0 &&
1013 draft.provider === null &&
9701014 draft.model === null &&
9711015 draft.effort === null
9721016 ) {
......@@ -979,6 +1023,9 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
9791023 if (draft.model) {
9801024 persistedDraft.model = draft.model;
9811025 }
1026 if (draft.provider) {
1027 persistedDraft.provider = draft.provider;
1028 }
9821029 if (draft.effort) {
9831030 persistedDraft.effort = draft.effort;
9841031 }
apps/web/src/session-logic.test.ts+26
......@@ -74,6 +74,32 @@ describe("derivePendingApprovals", () => {
7474 },
7575 ]);
7676 });
77
78 it("maps canonical requestType payloads into pending approvals", () => {
79 const activities: OrchestrationThreadActivity[] = [
80 makeActivity({
81 id: "approval-open-request-type",
82 createdAt: "2026-02-23T00:00:01.000Z",
83 kind: "approval.requested",
84 summary: "Command approval requested",
85 tone: "approval",
86 payload: {
87 requestId: "req-request-type",
88 requestType: "command_execution_approval",
89 detail: "pwd",
90 },
91 }),
92 ];
93
94 expect(derivePendingApprovals(activities)).toEqual([
95 {
96 requestId: "req-request-type",
97 requestKind: "command",
98 createdAt: "2026-02-23T00:00:01.000Z",
99 detail: "pwd",
100 },
101 ]);
102 });
77103});
78104
79105describe("deriveWorkLogEntries", () => {
apps/web/src/session-logic.ts+17
......@@ -88,6 +88,21 @@ export function isLatestTurnSettled(
8888 return true;
8989}
9090
91function requestKindFromRequestType(
92 requestType: unknown,
93): PendingApproval["requestKind"] | null {
94 switch (requestType) {
95 case "command_execution_approval":
96 case "exec_command_approval":
97 return "command";
98 case "file_change_approval":
99 case "apply_patch_approval":
100 return "file-change";
101 default:
102 return null;
103 }
104}
105
91106export function derivePendingApprovals(
92107 activities: ReadonlyArray<OrchestrationThreadActivity>,
93108): PendingApproval[] {
......@@ -106,6 +121,8 @@ export function derivePendingApprovals(
106121 const requestKind =
107122 payload && (payload.requestKind === "command" || payload.requestKind === "file-change")
108123 ? payload.requestKind
124 : payload
125 ? requestKindFromRequestType(payload.requestType)
109126 : null;
110127 const detail = payload && typeof payload.detail === "string" ? payload.detail : undefined;
111128