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 @@...@@ -1,5 +1,6 @@
1import type { ProviderRuntimeEvent } from "@t3tools/contracts";1import type { ProviderRuntimeEvent } from "@t3tools/contracts";
2import {2import {
3 ApprovalRequestId,
3 CommandId,4 CommandId,
4 EventId,5 EventId,
5 MessageId,6 MessageId,
...@@ -804,6 +805,64 @@ describe("ProviderRuntimeIngestion", () => {...@@ -804,6 +805,64 @@ describe("ProviderRuntimeIngestion", () => {
804 expect(completionEvents).toHaveLength(1);805 expect(completionEvents).toHaveLength(1);
805 });806 });
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
807 it("maps runtime.error into errored session state", async () => {866 it("maps runtime.error into errored session state", async () => {
808 const harness = await createHarness();867 const harness = await createHarness();
809 const now = new Date().toISOString();868 const now = new Date().toISOString();
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+21-2
...@@ -133,6 +133,21 @@ function runtimeErrorMessageFromEvent(event: ProviderRuntimeEvent): string | und...@@ -133,6 +133,21 @@ function runtimeErrorMessageFromEvent(event: ProviderRuntimeEvent): string | und
133 return payloadMessage ?? legacyMessage;133 return payloadMessage ?? legacyMessage;
134}134}
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
136function isToolLifecycleItemType(itemType: string): boolean {151function isToolLifecycleItemType(itemType: string): boolean {
137 return (152 return (
138 itemType === "command_execution" ||153 itemType === "command_execution" ||
...@@ -151,6 +166,7 @@ function runtimeEventToActivities(...@@ -151,6 +166,7 @@ function runtimeEventToActivities(
151 const maybeSequence = event.sessionSequence !== undefined ? { sequence: event.sessionSequence } : {};166 const maybeSequence = event.sessionSequence !== undefined ? { sequence: event.sessionSequence } : {};
152 switch (event.type) {167 switch (event.type) {
153 case "request.opened": {168 case "request.opened": {
169 const requestKind = requestKindFromCanonicalRequestType(event.payload.requestType);
154 return [170 return [
155 {171 {
156 id: event.eventId,172 id: event.eventId,
...@@ -158,13 +174,14 @@ function runtimeEventToActivities(...@@ -158,13 +174,14 @@ function runtimeEventToActivities(
158 tone: "approval",174 tone: "approval",
159 kind: "approval.requested",175 kind: "approval.requested",
160 summary:176 summary:
161 event.payload.requestType === "command_execution_approval"177 requestKind === "command"
162 ? "Command approval requested"178 ? "Command approval requested"
163 : event.payload.requestType === "file_change_approval"179 : requestKind === "file-change"
164 ? "File-change approval requested"180 ? "File-change approval requested"
165 : "Approval requested",181 : "Approval requested",
166 payload: {182 payload: {
167 requestId: event.requestId,183 requestId: event.requestId,
184 ...(requestKind ? { requestKind } : {}),
168 requestType: event.payload.requestType,185 requestType: event.payload.requestType,
169 ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),186 ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),
170 },187 },
...@@ -175,6 +192,7 @@ function runtimeEventToActivities(...@@ -175,6 +192,7 @@ function runtimeEventToActivities(
175 }192 }
176193
177 case "request.resolved": {194 case "request.resolved": {
195 const requestKind = requestKindFromCanonicalRequestType(event.payload.requestType);
178 return [196 return [
179 {197 {
180 id: event.eventId,198 id: event.eventId,
...@@ -184,6 +202,7 @@ function runtimeEventToActivities(...@@ -184,6 +202,7 @@ function runtimeEventToActivities(
184 summary: "Approval resolved",202 summary: "Approval resolved",
185 payload: {203 payload: {
186 requestId: event.requestId,204 requestId: event.requestId,
205 ...(requestKind ? { requestKind } : {}),
187 requestType: event.payload.requestType,206 requestType: event.payload.requestType,
188 ...(event.payload.decision ? { decision: event.payload.decision } : {}),207 ...(event.payload.decision ? { decision: event.payload.decision } : {}),
189 },208 },
apps/server/src/provider/Layers/CursorAdapter.test.ts+35
...@@ -406,4 +406,39 @@ describe("CursorAdapterLive", () => {...@@ -406,4 +406,39 @@ describe("CursorAdapterLive", () => {
406 assert.equal(fake.lastPermissionSelection, "allow-always");406 assert.equal(fake.lastPermissionSelection, "allow-always");
407 }).pipe(Effect.provide(layer));407 }).pipe(Effect.provide(layer));
408 });408 });
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 });
409});444});
apps/server/src/provider/Layers/CursorAdapter.ts+109-13
...@@ -15,6 +15,7 @@ import {...@@ -15,6 +15,7 @@ import {
15 EventId,15 EventId,
16 type CanonicalItemType,16 type CanonicalItemType,
17 type CanonicalRequestType,17 type CanonicalRequestType,
18 type ProviderApprovalPolicy,
18 ProviderSessionId,19 ProviderSessionId,
19 type ProviderRuntimeEvent,20 type ProviderRuntimeEvent,
20 type ProviderSession,21 type ProviderSession,
...@@ -73,6 +74,7 @@ interface CursorTurnState {...@@ -73,6 +74,7 @@ interface CursorTurnState {
7374
74interface CursorSessionContext {75interface CursorSessionContext {
75 session: ProviderSession;76 session: ProviderSession;
77 approvalPolicy: ProviderApprovalPolicy;
76 readonly child: ChildProcessWithoutNullStreams;78 readonly child: ChildProcessWithoutNullStreams;
77 readonly output: readline.Interface;79 readonly output: readline.Interface;
78 readonly pending: Map<string, PendingRequest>;80 readonly pending: Map<string, PendingRequest>;
...@@ -185,6 +187,43 @@ function normalizeRequestType(toolCall: unknown): CanonicalRequestType {...@@ -185,6 +187,43 @@ function normalizeRequestType(toolCall: unknown): CanonicalRequestType {
185 return "unknown";187 return "unknown";
186}188}
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
188function titleForItemType(itemType: CanonicalItemType): string {227function titleForItemType(itemType: CanonicalItemType): string {
189 switch (itemType) {228 switch (itemType) {
190 case "command_execution":229 case "command_execution":
...@@ -459,14 +498,79 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -459,14 +498,79 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
459 return Effect.gen(function* () {498 return Effect.gen(function* () {
460 const requestId = ApprovalRequestId.makeUnsafe(randomUUID());499 const requestId = ApprovalRequestId.makeUnsafe(randomUUID());
461 const requestType = normalizeRequestType(decoded.params.toolCall);500 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
463 context.pendingPermissions.set(requestId, {568 context.pendingPermissions.set(requestId, {
464 jsonRpcId: decoded.id,569 jsonRpcId: decoded.id,
465 requestType,570 requestType,
466 options: decoded.params.options.map((entry) => ({ optionId: entry.optionId })),571 options,
467 });572 });
468573
469 const detail = asString(asObject(decoded.params.toolCall)?.title);
470 const stamp = yield* makeEventStamp();574 const stamp = yield* makeEventStamp();
471 yield* offerRuntimeEvent({575 yield* offerRuntimeEvent({
472 type: "request.opened",576 type: "request.opened",
...@@ -891,6 +995,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -891,6 +995,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
891995
892 const context: CursorSessionContext = {996 const context: CursorSessionContext = {
893 session,997 session,
998 approvalPolicy: input.approvalPolicy ?? "on-request",
894 child,999 child,
895 output,1000 output,
896 pending: new Map(),1001 pending: new Map(),
...@@ -1323,16 +1428,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -1323,16 +1428,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
1323 });1428 });
1324 }1429 }
13251430
1326 const allowAlways = pending.options.find((option) => option.optionId === "allow-always");1431 const optionId = selectCursorPermissionOption(pending.options, decision);
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;
13361432
1337 if (!optionId) {1433 if (!optionId) {
1338 return yield* new ProviderAdapterRequestError({1434 return yield* new ProviderAdapterRequestError({
...@@ -1376,7 +1472,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -1376,7 +1472,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
1376 providerSessionId: context.session.sessionId,1472 providerSessionId: context.session.sessionId,
1377 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),1473 ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}),
1378 ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}),1474 ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}),
1379 providerRequestId: requestId,1475 providerRequestId: String(pending.jsonRpcId),
1380 },1476 },
1381 raw: {1477 raw: {
1382 source: "cursor.acp.response",1478 source: "cursor.acp.response",
apps/web/src/components/ChatView.tsx+67-56
...@@ -520,6 +520,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -520,6 +520,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
520 const composerImages = composerDraft.images;520 const composerImages = composerDraft.images;
521 const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds;521 const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds;
522 const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);522 const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);
523 const setComposerDraftProvider = useComposerDraftStore((store) => store.setProvider);
523 const setComposerDraftModel = useComposerDraftStore((store) => store.setModel);524 const setComposerDraftModel = useComposerDraftStore((store) => store.setModel);
524 const setComposerDraftEffort = useComposerDraftStore((store) => store.setEffort);525 const setComposerDraftEffort = useComposerDraftStore((store) => store.setEffort);
525 const addComposerDraftImage = useComposerDraftStore((store) => store.addImage);526 const addComposerDraftImage = useComposerDraftStore((store) => store.addImage);
...@@ -549,12 +550,6 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -549,12 +550,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
549 const [sendPhase, setSendPhase] = useState<SendPhase>("idle");550 const [sendPhase, setSendPhase] = useState<SendPhase>("idle");
550 const [isConnecting, _setIsConnecting] = useState(false);551 const [isConnecting, _setIsConnecting] = useState(false);
551 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);552 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 >({});
558 const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false);553 const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false);
559 const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]);554 const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]);
560 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});555 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});
...@@ -679,9 +674,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -679,9 +674,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
679 ]);674 ]);
680675
681 const sessionProvider = activeThread?.session?.provider ?? null;676 const sessionProvider = activeThread?.session?.provider ?? null;
682 const selectedProviderByThreadId = activeThread?.id677 const selectedProviderByThreadId = composerDraft.provider;
683 ? selectedProviderByThread[activeThread.id]
684 : undefined;
685 const hasThreadStarted = Boolean(678 const hasThreadStarted = Boolean(
686 activeThread &&679 activeThread &&
687 (activeThread.latestTurn !== null || activeThread.messages.length > 0 || activeThread.session !== null),680 (activeThread.latestTurn !== null || activeThread.messages.length > 0 || activeThread.session !== null),
...@@ -696,11 +689,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -696,11 +689,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
696 : null;689 : null;
697 const baseThreadModel = resolveModelSlugForProvider(690 const baseThreadModel = resolveModelSlugForProvider(
698 selectedProvider,691 selectedProvider,
699 (activeThread?.id ? selectedModelByThread[activeThread.id] : undefined) ??692 activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider) ?? DEFAULT_MODEL,
700 activeThread?.model ??
701 activeProject?.model ??
702 getDefaultModel(selectedProvider) ??
703 DEFAULT_MODEL,
704 );693 );
705 const selectedModel = resolveModelSlugForProvider(selectedProvider, composerDraft.model ?? baseThreadModel);694 const selectedModel = resolveModelSlugForProvider(selectedProvider, composerDraft.model ?? baseThreadModel);
706 const selectedEffort = composerDraft.effort ?? getDefaultReasoningEffort(selectedProvider);695 const selectedEffort = composerDraft.effort ?? getDefaultReasoningEffort(selectedProvider);
...@@ -741,29 +730,6 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -741,29 +730,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
741 ),730 ),
742 [lockedProvider],731 [lockedProvider],
743 );732 );
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]);
767 const phase = derivePhase(activeThread?.session ?? null);733 const phase = derivePhase(activeThread?.session ?? null);
768 const isSendBusy = sendPhase !== "idle";734 const isSendBusy = sendPhase !== "idle";
769 const isPreparingWorktree = sendPhase === "preparing-worktree";735 const isPreparingWorktree = sendPhase === "preparing-worktree";
...@@ -2348,14 +2314,8 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2348,14 +2314,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
2348 provider === "cursor"2314 provider === "cursor"
2349 ? resolveCursorModelFromSelection(parseCursorModelSelection(model))2315 ? resolveCursorModelFromSelection(parseCursorModelSelection(model))
2350 : resolveModelSlugForProvider(provider, model);2316 : resolveModelSlugForProvider(provider, model);
2351 setSelectedProviderByThread((existing) => ({2317 setComposerDraftProvider(activeThread.id, provider);
2352 ...existing,2318 setComposerDraftModel(activeThread.id, resolvedModel);
2353 [activeThread.id]: provider,
2354 }));
2355 setSelectedModelByThread((existing) => ({
2356 ...existing,
2357 [activeThread.id]: resolvedModel,
2358 }));
2359 if (api && isServerThread) {2319 if (api && isServerThread) {
2360 void api.orchestration.dispatchCommand({2320 void api.orchestration.dispatchCommand({
2361 type: "thread.meta.update",2321 type: "thread.meta.update",
...@@ -2372,6 +2332,8 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2372,6 +2332,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
2372 isServerThread,2332 isServerThread,
2373 lockedProvider,2333 lockedProvider,
2374 scheduleComposerFocus,2334 scheduleComposerFocus,
2335 setComposerDraftModel,
2336 setComposerDraftProvider,
2375 ],2337 ],
2376 );2338 );
2377 const onCursorReasoningSelect = useCallback(2339 const onCursorReasoningSelect = useCallback(
...@@ -2888,13 +2850,37 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2888,13 +2850,37 @@ export default function ChatView({ threadId }: ChatViewProps) {
2888 )}2850 )}
28892851
2890 {selectedCursorModel && selectedCursorModelCapabilities && hasSelectedCursorTraits && (2852 {selectedCursorModel && selectedCursorModelCapabilities && hasSelectedCursorTraits && (
2891 <CursorTraitsPicker2853 <>
2892 selection={selectedCursorModel}2854 {cursorModelSelectionLockedReason ? (
2893 capabilities={selectedCursorModelCapabilities}2855 <Tooltip>
2894 onReasoningChange={onCursorReasoningSelect}2856 <TooltipTrigger
2895 onFastModeChange={onCursorFastModeChange}2857 render={
2896 onThinkingModeChange={onCursorThinkingModeChange}2858 <span className="inline-flex">
2897 />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 </>
2898 )}2884 )}
2899 </>2885 </>
2900 ) : supportsReasoningEffort ? (2886 ) : supportsReasoningEffort ? (
...@@ -4238,10 +4224,12 @@ const ReasoningEffortPicker = memo(function ReasoningEffortPicker(props: {...@@ -4238,10 +4224,12 @@ const ReasoningEffortPicker = memo(function ReasoningEffortPicker(props: {
4238const CursorTraitsPicker = memo(function CursorTraitsPicker(props: {4224const CursorTraitsPicker = memo(function CursorTraitsPicker(props: {
4239 selection: ReturnType<typeof parseCursorModelSelection>;4225 selection: ReturnType<typeof parseCursorModelSelection>;
4240 capabilities: ReturnType<typeof getCursorModelCapabilities>;4226 capabilities: ReturnType<typeof getCursorModelCapabilities>;
4227 disabled?: boolean;
4241 onReasoningChange: (reasoning: CursorReasoningOption) => void;4228 onReasoningChange: (reasoning: CursorReasoningOption) => void;
4242 onFastModeChange: (enabled: boolean) => void;4229 onFastModeChange: (enabled: boolean) => void;
4243 onThinkingModeChange: (enabled: boolean) => void;4230 onThinkingModeChange: (enabled: boolean) => void;
4244}) {4231}) {
4232 const [isMenuOpen, setIsMenuOpen] = useState(false);
4245 const reasoningLabelByOption: Record<CursorReasoningOption, string> = {4233 const reasoningLabelByOption: Record<CursorReasoningOption, string> = {
4246 low: "Low",4234 low: "Low",
4247 normal: "Normal",4235 normal: "Normal",
...@@ -4258,9 +4246,25 @@ const CursorTraitsPicker = memo(function CursorTraitsPicker(props: {...@@ -4258,9 +4246,25 @@ const CursorTraitsPicker = memo(function CursorTraitsPicker(props: {
4258 const triggerLabel = traitSummary.length > 0 ? traitSummary.join(" · ") : "Traits";4246 const triggerLabel = traitSummary.length > 0 ? traitSummary.join(" · ") : "Traits";
42594247
4260 return (4248 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 >
4262 <MenuTrigger4259 <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 }
4264 >4268 >
4265 <span>{triggerLabel}</span>4269 <span>{triggerLabel}</span>
4266 <ChevronDownIcon aria-hidden="true" className="size-3 opacity-60" />4270 <ChevronDownIcon aria-hidden="true" className="size-3 opacity-60" />
...@@ -4272,6 +4276,7 @@ const CursorTraitsPicker = memo(function CursorTraitsPicker(props: {...@@ -4272,6 +4276,7 @@ const CursorTraitsPicker = memo(function CursorTraitsPicker(props: {
4272 <MenuRadioGroup4276 <MenuRadioGroup
4273 value={props.selection.reasoning}4277 value={props.selection.reasoning}
4274 onValueChange={(value) => {4278 onValueChange={(value) => {
4279 if (props.disabled) return;
4275 if (!value) return;4280 if (!value) return;
4276 const nextReasoning = CURSOR_REASONING_OPTIONS.find((option) => option === value);4281 const nextReasoning = CURSOR_REASONING_OPTIONS.find((option) => option === value);
4277 if (!nextReasoning) return;4282 if (!nextReasoning) return;
...@@ -4294,7 +4299,10 @@ const CursorTraitsPicker = memo(function CursorTraitsPicker(props: {...@@ -4294,7 +4299,10 @@ const CursorTraitsPicker = memo(function CursorTraitsPicker(props: {
4294 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Fast Mode</div>4299 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Fast Mode</div>
4295 <MenuRadioGroup4300 <MenuRadioGroup
4296 value={props.selection.fast ? "on" : "off"}4301 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 }}
4298 >4306 >
4299 <MenuRadioItem value="off">off</MenuRadioItem>4307 <MenuRadioItem value="off">off</MenuRadioItem>
4300 <MenuRadioItem value="on">on</MenuRadioItem>4308 <MenuRadioItem value="on">on</MenuRadioItem>
...@@ -4307,7 +4315,10 @@ const CursorTraitsPicker = memo(function CursorTraitsPicker(props: {...@@ -4307,7 +4315,10 @@ const CursorTraitsPicker = memo(function CursorTraitsPicker(props: {
4307 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Thinking</div>4315 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Thinking</div>
4308 <MenuRadioGroup4316 <MenuRadioGroup
4309 value={props.selection.thinking ? "on" : "off"}4317 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 }}
4311 >4322 >
4312 <MenuRadioItem value="off">off</MenuRadioItem>4323 <MenuRadioItem value="off">off</MenuRadioItem>
4313 <MenuRadioItem value="on">on</MenuRadioItem>4324 <MenuRadioItem value="on">on</MenuRadioItem>
apps/web/src/composerDraftStore.test.ts+29
...@@ -348,3 +348,32 @@ describe("composerDraftStore setModel", () => {...@@ -348,3 +348,32 @@ describe("composerDraftStore setModel", () => {
348 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.model).toBe("gpt-5.3-codex");348 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.model).toBe("gpt-5.3-codex");
349 });349 });
350});350});
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 {...@@ -5,6 +5,7 @@ import {
5 ThreadId,5 ThreadId,
6 normalizeModelSlug,6 normalizeModelSlug,
7 type CodexReasoningEffort,7 type CodexReasoningEffort,
8 type ProviderKind,
8} from "@t3tools/contracts";9} from "@t3tools/contracts";
9import type { ChatImageAttachment } from "./types";10import type { ChatImageAttachment } from "./types";
10import { create } from "zustand";11import { create } from "zustand";
...@@ -29,6 +30,7 @@ export interface ComposerImageAttachment extends Omit<ChatImageAttachment, "prev...@@ -29,6 +30,7 @@ export interface ComposerImageAttachment extends Omit<ChatImageAttachment, "prev
29interface PersistedComposerThreadDraftState {30interface PersistedComposerThreadDraftState {
30 prompt: string;31 prompt: string;
31 attachments: PersistedComposerImageAttachment[];32 attachments: PersistedComposerImageAttachment[];
33 provider?: ProviderKind | null;
32 model?: string | null;34 model?: string | null;
33 effort?: CodexReasoningEffort | null;35 effort?: CodexReasoningEffort | null;
34}36}
...@@ -52,6 +54,7 @@ interface ComposerThreadDraftState {...@@ -52,6 +54,7 @@ interface ComposerThreadDraftState {
52 images: ComposerImageAttachment[];54 images: ComposerImageAttachment[];
53 nonPersistedImageIds: string[];55 nonPersistedImageIds: string[];
54 persistedAttachments: PersistedComposerImageAttachment[];56 persistedAttachments: PersistedComposerImageAttachment[];
57 provider: ProviderKind | null;
55 model: string | null;58 model: string | null;
56 effort: CodexReasoningEffort | null;59 effort: CodexReasoningEffort | null;
57}60}
...@@ -98,6 +101,7 @@ interface ComposerDraftStoreState {...@@ -98,6 +101,7 @@ interface ComposerDraftStoreState {
98 clearProjectDraftThreadById: (projectId: ProjectId, threadId: ThreadId) => void;101 clearProjectDraftThreadById: (projectId: ProjectId, threadId: ThreadId) => void;
99 clearDraftThread: (threadId: ThreadId) => void;102 clearDraftThread: (threadId: ThreadId) => void;
100 setPrompt: (threadId: ThreadId, prompt: string) => void;103 setPrompt: (threadId: ThreadId, prompt: string) => void;
104 setProvider: (threadId: ThreadId, provider: ProviderKind | null | undefined) => void;
101 setModel: (threadId: ThreadId, model: string | null | undefined) => void;105 setModel: (threadId: ThreadId, model: string | null | undefined) => void;
102 setEffort: (threadId: ThreadId, effort: CodexReasoningEffort | null | undefined) => void;106 setEffort: (threadId: ThreadId, effort: CodexReasoningEffort | null | undefined) => void;
103 addImage: (threadId: ThreadId, image: ComposerImageAttachment) => void;107 addImage: (threadId: ThreadId, image: ComposerImageAttachment) => void;
...@@ -129,6 +133,7 @@ const EMPTY_THREAD_DRAFT = Object.freeze({...@@ -129,6 +133,7 @@ const EMPTY_THREAD_DRAFT = Object.freeze({
129 images: EMPTY_IMAGES,133 images: EMPTY_IMAGES,
130 nonPersistedImageIds: EMPTY_IDS,134 nonPersistedImageIds: EMPTY_IDS,
131 persistedAttachments: EMPTY_PERSISTED_ATTACHMENTS,135 persistedAttachments: EMPTY_PERSISTED_ATTACHMENTS,
136 provider: null,
132 model: null,137 model: null,
133 effort: null,138 effort: null,
134}) as ComposerThreadDraftState;139}) as ComposerThreadDraftState;
...@@ -143,6 +148,7 @@ function createEmptyThreadDraft(): ComposerThreadDraftState {...@@ -143,6 +148,7 @@ function createEmptyThreadDraft(): ComposerThreadDraftState {
143 images: [],148 images: [],
144 nonPersistedImageIds: [],149 nonPersistedImageIds: [],
145 persistedAttachments: [],150 persistedAttachments: [],
151 provider: null,
146 model: null,152 model: null,
147 effort: null,153 effort: null,
148 };154 };
...@@ -159,11 +165,16 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean {...@@ -159,11 +165,16 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean {
159 draft.prompt.length === 0 &&165 draft.prompt.length === 0 &&
160 draft.images.length === 0 &&166 draft.images.length === 0 &&
161 draft.persistedAttachments.length === 0 &&167 draft.persistedAttachments.length === 0 &&
168 draft.provider === null &&
162 draft.model === null &&169 draft.model === null &&
163 draft.effort === null170 draft.effort === null
164 );171 );
165}172}
166173
174function normalizeProviderKind(value: unknown): ProviderKind | null {
175 return value === "codex" || value === "claudeCode" || value === "cursor" ? value : null;
176}
177
167function revokeObjectPreviewUrl(previewUrl: string): void {178function revokeObjectPreviewUrl(previewUrl: string): void {
168 if (typeof URL === "undefined") {179 if (typeof URL === "undefined") {
169 return;180 return;
...@@ -307,20 +318,24 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer...@@ -307,20 +318,24 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer
307 return normalized ? [normalized] : [];318 return normalized ? [normalized] : [];
308 })319 })
309 : [];320 : [];
321 const provider = normalizeProviderKind(draftCandidate.provider);
310 const model =322 const model =
311 typeof draftCandidate.model === "string" ? normalizeModelSlug(draftCandidate.model) : null;323 typeof draftCandidate.model === "string"
324 ? normalizeModelSlug(draftCandidate.model, provider ?? "codex")
325 : null;
312 const effortCandidate =326 const effortCandidate =
313 typeof draftCandidate.effort === "string" ? draftCandidate.effort : null;327 typeof draftCandidate.effort === "string" ? draftCandidate.effort : null;
314 const effort =328 const effort =
315 effortCandidate && REASONING_EFFORT_VALUES.has(effortCandidate as CodexReasoningEffort)329 effortCandidate && REASONING_EFFORT_VALUES.has(effortCandidate as CodexReasoningEffort)
316 ? (effortCandidate as CodexReasoningEffort)330 ? (effortCandidate as CodexReasoningEffort)
317 : null;331 : null;
318 if (prompt.length === 0 && attachments.length === 0 && !model && !effort) {332 if (prompt.length === 0 && attachments.length === 0 && !provider && !model && !effort) {
319 continue;333 continue;
320 }334 }
321 nextDraftsByThreadId[threadId as ThreadId] = {335 nextDraftsByThreadId[threadId as ThreadId] = {
322 prompt,336 prompt,
323 attachments,337 attachments,
338 ...(provider ? { provider } : {}),
324 ...(model ? { model } : {}),339 ...(model ? { model } : {}),
325 ...(effort ? { effort } : {}),340 ...(effort ? { effort } : {}),
326 };341 };
...@@ -424,6 +439,7 @@ function toHydratedThreadDraft(...@@ -424,6 +439,7 @@ function toHydratedThreadDraft(
424 images: hydrateImagesFromPersisted(persistedDraft.attachments),439 images: hydrateImagesFromPersisted(persistedDraft.attachments),
425 nonPersistedImageIds: [],440 nonPersistedImageIds: [],
426 persistedAttachments: persistedDraft.attachments,441 persistedAttachments: persistedDraft.attachments,
442 provider: persistedDraft.provider ?? null,
427 model: persistedDraft.model ?? null,443 model: persistedDraft.model ?? null,
428 effort: persistedDraft.effort ?? null,444 effort: persistedDraft.effort ?? null,
429 };445 };
...@@ -676,6 +692,33 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -676,6 +692,33 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
676 return { draftsByThreadId: nextDraftsByThreadId };692 return { draftsByThreadId: nextDraftsByThreadId };
677 });693 });
678 },694 },
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 },
679 setModel: (threadId, model) => {722 setModel: (threadId, model) => {
680 if (threadId.length === 0) {723 if (threadId.length === 0) {
681 return;724 return;
...@@ -967,6 +1010,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -967,6 +1010,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
967 if (1010 if (
968 draft.prompt.length === 0 &&1011 draft.prompt.length === 0 &&
969 draft.persistedAttachments.length === 0 &&1012 draft.persistedAttachments.length === 0 &&
1013 draft.provider === null &&
970 draft.model === null &&1014 draft.model === null &&
971 draft.effort === null1015 draft.effort === null
972 ) {1016 ) {
...@@ -979,6 +1023,9 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -979,6 +1023,9 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
979 if (draft.model) {1023 if (draft.model) {
980 persistedDraft.model = draft.model;1024 persistedDraft.model = draft.model;
981 }1025 }
1026 if (draft.provider) {
1027 persistedDraft.provider = draft.provider;
1028 }
982 if (draft.effort) {1029 if (draft.effort) {
983 persistedDraft.effort = draft.effort;1030 persistedDraft.effort = draft.effort;
984 }1031 }
apps/web/src/session-logic.test.ts+26
...@@ -74,6 +74,32 @@ describe("derivePendingApprovals", () => {...@@ -74,6 +74,32 @@ describe("derivePendingApprovals", () => {
74 },74 },
75 ]);75 ]);
76 });76 });
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 });
77});103});
78104
79describe("deriveWorkLogEntries", () => {105describe("deriveWorkLogEntries", () => {
apps/web/src/session-logic.ts+17
...@@ -88,6 +88,21 @@ export function isLatestTurnSettled(...@@ -88,6 +88,21 @@ export function isLatestTurnSettled(
88 return true;88 return true;
89}89}
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
91export function derivePendingApprovals(106export function derivePendingApprovals(
92 activities: ReadonlyArray<OrchestrationThreadActivity>,107 activities: ReadonlyArray<OrchestrationThreadActivity>,
93): PendingApproval[] {108): PendingApproval[] {
...@@ -106,6 +121,8 @@ export function derivePendingApprovals(...@@ -106,6 +121,8 @@ export function derivePendingApprovals(
106 const requestKind =121 const requestKind =
107 payload && (payload.requestKind === "command" || payload.requestKind === "file-change")122 payload && (payload.requestKind === "command" || payload.requestKind === "file-change")
108 ? payload.requestKind123 ? payload.requestKind
124 : payload
125 ? requestKindFromRequestType(payload.requestType)
109 : null;126 : null;
110 const detail = payload && typeof payload.detail === "string" ? payload.detail : undefined;127 const detail = payload && typeof payload.detail === "string" ? payload.detail : undefined;
111128