| author | |
| committer | |
| log | 99a906753ec9c0f86eff6fe306062f0c2c3d15df |
| tree | 48d9a4af7013057cd6b3c8fe74139fc5a08a3d08 |
| parent | de5df2690de58406a480071eb2c7ba7e2fd4e3bd |
| signature | Commit is signed but in an unrecognized format. |
16 files changed, 560 insertions(+), 33 deletions(-)
README.md+15-3| ... | @@ -26,6 +26,17 @@ CodeThing is a desktop shell for coding agents. This first implementation is: | ... | @@ -26,6 +26,17 @@ CodeThing is a desktop shell for coding agents. This first implementation is: |
| 26 | - Renderer talks only to `window.nativeApi` exposed by preload. | 26 | - Renderer talks only to `window.nativeApi` exposed by preload. |
| 27 | - Preload and main both validate inputs using shared Zod schemas. | 27 | - Preload and main both validate inputs using shared Zod schemas. |
| 28 | 28 | ||
| 29 | `sandbox: true` above is Electron renderer sandboxing. It is separate from Codex execution sandbox policy (`read-only`, `workspace-write`, `danger-full-access`) used when starting provider sessions. | ||
| 30 | |||
| 31 | ## Runtime modes | ||
| 32 | |||
| 33 | CodeThing has a global runtime mode switch in the sidebar: | ||
| 34 | |||
| 35 | - `Full access` (default): starts new sessions with `approvalPolicy: never` and `sandboxMode: danger-full-access`. | ||
| 36 | - `Approval required`: starts new sessions with `approvalPolicy: on-request` and `sandboxMode: workspace-write`, then prompts in-app for command/file approvals. | ||
| 37 | |||
| 38 | Mode changes apply across all threads. Existing live sessions are restarted so old and new threads use the selected mode. | ||
| 39 | |||
| 29 | ## Scripts | 40 | ## Scripts |
| 30 | 41 | ||
| 31 | - `bun run dev`: starts contract build/watch, renderer dev server, and Electron process. | 42 | - `bun run dev`: starts contract build/watch, renderer dev server, and Electron process. |
| ... | @@ -47,8 +58,9 @@ The renderer now depends on `nativeApi.providers.*`: | ... | @@ -47,8 +58,9 @@ The renderer now depends on `nativeApi.providers.*`: |
| 47 | 1. `startSession` | 58 | 1. `startSession` |
| 48 | 2. `sendTurn` | 59 | 2. `sendTurn` |
| 49 | 3. `interruptTurn` | 60 | 3. `interruptTurn` |
| 50 | 4. `stopSession` | 61 | 4. `respondToRequest` |
| 51 | 5. `listSessions` | 62 | 5. `stopSession` |
| 52 | 6. `onEvent` | 63 | 6. `listSessions` |
| 64 | 7. `onEvent` | ||
| 53 | 65 | ||
| 54 | Codex is the only implemented provider right now. `claudeCode` is reserved in contracts/UI but returns a not-implemented error in main-process dispatch. | 66 | Codex is the only implemented provider right now. `claudeCode` is reserved in contracts/UI but returns a not-implemented error in main-process dispatch. |
TODO.md+2-4| ... | @@ -1,6 +1,4 @@ | ... | @@ -1,6 +1,4 @@ |
| 1 | ## TODO | 1 | ## TODO |
| 2 | 2 | ||
| 3 | - [x] Remove model picker from new project menu | 3 | - [ ] 1 click copy in codeblocks |
| 4 | - [x] CMD+Shift+O to create new thread in current project | 4 | - [ ] Pasting/adding images to submit with prompts |
| 5 | - [x] Remove project title pill from input box and topnav | ||
| 6 | - [x] Remove connection status from topnav |
apps/desktop/src/codexAppServerManager.ts+92-13| ... | @@ -4,7 +4,9 @@ import { EventEmitter } from "node:events"; | ... | @@ -4,7 +4,9 @@ import { EventEmitter } from "node:events"; |
| 4 | import readline from "node:readline"; | 4 | import readline from "node:readline"; |
| 5 | 5 | ||
| 6 | import type { | 6 | import type { |
| 7 | ProviderApprovalDecision, | ||
| 7 | ProviderEvent, | 8 | ProviderEvent, |
| 9 | ProviderRequestKind, | ||
| 8 | ProviderSendTurnInput, | 10 | ProviderSendTurnInput, |
| 9 | ProviderSession, | 11 | ProviderSession, |
| 10 | ProviderSessionStartInput, | 12 | ProviderSessionStartInput, |
| ... | @@ -21,11 +23,24 @@ interface PendingRequest { | ... | @@ -21,11 +23,24 @@ interface PendingRequest { |
| 21 | reject: (error: Error) => void; | 23 | reject: (error: Error) => void; |
| 22 | } | 24 | } |
| 23 | 25 | ||
| 26 | interface PendingApprovalRequest { | ||
| 27 | requestId: string; | ||
| 28 | jsonRpcId: string | number; | ||
| 29 | method: | ||
| 30 | | "item/commandExecution/requestApproval" | ||
| 31 | | "item/fileChange/requestApproval"; | ||
| 32 | requestKind: ProviderRequestKind; | ||
| 33 | threadId?: string; | ||
| 34 | turnId?: string; | ||
| 35 | itemId?: string; | ||
| 36 | } | ||
| 37 | |||
| 24 | interface CodexSessionContext { | 38 | interface CodexSessionContext { |
| 25 | session: ProviderSession; | 39 | session: ProviderSession; |
| 26 | child: ChildProcessWithoutNullStreams; | 40 | child: ChildProcessWithoutNullStreams; |
| 27 | output: readline.Interface; | 41 | output: readline.Interface; |
| 28 | pending: Map<PendingRequestKey, PendingRequest>; | 42 | pending: Map<PendingRequestKey, PendingRequest>; |
| 43 | pendingApprovals: Map<string, PendingApprovalRequest>; | ||
| 29 | nextRequestId: number; | 44 | nextRequestId: number; |
| 30 | stopping: boolean; | 45 | stopping: boolean; |
| 31 | } | 46 | } |
| ... | @@ -137,6 +152,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve | ... | @@ -137,6 +152,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 137 | child, | 152 | child, |
| 138 | output, | 153 | output, |
| 139 | pending: new Map(), | 154 | pending: new Map(), |
| 155 | pendingApprovals: new Map(), | ||
| 140 | nextRequestId: 1, | 156 | nextRequestId: 1, |
| 141 | stopping: false, | 157 | stopping: false, |
| 142 | }; | 158 | }; |
| ... | @@ -273,6 +289,45 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve | ... | @@ -273,6 +289,45 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 273 | }); | 289 | }); |
| 274 | } | 290 | } |
| 275 | 291 | ||
| 292 | async respondToRequest( | ||
| 293 | sessionId: string, | ||
| 294 | requestId: string, | ||
| 295 | decision: ProviderApprovalDecision, | ||
| 296 | ): Promise<void> { | ||
| 297 | const context = this.requireSession(sessionId); | ||
| 298 | const pendingRequest = context.pendingApprovals.get(requestId); | ||
| 299 | if (!pendingRequest) { | ||
| 300 | throw new Error(`Unknown pending approval request: ${requestId}`); | ||
| 301 | } | ||
| 302 | |||
| 303 | context.pendingApprovals.delete(requestId); | ||
| 304 | this.writeMessage(context, { | ||
| 305 | id: pendingRequest.jsonRpcId, | ||
| 306 | result: { | ||
| 307 | decision, | ||
| 308 | }, | ||
| 309 | }); | ||
| 310 | |||
| 311 | this.emitEvent({ | ||
| 312 | id: randomUUID(), | ||
| 313 | kind: "notification", | ||
| 314 | provider: "codex", | ||
| 315 | sessionId: context.session.sessionId, | ||
| 316 | createdAt: new Date().toISOString(), | ||
| 317 | method: "item/requestApproval/decision", | ||
| 318 | threadId: pendingRequest.threadId, | ||
| 319 | turnId: pendingRequest.turnId, | ||
| 320 | itemId: pendingRequest.itemId, | ||
| 321 | requestId: pendingRequest.requestId, | ||
| 322 | requestKind: pendingRequest.requestKind, | ||
| 323 | payload: { | ||
| 324 | requestId: pendingRequest.requestId, | ||
| 325 | requestKind: pendingRequest.requestKind, | ||
| 326 | decision, | ||
| 327 | }, | ||
| 328 | }); | ||
| 329 | } | ||
| 330 | |||
| 276 | stopSession(sessionId: string): void { | 331 | stopSession(sessionId: string): void { |
| 277 | const context = this.sessions.get(sessionId); | 332 | const context = this.sessions.get(sessionId); |
| 278 | if (!context) { | 333 | if (!context) { |
| ... | @@ -286,6 +341,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve | ... | @@ -286,6 +341,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 286 | pending.reject(new Error("Session stopped before request completed.")); | 341 | pending.reject(new Error("Session stopped before request completed.")); |
| 287 | } | 342 | } |
| 288 | context.pending.clear(); | 343 | context.pending.clear(); |
| 344 | context.pendingApprovals.clear(); | ||
| 289 | 345 | ||
| 290 | context.output.close(); | 346 | context.output.close(); |
| 291 | 347 | ||
| ... | @@ -498,6 +554,25 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve | ... | @@ -498,6 +554,25 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 498 | request: JsonRpcRequest, | 554 | request: JsonRpcRequest, |
| 499 | ): void { | 555 | ): void { |
| 500 | const route = this.readRouteFields(request.params); | 556 | const route = this.readRouteFields(request.params); |
| 557 | const requestKind = this.requestKindForMethod(request.method); | ||
| 558 | let requestId: string | undefined; | ||
| 559 | if (requestKind) { | ||
| 560 | requestId = randomUUID(); | ||
| 561 | const pendingRequest: PendingApprovalRequest = { | ||
| 562 | requestId, | ||
| 563 | jsonRpcId: request.id, | ||
| 564 | method: | ||
| 565 | requestKind === "command" | ||
| 566 | ? "item/commandExecution/requestApproval" | ||
| 567 | : "item/fileChange/requestApproval", | ||
| 568 | requestKind, | ||
| 569 | ...(route.threadId ? { threadId: route.threadId } : {}), | ||
| 570 | ...(route.turnId ? { turnId: route.turnId } : {}), | ||
| 571 | ...(route.itemId ? { itemId: route.itemId } : {}), | ||
| 572 | }; | ||
| 573 | context.pendingApprovals.set(requestId, pendingRequest); | ||
| 574 | } | ||
| 575 | |||
| 501 | this.emitEvent({ | 576 | this.emitEvent({ |
| 502 | id: randomUUID(), | 577 | id: randomUUID(), |
| 503 | kind: "request", | 578 | kind: "request", |
| ... | @@ -508,22 +583,12 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve | ... | @@ -508,22 +583,12 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 508 | threadId: route.threadId, | 583 | threadId: route.threadId, |
| 509 | turnId: route.turnId, | 584 | turnId: route.turnId, |
| 510 | itemId: route.itemId, | 585 | itemId: route.itemId, |
| 586 | requestId, | ||
| 587 | requestKind, | ||
| 511 | payload: request.params, | 588 | payload: request.params, |
| 512 | }); | 589 | }); |
| 513 | 590 | ||
| 514 | if (request.method === "item/commandExecution/requestApproval") { | 591 | if (requestKind) { |
| 515 | this.writeMessage(context, { | ||
| 516 | id: request.id, | ||
| 517 | result: { decision: "decline" }, | ||
| 518 | }); | ||
| 519 | return; | ||
| 520 | } | ||
| 521 | |||
| 522 | if (request.method === "item/fileChange/requestApproval") { | ||
| 523 | this.writeMessage(context, { | ||
| 524 | id: request.id, | ||
| 525 | result: { decision: "decline" }, | ||
| 526 | }); | ||
| 527 | return; | 592 | return; |
| 528 | } | 593 | } |
| 529 | 594 | ||
| ... | @@ -656,6 +721,20 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve | ... | @@ -656,6 +721,20 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 656 | }; | 721 | }; |
| 657 | } | 722 | } |
| 658 | 723 | ||
| 724 | private requestKindForMethod( | ||
| 725 | method: string, | ||
| 726 | ): ProviderRequestKind | undefined { | ||
| 727 | if (method === "item/commandExecution/requestApproval") { | ||
| 728 | return "command"; | ||
| 729 | } | ||
| 730 | |||
| 731 | if (method === "item/fileChange/requestApproval") { | ||
| 732 | return "file-change"; | ||
| 733 | } | ||
| 734 | |||
| 735 | return undefined; | ||
| 736 | } | ||
| 737 | |||
| 659 | private isServerRequest(value: unknown): value is JsonRpcRequest { | 738 | private isServerRequest(value: unknown): value is JsonRpcRequest { |
| 660 | if (!value || typeof value !== "object") { | 739 | if (!value || typeof value !== "object") { |
| 661 | return false; | 740 | return false; |
apps/desktop/src/main.ts+11| ... | @@ -13,6 +13,7 @@ import { | ... | @@ -13,6 +13,7 @@ import { |
| 13 | agentSessionIdSchema, | 13 | agentSessionIdSchema, |
| 14 | newTodoInputSchema, | 14 | newTodoInputSchema, |
| 15 | providerInterruptTurnInputSchema, | 15 | providerInterruptTurnInputSchema, |
| 16 | providerRespondToRequestInputSchema, | ||
| 16 | providerSendTurnInputSchema, | 17 | providerSendTurnInputSchema, |
| 17 | providerSessionStartInputSchema, | 18 | providerSessionStartInputSchema, |
| 18 | providerStopSessionInputSchema, | 19 | providerStopSessionInputSchema, |
| ... | @@ -175,6 +176,16 @@ function registerIpcHandlers(): void { | ... | @@ -175,6 +176,16 @@ function registerIpcHandlers(): void { |
| 175 | ), | 176 | ), |
| 176 | ); | 177 | ); |
| 177 | 178 | ||
| 179 | ipcMain.handle( | ||
| 180 | IPC_CHANNELS.providerRequestRespond, | ||
| 181 | withParsedPayload( | ||
| 182 | providerRespondToRequestInputSchema, | ||
| 183 | async (_event, payload) => { | ||
| 184 | await providerManager.respondToRequest(payload); | ||
| 185 | }, | ||
| 186 | ), | ||
| 187 | ); | ||
| 188 | |||
| 178 | ipcMain.handle( | 189 | ipcMain.handle( |
| 179 | IPC_CHANNELS.providerSessionStop, | 190 | IPC_CHANNELS.providerSessionStop, |
| 180 | withParsedPayload( | 191 | withParsedPayload( |
apps/desktop/src/preload.ts+2| ... | @@ -41,6 +41,8 @@ const nativeApi: NativeApi = { | ... | @@ -41,6 +41,8 @@ const nativeApi: NativeApi = { |
| 41 | ipcRenderer.invoke(IPC_CHANNELS.providerTurnStart, input), | 41 | ipcRenderer.invoke(IPC_CHANNELS.providerTurnStart, input), |
| 42 | interruptTurn: (input) => | 42 | interruptTurn: (input) => |
| 43 | ipcRenderer.invoke(IPC_CHANNELS.providerTurnInterrupt, input), | 43 | ipcRenderer.invoke(IPC_CHANNELS.providerTurnInterrupt, input), |
| 44 | respondToRequest: (input) => | ||
| 45 | ipcRenderer.invoke(IPC_CHANNELS.providerRequestRespond, input), | ||
| 44 | stopSession: (input) => | 46 | stopSession: (input) => |
| 45 | ipcRenderer.invoke(IPC_CHANNELS.providerSessionStop, input), | 47 | ipcRenderer.invoke(IPC_CHANNELS.providerSessionStop, input), |
| 46 | listSessions: () => ipcRenderer.invoke(IPC_CHANNELS.providerSessionList), | 48 | listSessions: () => ipcRenderer.invoke(IPC_CHANNELS.providerSessionList), |
apps/desktop/src/providerManager.test.ts+14| ... | @@ -48,4 +48,18 @@ describe("ProviderManager", () => { | ... | @@ -48,4 +48,18 @@ describe("ProviderManager", () => { |
| 48 | manager.dispose(); | 48 | manager.dispose(); |
| 49 | expect(() => manager.dispose()).not.toThrow(); | 49 | expect(() => manager.dispose()).not.toThrow(); |
| 50 | }); | 50 | }); |
| 51 | |||
| 52 | it("rejects request responses for unknown sessions", async () => { | ||
| 53 | const manager = new ProviderManager(); | ||
| 54 | |||
| 55 | await expect( | ||
| 56 | manager.respondToRequest({ | ||
| 57 | sessionId: "missing-session", | ||
| 58 | requestId: "req-1", | ||
| 59 | decision: "accept", | ||
| 60 | }), | ||
| 61 | ).rejects.toThrow("Unknown provider session: missing-session"); | ||
| 62 | |||
| 63 | manager.dispose(); | ||
| 64 | }); | ||
| 51 | }); | 65 | }); |
apps/desktop/src/providerManager.ts+15| ... | @@ -5,12 +5,14 @@ import path from "node:path"; | ... | @@ -5,12 +5,14 @@ import path from "node:path"; |
| 5 | import { | 5 | import { |
| 6 | type ProviderEvent, | 6 | type ProviderEvent, |
| 7 | type ProviderInterruptTurnInput, | 7 | type ProviderInterruptTurnInput, |
| 8 | type ProviderRespondToRequestInput, | ||
| 8 | type ProviderSendTurnInput, | 9 | type ProviderSendTurnInput, |
| 9 | type ProviderSession, | 10 | type ProviderSession, |
| 10 | type ProviderSessionStartInput, | 11 | type ProviderSessionStartInput, |
| 11 | type ProviderStopSessionInput, | 12 | type ProviderStopSessionInput, |
| 12 | type ProviderTurnStartResult, | 13 | type ProviderTurnStartResult, |
| 13 | providerInterruptTurnInputSchema, | 14 | providerInterruptTurnInputSchema, |
| 15 | providerRespondToRequestInputSchema, | ||
| 14 | providerSendTurnInputSchema, | 16 | providerSendTurnInputSchema, |
| 15 | providerSessionStartInputSchema, | 17 | providerSessionStartInputSchema, |
| 16 | providerStopSessionInputSchema, | 18 | providerStopSessionInputSchema, |
| ... | @@ -79,6 +81,19 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { | ... | @@ -79,6 +81,19 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { |
| 79 | await this.codex.interruptTurn(input.sessionId, input.turnId); | 81 | await this.codex.interruptTurn(input.sessionId, input.turnId); |
| 80 | } | 82 | } |
| 81 | 83 | ||
| 84 | async respondToRequest(raw: ProviderRespondToRequestInput): Promise<void> { | ||
| 85 | const input = providerRespondToRequestInputSchema.parse(raw); | ||
| 86 | if (!this.codex.hasSession(input.sessionId)) { | ||
| 87 | throw new Error(`Unknown provider session: ${input.sessionId}`); | ||
| 88 | } | ||
| 89 | |||
| 90 | await this.codex.respondToRequest( | ||
| 91 | input.sessionId, | ||
| 92 | input.requestId, | ||
| 93 | input.decision, | ||
| 94 | ); | ||
| 95 | } | ||
| 96 | |||
| 82 | stopSession(raw: ProviderStopSessionInput): void { | 97 | stopSession(raw: ProviderStopSessionInput): void { |
| 83 | const input = providerStopSessionInputSchema.parse(raw); | 98 | const input = providerStopSessionInputSchema.parse(raw); |
| 84 | this.codex.stopSession(input.sessionId); | 99 | this.codex.stopSession(input.sessionId); |
apps/renderer/src/components/ChatView.tsx+190| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import type { ProviderApprovalDecision, ProviderEvent } from "@acme/contracts"; | ||
| 1 | import { | 2 | import { |
| 2 | type FormEvent, | 3 | type FormEvent, |
| 3 | Fragment, | 4 | Fragment, |
| ... | @@ -39,6 +40,70 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { | ... | @@ -39,6 +40,70 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { |
| 39 | return "text-[#606060]"; | 40 | return "text-[#606060]"; |
| 40 | } | 41 | } |
| 41 | 42 | ||
| 43 | interface PendingApprovalCard { | ||
| 44 | requestId: string; | ||
| 45 | requestKind: "command" | "file-change"; | ||
| 46 | createdAt: string; | ||
| 47 | detail?: string; | ||
| 48 | } | ||
| 49 | |||
| 50 | function asRecord(value: unknown): Record<string, unknown> | undefined { | ||
| 51 | if (!value || typeof value !== "object") return undefined; | ||
| 52 | return value as Record<string, unknown>; | ||
| 53 | } | ||
| 54 | |||
| 55 | function asString(value: unknown): string | undefined { | ||
| 56 | return typeof value === "string" ? value : undefined; | ||
| 57 | } | ||
| 58 | |||
| 59 | function approvalDetail(event: ProviderEvent): string | undefined { | ||
| 60 | const payload = asRecord(event.payload); | ||
| 61 | const command = asString(payload?.command); | ||
| 62 | if (command) return command; | ||
| 63 | return asString(payload?.reason); | ||
| 64 | } | ||
| 65 | |||
| 66 | function derivePendingApprovals( | ||
| 67 | events: ProviderEvent[], | ||
| 68 | ): PendingApprovalCard[] { | ||
| 69 | const pending = new Map<string, PendingApprovalCard>(); | ||
| 70 | const ordered = [...events].reverse(); | ||
| 71 | |||
| 72 | for (const event of ordered) { | ||
| 73 | if ( | ||
| 74 | event.method === "session/closed" || | ||
| 75 | event.method === "session/exited" | ||
| 76 | ) { | ||
| 77 | pending.clear(); | ||
| 78 | continue; | ||
| 79 | } | ||
| 80 | |||
| 81 | const requestId = | ||
| 82 | event.requestId ?? asString(asRecord(event.payload)?.requestId); | ||
| 83 | if (!requestId) continue; | ||
| 84 | |||
| 85 | if ( | ||
| 86 | event.kind === "request" && | ||
| 87 | (event.requestKind === "command" || event.requestKind === "file-change") | ||
| 88 | ) { | ||
| 89 | const detail = approvalDetail(event); | ||
| 90 | pending.set(requestId, { | ||
| 91 | requestId, | ||
| 92 | requestKind: event.requestKind, | ||
| 93 | createdAt: event.createdAt, | ||
| 94 | ...(detail ? { detail } : {}), | ||
| 95 | }); | ||
| 96 | continue; | ||
| 97 | } | ||
| 98 | |||
| 99 | if (event.method === "item/requestApproval/decision") { | ||
| 100 | pending.delete(requestId); | ||
| 101 | } | ||
| 102 | } | ||
| 103 | |||
| 104 | return Array.from(pending.values()); | ||
| 105 | } | ||
| 106 | |||
| 42 | export default function ChatView() { | 107 | export default function ChatView() { |
| 43 | const { state, dispatch } = useStore(); | 108 | const { state, dispatch } = useStore(); |
| 44 | const api = useMemo(() => readNativeApi(), []); | 109 | const api = useMemo(() => readNativeApi(), []); |
| ... | @@ -48,6 +113,9 @@ export default function ChatView() { | ... | @@ -48,6 +113,9 @@ export default function ChatView() { |
| 48 | const [isModelMenuOpen, setIsModelMenuOpen] = useState(false); | 113 | const [isModelMenuOpen, setIsModelMenuOpen] = useState(false); |
| 49 | const [selectedEffort, setSelectedEffort] = | 114 | const [selectedEffort, setSelectedEffort] = |
| 50 | useState<string>(DEFAULT_REASONING); | 115 | useState<string>(DEFAULT_REASONING); |
| 116 | const [respondingRequestIds, setRespondingRequestIds] = useState<string[]>( | ||
| 117 | [], | ||
| 118 | ); | ||
| 51 | const [nowTick, setNowTick] = useState(() => Date.now()); | 119 | const [nowTick, setNowTick] = useState(() => Date.now()); |
| 52 | const messagesEndRef = useRef<HTMLDivElement>(null); | 120 | const messagesEndRef = useRef<HTMLDivElement>(null); |
| 53 | const textareaRef = useRef<HTMLTextAreaElement>(null); | 121 | const textareaRef = useRef<HTMLTextAreaElement>(null); |
| ... | @@ -68,6 +136,10 @@ export default function ChatView() { | ... | @@ -68,6 +136,10 @@ export default function ChatView() { |
| 68 | () => deriveWorkLogEntries(activeThread?.events ?? [], undefined), | 136 | () => deriveWorkLogEntries(activeThread?.events ?? [], undefined), |
| 69 | [activeThread?.events], | 137 | [activeThread?.events], |
| 70 | ); | 138 | ); |
| 139 | const pendingApprovals = useMemo( | ||
| 140 | () => derivePendingApprovals(activeThread?.events ?? []), | ||
| 141 | [activeThread?.events], | ||
| 142 | ); | ||
| 71 | const assistantCompletionByItemId = useMemo(() => { | 143 | const assistantCompletionByItemId = useMemo(() => { |
| 72 | const map = new Map<string, string>(); | 144 | const map = new Map<string, string>(); |
| 73 | const ordered = [...(activeThread?.events ?? [])].reverse(); | 145 | const ordered = [...(activeThread?.events ?? [])].reverse(); |
| ... | @@ -127,6 +199,16 @@ export default function ChatView() { | ... | @@ -127,6 +199,16 @@ export default function ChatView() { |
| 127 | timelineEntries, | 199 | timelineEntries, |
| 128 | workLogEntries.length, | 200 | workLogEntries.length, |
| 129 | ]); | 201 | ]); |
| 202 | const runtimeSessionConfig = | ||
| 203 | state.runtimeMode === "full-access" | ||
| 204 | ? ({ | ||
| 205 | approvalPolicy: "never", | ||
| 206 | sandboxMode: "danger-full-access", | ||
| 207 | } as const) | ||
| 208 | : ({ | ||
| 209 | approvalPolicy: "on-request", | ||
| 210 | sandboxMode: "workspace-write", | ||
| 211 | } as const); | ||
| 130 | 212 | ||
| 131 | // Auto-scroll on new messages | 213 | // Auto-scroll on new messages |
| 132 | const messageCount = activeThread?.messages.length ?? 0; | 214 | const messageCount = activeThread?.messages.length ?? 0; |
| ... | @@ -191,6 +273,8 @@ export default function ChatView() { | ... | @@ -191,6 +273,8 @@ export default function ChatView() { |
| 191 | provider: "codex", | 273 | provider: "codex", |
| 192 | cwd: activeProject.cwd || undefined, | 274 | cwd: activeProject.cwd || undefined, |
| 193 | model: selectedModel || undefined, | 275 | model: selectedModel || undefined, |
| 276 | approvalPolicy: runtimeSessionConfig.approvalPolicy, | ||
| 277 | sandboxMode: runtimeSessionConfig.sandboxMode, | ||
| 194 | }); | 278 | }); |
| 195 | dispatch({ | 279 | dispatch({ |
| 196 | type: "UPDATE_SESSION", | 280 | type: "UPDATE_SESSION", |
| ... | @@ -270,6 +354,37 @@ export default function ChatView() { | ... | @@ -270,6 +354,37 @@ export default function ChatView() { |
| 270 | }); | 354 | }); |
| 271 | }; | 355 | }; |
| 272 | 356 | ||
| 357 | const onRespondToApproval = async ( | ||
| 358 | requestId: string, | ||
| 359 | decision: ProviderApprovalDecision, | ||
| 360 | ) => { | ||
| 361 | if (!api || !activeThread?.session) return; | ||
| 362 | |||
| 363 | setRespondingRequestIds((existing) => | ||
| 364 | existing.includes(requestId) ? existing : [...existing, requestId], | ||
| 365 | ); | ||
| 366 | try { | ||
| 367 | await api.providers.respondToRequest({ | ||
| 368 | sessionId: activeThread.session.sessionId, | ||
| 369 | requestId, | ||
| 370 | decision, | ||
| 371 | }); | ||
| 372 | } catch (err) { | ||
| 373 | dispatch({ | ||
| 374 | type: "SET_ERROR", | ||
| 375 | threadId: activeThread.id, | ||
| 376 | error: | ||
| 377 | err instanceof Error | ||
| 378 | ? err.message | ||
| 379 | : "Failed to submit approval decision.", | ||
| 380 | }); | ||
| 381 | } finally { | ||
| 382 | setRespondingRequestIds((existing) => | ||
| 383 | existing.filter((id) => id !== requestId), | ||
| 384 | ); | ||
| 385 | } | ||
| 386 | }; | ||
| 387 | |||
| 273 | const onModelSelect = (model: string) => { | 388 | const onModelSelect = (model: string) => { |
| 274 | if (!activeThread) return; | 389 | if (!activeThread) return; |
| 275 | dispatch({ | 390 | dispatch({ |
| ... | @@ -335,6 +450,81 @@ export default function ChatView() { | ... | @@ -335,6 +450,81 @@ export default function ChatView() { |
| 335 | </div> | 450 | </div> |
| 336 | )} | 451 | )} |
| 337 | 452 | ||
| 453 | {pendingApprovals.length > 0 && ( | ||
| 454 | <div className="mx-4 mt-3 space-y-2"> | ||
| 455 | {pendingApprovals.map((approval) => { | ||
| 456 | const isResponding = respondingRequestIds.includes( | ||
| 457 | approval.requestId, | ||
| 458 | ); | ||
| 459 | return ( | ||
| 460 | <div | ||
| 461 | key={approval.requestId} | ||
| 462 | className="rounded-lg border border-amber-300/20 bg-amber-500/[0.07] px-3 py-2" | ||
| 463 | > | ||
| 464 | <p className="text-xs font-medium text-amber-100"> | ||
| 465 | {approval.requestKind === "command" | ||
| 466 | ? "Command approval requested" | ||
| 467 | : "File-change approval requested"} | ||
| 468 | </p> | ||
| 469 | {approval.detail && ( | ||
| 470 | <p | ||
| 471 | className="mt-1 truncate font-mono text-[11px] text-amber-100/75" | ||
| 472 | title={approval.detail} | ||
| 473 | > | ||
| 474 | {approval.detail} | ||
| 475 | </p> | ||
| 476 | )} | ||
| 477 | <div className="mt-2 flex flex-wrap gap-1.5"> | ||
| 478 | <button | ||
| 479 | type="button" | ||
| 480 | className="rounded-md border border-white/[0.15] bg-white/[0.08] px-2 py-1 text-[11px] text-[#e8e8e8] transition-colors duration-150 hover:bg-white/[0.13] disabled:cursor-not-allowed disabled:opacity-50" | ||
| 481 | disabled={isResponding} | ||
| 482 | onClick={() => | ||
| 483 | void onRespondToApproval(approval.requestId, "accept") | ||
| 484 | } | ||
| 485 | > | ||
| 486 | Approve once | ||
| 487 | </button> | ||
| 488 | <button | ||
| 489 | type="button" | ||
| 490 | className="rounded-md border border-sky-300/30 bg-sky-500/[0.15] px-2 py-1 text-[11px] text-sky-100 transition-colors duration-150 hover:bg-sky-500/[0.22] disabled:cursor-not-allowed disabled:opacity-50" | ||
| 491 | disabled={isResponding} | ||
| 492 | onClick={() => | ||
| 493 | void onRespondToApproval( | ||
| 494 | approval.requestId, | ||
| 495 | "acceptForSession", | ||
| 496 | ) | ||
| 497 | } | ||
| 498 | > | ||
| 499 | Always allow this session | ||
| 500 | </button> | ||
| 501 | <button | ||
| 502 | type="button" | ||
| 503 | className="rounded-md border border-white/[0.15] px-2 py-1 text-[11px] text-[#d8d8d8] transition-colors duration-150 hover:bg-white/[0.06] disabled:cursor-not-allowed disabled:opacity-50" | ||
| 504 | disabled={isResponding} | ||
| 505 | onClick={() => | ||
| 506 | void onRespondToApproval(approval.requestId, "decline") | ||
| 507 | } | ||
| 508 | > | ||
| 509 | Decline | ||
| 510 | </button> | ||
| 511 | <button | ||
| 512 | type="button" | ||
| 513 | className="rounded-md border border-rose-300/30 bg-rose-500/[0.12] px-2 py-1 text-[11px] text-rose-100 transition-colors duration-150 hover:bg-rose-500/[0.2] disabled:cursor-not-allowed disabled:opacity-50" | ||
| 514 | disabled={isResponding} | ||
| 515 | onClick={() => | ||
| 516 | void onRespondToApproval(approval.requestId, "cancel") | ||
| 517 | } | ||
| 518 | > | ||
| 519 | Cancel turn | ||
| 520 | </button> | ||
| 521 | </div> | ||
| 522 | </div> | ||
| 523 | ); | ||
| 524 | })} | ||
| 525 | </div> | ||
| 526 | )} | ||
| 527 | |||
| 338 | {/* Messages */} | 528 | {/* Messages */} |
| 339 | <div className="flex-1 overflow-y-auto px-5 py-4"> | 529 | <div className="flex-1 overflow-y-auto px-5 py-4"> |
| 340 | {activeThread.messages.length === 0 && !isWorking ? ( | 530 | {activeThread.messages.length === 0 && !isWorking ? ( |
apps/renderer/src/components/Sidebar.tsx+75| ... | @@ -28,6 +28,7 @@ export default function Sidebar() { | ... | @@ -28,6 +28,7 @@ export default function Sidebar() { |
| 28 | const [addingProject, setAddingProject] = useState(false); | 28 | const [addingProject, setAddingProject] = useState(false); |
| 29 | const [newCwd, setNewCwd] = useState(""); | 29 | const [newCwd, setNewCwd] = useState(""); |
| 30 | const [isPickingFolder, setIsPickingFolder] = useState(false); | 30 | const [isPickingFolder, setIsPickingFolder] = useState(false); |
| 31 | const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false); | ||
| 31 | 32 | ||
| 32 | const handleAddProject = () => { | 33 | const handleAddProject = () => { |
| 33 | const cwd = newCwd.trim(); | 34 | const cwd = newCwd.trim(); |
| ... | @@ -79,6 +80,43 @@ export default function Sidebar() { | ... | @@ -79,6 +80,43 @@ export default function Sidebar() { |
| 79 | } | 80 | } |
| 80 | }; | 81 | }; |
| 81 | 82 | ||
| 83 | const handleRuntimeModeChange = useCallback( | ||
| 84 | async (mode: "approval-required" | "full-access") => { | ||
| 85 | if (mode === state.runtimeMode) return; | ||
| 86 | |||
| 87 | dispatch({ | ||
| 88 | type: "SET_RUNTIME_MODE", | ||
| 89 | mode, | ||
| 90 | }); | ||
| 91 | |||
| 92 | if (!api) return; | ||
| 93 | |||
| 94 | const sessionIds = state.threads | ||
| 95 | .map((thread) => thread.session) | ||
| 96 | .filter( | ||
| 97 | (session): session is NonNullable<typeof session> => | ||
| 98 | session !== null && session.status !== "closed", | ||
| 99 | ) | ||
| 100 | .map((session) => session.sessionId); | ||
| 101 | |||
| 102 | if (sessionIds.length === 0) { | ||
| 103 | return; | ||
| 104 | } | ||
| 105 | |||
| 106 | setIsSwitchingRuntimeMode(true); | ||
| 107 | try { | ||
| 108 | await Promise.all( | ||
| 109 | sessionIds.map((sessionId) => | ||
| 110 | api.providers.stopSession({ sessionId }).catch(() => undefined), | ||
| 111 | ), | ||
| 112 | ); | ||
| 113 | } finally { | ||
| 114 | setIsSwitchingRuntimeMode(false); | ||
| 115 | } | ||
| 116 | }, | ||
| 117 | [api, dispatch, state.runtimeMode, state.threads], | ||
| 118 | ); | ||
| 119 | |||
| 82 | useEffect(() => { | 120 | useEffect(() => { |
| 83 | const onWindowKeyDown = (event: KeyboardEvent) => { | 121 | const onWindowKeyDown = (event: KeyboardEvent) => { |
| 84 | const isNewThreadShortcut = | 122 | const isNewThreadShortcut = |
| ... | @@ -151,6 +189,43 @@ export default function Sidebar() { | ... | @@ -151,6 +189,43 @@ export default function Sidebar() { |
| 151 | </button> | 189 | </button> |
| 152 | </div> | 190 | </div> |
| 153 | 191 | ||
| 192 | <div className="px-3 pb-3"> | ||
| 193 | <div className="rounded-lg border border-white/[0.08] bg-white/[0.03] p-2"> | ||
| 194 | <p className="px-1 text-[10px] font-medium uppercase tracking-[0.08em] text-[#a0a0a0]/60"> | ||
| 195 | Runtime mode | ||
| 196 | </p> | ||
| 197 | <p className="px-1 pt-0.5 pb-2 text-[10px] text-[#a0a0a0]/45"> | ||
| 198 | Switching restarts live sessions. | ||
| 199 | </p> | ||
| 200 | <div className="grid grid-cols-2 gap-1"> | ||
| 201 | <button | ||
| 202 | type="button" | ||
| 203 | className={`rounded-md px-2 py-1.5 text-[11px] transition-colors duration-150 ${ | ||
| 204 | state.runtimeMode === "approval-required" | ||
| 205 | ? "bg-white/12 text-white" | ||
| 206 | : "text-[#a0a0a0]/70 hover:bg-white/[0.06]" | ||
| 207 | }`} | ||
| 208 | disabled={isSwitchingRuntimeMode} | ||
| 209 | onClick={() => void handleRuntimeModeChange("approval-required")} | ||
| 210 | > | ||
| 211 | Approval required | ||
| 212 | </button> | ||
| 213 | <button | ||
| 214 | type="button" | ||
| 215 | className={`rounded-md px-2 py-1.5 text-[11px] transition-colors duration-150 ${ | ||
| 216 | state.runtimeMode === "full-access" | ||
| 217 | ? "bg-white/12 text-white" | ||
| 218 | : "text-[#a0a0a0]/70 hover:bg-white/[0.06]" | ||
| 219 | }`} | ||
| 220 | disabled={isSwitchingRuntimeMode} | ||
| 221 | onClick={() => void handleRuntimeModeChange("full-access")} | ||
| 222 | > | ||
| 223 | Full access | ||
| 224 | </button> | ||
| 225 | </div> | ||
| 226 | </div> | ||
| 227 | </div> | ||
| 228 | |||
| 154 | {/* Project list */} | 229 | {/* Project list */} |
| 155 | <nav className="flex-1 overflow-y-auto px-2"> | 230 | <nav className="flex-1 overflow-y-auto px-2"> |
| 156 | {state.projects.map((project) => { | 231 | {state.projects.map((project) => { |
apps/renderer/src/persistenceSchema.test.ts+27-2| ... | @@ -47,6 +47,7 @@ describe("hydratePersistedState", () => { | ... | @@ -47,6 +47,7 @@ describe("hydratePersistedState", () => { |
| 47 | expect(hydrated?.projects[0]?.model).toBe(DEFAULT_MODEL); | 47 | expect(hydrated?.projects[0]?.model).toBe(DEFAULT_MODEL); |
| 48 | expect(hydrated?.threads[0]?.model).toBe(DEFAULT_MODEL); | 48 | expect(hydrated?.threads[0]?.model).toBe(DEFAULT_MODEL); |
| 49 | expect(hydrated?.threads[0]?.messages[0]?.streaming).toBe(false); | 49 | expect(hydrated?.threads[0]?.messages[0]?.streaming).toBe(false); |
| 50 | expect(hydrated?.runtimeMode).toBe("full-access"); | ||
| 50 | }); | 51 | }); |
| 51 | 52 | ||
| 52 | it("filters unknown project references and repairs active thread", () => { | 53 | it("filters unknown project references and repairs active thread", () => { |
| ... | @@ -85,11 +86,33 @@ describe("hydratePersistedState", () => { | ... | @@ -85,11 +86,33 @@ describe("hydratePersistedState", () => { |
| 85 | expect(hydrated).not.toBeNull(); | 86 | expect(hydrated).not.toBeNull(); |
| 86 | expect(hydrated?.threads.map((thread) => thread.id)).toEqual(["t-1"]); | 87 | expect(hydrated?.threads.map((thread) => thread.id)).toEqual(["t-1"]); |
| 87 | expect(hydrated?.activeThreadId).toBe("t-1"); | 88 | expect(hydrated?.activeThreadId).toBe("t-1"); |
| 89 | expect(hydrated?.runtimeMode).toBe("full-access"); | ||
| 90 | }); | ||
| 91 | |||
| 92 | it("hydrates runtime mode from v3 payload", () => { | ||
| 93 | const payload = JSON.stringify({ | ||
| 94 | version: 3, | ||
| 95 | runtimeMode: "approval-required", | ||
| 96 | projects: [ | ||
| 97 | { | ||
| 98 | id: "p-1", | ||
| 99 | name: "Project", | ||
| 100 | cwd: "/tmp/project", | ||
| 101 | model: "gpt-5.3-codex", | ||
| 102 | expanded: false, | ||
| 103 | }, | ||
| 104 | ], | ||
| 105 | threads: [], | ||
| 106 | activeThreadId: null, | ||
| 107 | }); | ||
| 108 | |||
| 109 | const hydrated = hydratePersistedState(payload, false); | ||
| 110 | expect(hydrated?.runtimeMode).toBe("approval-required"); | ||
| 88 | }); | 111 | }); |
| 89 | }); | 112 | }); |
| 90 | 113 | ||
| 91 | describe("toPersistedState", () => { | 114 | describe("toPersistedState", () => { |
| 92 | it("writes v2 payload and strips non-persisted thread fields", () => { | 115 | it("writes v3 payload and strips non-persisted thread fields", () => { |
| 93 | const thread: Thread = { | 116 | const thread: Thread = { |
| 94 | id: "t-1", | 117 | id: "t-1", |
| 95 | projectId: "p-1", | 118 | projectId: "p-1", |
| ... | @@ -122,9 +145,11 @@ describe("toPersistedState", () => { | ... | @@ -122,9 +145,11 @@ describe("toPersistedState", () => { |
| 122 | ], | 145 | ], |
| 123 | threads: [thread], | 146 | threads: [thread], |
| 124 | activeThreadId: "t-1", | 147 | activeThreadId: "t-1", |
| 148 | runtimeMode: "full-access", | ||
| 125 | }); | 149 | }); |
| 126 | 150 | ||
| 127 | expect(persisted.version).toBe(2); | 151 | expect(persisted.version).toBe(3); |
| 152 | expect(persisted.runtimeMode).toBe("full-access"); | ||
| 128 | expect(persisted.threads[0]).toEqual({ | 153 | expect(persisted.threads[0]).toEqual({ |
| 129 | id: "t-1", | 154 | id: "t-1", |
| 130 | projectId: "p-1", | 155 | projectId: "p-1", |
apps/renderer/src/persistenceSchema.ts+22-3| ... | @@ -1,7 +1,12 @@ | ... | @@ -1,7 +1,12 @@ |
| 1 | import { z } from "zod"; | 1 | import { z } from "zod"; |
| 2 | 2 | ||
| 3 | import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic"; | 3 | import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic"; |
| 4 | import type { Project, Thread } from "./types"; | 4 | import { |
| 5 | DEFAULT_RUNTIME_MODE, | ||
| 6 | type Project, | ||
| 7 | type RuntimeMode, | ||
| 8 | type Thread, | ||
| 9 | } from "./types"; | ||
| 5 | 10 | ||
| 6 | const LEGACY_DEFAULT_MODEL = "gpt-5.2-codex"; | 11 | const LEGACY_DEFAULT_MODEL = "gpt-5.2-codex"; |
| 7 | 12 | ||
| ... | @@ -36,6 +41,8 @@ const persistedStateBodySchema = z.object({ | ... | @@ -36,6 +41,8 @@ const persistedStateBodySchema = z.object({ |
| 36 | activeThreadId: z.string().min(1).nullable(), | 41 | activeThreadId: z.string().min(1).nullable(), |
| 37 | }); | 42 | }); |
| 38 | 43 | ||
| 44 | const runtimeModeSchema = z.enum(["approval-required", "full-access"]); | ||
| 45 | |||
| 39 | export const persistedStateV1Schema = persistedStateBodySchema.extend({ | 46 | export const persistedStateV1Schema = persistedStateBodySchema.extend({ |
| 40 | version: z.literal(1).optional(), | 47 | version: z.literal(1).optional(), |
| 41 | }); | 48 | }); |
| ... | @@ -44,7 +51,13 @@ export const persistedStateV2Schema = persistedStateBodySchema.extend({ | ... | @@ -44,7 +51,13 @@ export const persistedStateV2Schema = persistedStateBodySchema.extend({ |
| 44 | version: z.literal(2).optional(), | 51 | version: z.literal(2).optional(), |
| 45 | }); | 52 | }); |
| 46 | 53 | ||
| 54 | export const persistedStateV3Schema = persistedStateBodySchema.extend({ | ||
| 55 | runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE), | ||
| 56 | version: z.literal(3).optional(), | ||
| 57 | }); | ||
| 58 | |||
| 47 | const persistedStateSchema = z.union([ | 59 | const persistedStateSchema = z.union([ |
| 60 | persistedStateV3Schema, | ||
| 48 | persistedStateV2Schema, | 61 | persistedStateV2Schema, |
| 49 | persistedStateV1Schema, | 62 | persistedStateV1Schema, |
| 50 | ]); | 63 | ]); |
| ... | @@ -53,6 +66,7 @@ export interface PersistedStoreSnapshot { | ... | @@ -53,6 +66,7 @@ export interface PersistedStoreSnapshot { |
| 53 | projects: Project[]; | 66 | projects: Project[]; |
| 54 | threads: Thread[]; | 67 | threads: Thread[]; |
| 55 | activeThreadId: string | null; | 68 | activeThreadId: string | null; |
| 69 | runtimeMode: RuntimeMode; | ||
| 56 | } | 70 | } |
| 57 | 71 | ||
| 58 | function maybeMigrateLegacyModel( | 72 | function maybeMigrateLegacyModel( |
| ... | @@ -134,14 +148,18 @@ export function hydratePersistedState( | ... | @@ -134,14 +148,18 @@ export function hydratePersistedState( |
| 134 | activeThreadId: hasActiveThread | 148 | activeThreadId: hasActiveThread |
| 135 | ? parsedState.data.activeThreadId | 149 | ? parsedState.data.activeThreadId |
| 136 | : (threads[0]?.id ?? null), | 150 | : (threads[0]?.id ?? null), |
| 151 | runtimeMode: | ||
| 152 | "runtimeMode" in parsedState.data | ||
| 153 | ? parsedState.data.runtimeMode | ||
| 154 | : DEFAULT_RUNTIME_MODE, | ||
| 137 | }; | 155 | }; |
| 138 | } | 156 | } |
| 139 | 157 | ||
| 140 | export function toPersistedState( | 158 | export function toPersistedState( |
| 141 | state: PersistedStoreSnapshot, | 159 | state: PersistedStoreSnapshot, |
| 142 | ): z.infer<typeof persistedStateV2Schema> { | 160 | ): z.infer<typeof persistedStateV3Schema> { |
| 143 | return { | 161 | return { |
| 144 | version: 2, | 162 | version: 3, |
| 145 | projects: state.projects, | 163 | projects: state.projects, |
| 146 | threads: state.threads.map((thread) => ({ | 164 | threads: state.threads.map((thread) => ({ |
| 147 | id: thread.id, | 165 | id: thread.id, |
| ... | @@ -152,5 +170,6 @@ export function toPersistedState( | ... | @@ -152,5 +170,6 @@ export function toPersistedState( |
| 152 | createdAt: thread.createdAt, | 170 | createdAt: thread.createdAt, |
| 153 | })), | 171 | })), |
| 154 | activeThreadId: state.activeThreadId, | 172 | activeThreadId: state.activeThreadId, |
| 173 | runtimeMode: state.runtimeMode, | ||
| 155 | }; | 174 | }; |
| 156 | } | 175 | } |
apps/renderer/src/store.ts+29-8| ... | @@ -17,7 +17,12 @@ import { | ... | @@ -17,7 +17,12 @@ import { |
| 17 | asString, | 17 | asString, |
| 18 | evolveSession, | 18 | evolveSession, |
| 19 | } from "./session-logic"; | 19 | } from "./session-logic"; |
| 20 | import type { Project, Thread } from "./types"; | 20 | import { |
| 21 | DEFAULT_RUNTIME_MODE, | ||
| 22 | type Project, | ||
| 23 | type RuntimeMode, | ||
| 24 | type Thread, | ||
| 25 | } from "./types"; | ||
| 21 | 26 | ||
| 22 | // ── Actions ────────────────────────────────────────────────────────── | 27 | // ── Actions ────────────────────────────────────────────────────────── |
| 23 | 28 | ||
| ... | @@ -36,7 +41,8 @@ type Action = | ... | @@ -36,7 +41,8 @@ type Action = |
| 36 | | { type: "PUSH_USER_MESSAGE"; threadId: string; id: string; text: string } | 41 | | { type: "PUSH_USER_MESSAGE"; threadId: string; id: string; text: string } |
| 37 | | { type: "SET_ERROR"; threadId: string; error: string | null } | 42 | | { type: "SET_ERROR"; threadId: string; error: string | null } |
| 38 | | { type: "SET_THREAD_TITLE"; threadId: string; title: string } | 43 | | { type: "SET_THREAD_TITLE"; threadId: string; title: string } |
| 39 | | { type: "SET_THREAD_MODEL"; threadId: string; model: string }; | 44 | | { type: "SET_THREAD_MODEL"; threadId: string; model: string } |
| 45 | | { type: "SET_RUNTIME_MODE"; mode: RuntimeMode }; | ||
| 40 | 46 | ||
| 41 | // ── State ──────────────────────────────────────────────────────────── | 47 | // ── State ──────────────────────────────────────────────────────────── |
| 42 | 48 | ||
| ... | @@ -44,16 +50,21 @@ export interface AppState { | ... | @@ -44,16 +50,21 @@ export interface AppState { |
| 44 | projects: Project[]; | 50 | projects: Project[]; |
| 45 | threads: Thread[]; | 51 | threads: Thread[]; |
| 46 | activeThreadId: string | null; | 52 | activeThreadId: string | null; |
| 53 | runtimeMode: RuntimeMode; | ||
| 47 | diffOpen: boolean; | 54 | diffOpen: boolean; |
| 48 | } | 55 | } |
| 49 | 56 | ||
| 50 | const PERSISTED_STATE_KEY = "codething:renderer-state:v2"; | 57 | const PERSISTED_STATE_KEY = "codething:renderer-state:v3"; |
| 51 | const LEGACY_PERSISTED_STATE_KEY = "codething:renderer-state:v1"; | 58 | const LEGACY_PERSISTED_STATE_KEYS = [ |
| 59 | "codething:renderer-state:v2", | ||
| 60 | "codething:renderer-state:v1", | ||
| 61 | ] as const; | ||
| 52 | 62 | ||
| 53 | const initialState: AppState = { | 63 | const initialState: AppState = { |
| 54 | projects: [], | 64 | projects: [], |
| 55 | threads: [], | 65 | threads: [], |
| 56 | activeThreadId: null, | 66 | activeThreadId: null, |
| 67 | runtimeMode: DEFAULT_RUNTIME_MODE, | ||
| 57 | diffOpen: false, | 68 | diffOpen: false, |
| 58 | }; | 69 | }; |
| 59 | 70 | ||
| ... | @@ -64,12 +75,14 @@ function readPersistedState(): AppState { | ... | @@ -64,12 +75,14 @@ function readPersistedState(): AppState { |
| 64 | 75 | ||
| 65 | try { | 76 | try { |
| 66 | const rawCurrent = window.localStorage.getItem(PERSISTED_STATE_KEY); | 77 | const rawCurrent = window.localStorage.getItem(PERSISTED_STATE_KEY); |
| 67 | const rawLegacy = window.localStorage.getItem(LEGACY_PERSISTED_STATE_KEY); | 78 | const [legacyV2Key, legacyV1Key] = LEGACY_PERSISTED_STATE_KEYS; |
| 68 | const raw = rawCurrent ?? rawLegacy; | 79 | const rawLegacyV2 = window.localStorage.getItem(legacyV2Key); |
| 80 | const rawLegacyV1 = window.localStorage.getItem(legacyV1Key); | ||
| 81 | const raw = rawCurrent ?? rawLegacyV2 ?? rawLegacyV1; | ||
| 69 | if (!raw) return initialState; | 82 | if (!raw) return initialState; |
| 70 | const hydrated = hydratePersistedState( | 83 | const hydrated = hydratePersistedState( |
| 71 | raw, | 84 | raw, |
| 72 | !rawCurrent && Boolean(rawLegacy), | 85 | !rawCurrent && !rawLegacyV2 && Boolean(rawLegacyV1), |
| 73 | ); | 86 | ); |
| 74 | if (!hydrated) return initialState; | 87 | if (!hydrated) return initialState; |
| 75 | 88 | ||
| ... | @@ -87,7 +100,9 @@ function persistState(state: AppState): void { | ... | @@ -87,7 +100,9 @@ function persistState(state: AppState): void { |
| 87 | PERSISTED_STATE_KEY, | 100 | PERSISTED_STATE_KEY, |
| 88 | JSON.stringify(toPersistedState(state)), | 101 | JSON.stringify(toPersistedState(state)), |
| 89 | ); | 102 | ); |
| 90 | window.localStorage.removeItem(LEGACY_PERSISTED_STATE_KEY); | 103 | for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) { |
| 104 | window.localStorage.removeItem(legacyKey); | ||
| 105 | } | ||
| 91 | } catch { | 106 | } catch { |
| 92 | // Ignore quota/storage errors to avoid breaking chat UX. | 107 | // Ignore quota/storage errors to avoid breaking chat UX. |
| 93 | } | 108 | } |
| ... | @@ -285,6 +300,12 @@ function reducer(state: AppState, action: Action): AppState { | ... | @@ -285,6 +300,12 @@ function reducer(state: AppState, action: Action): AppState { |
| 285 | })), | 300 | })), |
| 286 | }; | 301 | }; |
| 287 | 302 | ||
| 303 | case "SET_RUNTIME_MODE": | ||
| 304 | return { | ||
| 305 | ...state, | ||
| 306 | runtimeMode: action.mode, | ||
| 307 | }; | ||
| 308 | |||
| 288 | default: | 309 | default: |
| 289 | return state; | 310 | return state; |
| 290 | } | 311 | } |
apps/renderer/src/types.ts+2| ... | @@ -1,6 +1,8 @@ | ... | @@ -1,6 +1,8 @@ |
| 1 | import type { ProviderEvent, ProviderSession } from "@acme/contracts"; | 1 | import type { ProviderEvent, ProviderSession } from "@acme/contracts"; |
| 2 | 2 | ||
| 3 | export type SessionPhase = "disconnected" | "connecting" | "ready" | "running"; | 3 | export type SessionPhase = "disconnected" | "connecting" | "ready" | "running"; |
| 4 | export type RuntimeMode = "approval-required" | "full-access"; | ||
| 5 | export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; | ||
| 4 | 6 | ||
| 5 | export interface ChatMessage { | 7 | export interface ChatMessage { |
| 6 | id: string; | 8 | id: string; |
packages/contracts/src/ipc.ts+3| ... | @@ -2,6 +2,7 @@ import type { AgentConfig, AgentExit, OutputChunk } from "./agent"; | ... | @@ -2,6 +2,7 @@ import type { AgentConfig, AgentExit, OutputChunk } from "./agent"; |
| 2 | import type { | 2 | import type { |
| 3 | ProviderEvent, | 3 | ProviderEvent, |
| 4 | ProviderInterruptTurnInput, | 4 | ProviderInterruptTurnInput, |
| 5 | ProviderRespondToRequestInput, | ||
| 5 | ProviderSendTurnInput, | 6 | ProviderSendTurnInput, |
| 6 | ProviderSession, | 7 | ProviderSession, |
| 7 | ProviderSessionStartInput, | 8 | ProviderSessionStartInput, |
| ... | @@ -28,6 +29,7 @@ export const IPC_CHANNELS = { | ... | @@ -28,6 +29,7 @@ export const IPC_CHANNELS = { |
| 28 | providerTurnInterrupt: "provider:turn:interrupt", | 29 | providerTurnInterrupt: "provider:turn:interrupt", |
| 29 | providerSessionStop: "provider:session:stop", | 30 | providerSessionStop: "provider:session:stop", |
| 30 | providerSessionList: "provider:session:list", | 31 | providerSessionList: "provider:session:list", |
| 32 | providerRequestRespond: "provider:request:respond", | ||
| 31 | providerEvent: "provider:event", | 33 | providerEvent: "provider:event", |
| 32 | } as const; | 34 | } as const; |
| 33 | 35 | ||
| ... | @@ -59,6 +61,7 @@ export interface NativeApi { | ... | @@ -59,6 +61,7 @@ export interface NativeApi { |
| 59 | input: ProviderSendTurnInput, | 61 | input: ProviderSendTurnInput, |
| 60 | ) => Promise<ProviderTurnStartResult>; | 62 | ) => Promise<ProviderTurnStartResult>; |
| 61 | interruptTurn: (input: ProviderInterruptTurnInput) => Promise<void>; | 63 | interruptTurn: (input: ProviderInterruptTurnInput) => Promise<void>; |
| 64 | respondToRequest: (input: ProviderRespondToRequestInput) => Promise<void>; | ||
| 62 | stopSession: (input: ProviderStopSessionInput) => Promise<void>; | 65 | stopSession: (input: ProviderStopSessionInput) => Promise<void>; |
| 63 | listSessions: () => Promise<ProviderSession[]>; | 66 | listSessions: () => Promise<ProviderSession[]>; |
| 64 | onEvent: (callback: (event: ProviderEvent) => void) => () => void; | 67 | onEvent: (callback: (event: ProviderEvent) => void) => () => void; |
packages/contracts/src/provider.test.ts+37| ... | @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; | ... | @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 4 | providerEventSchema, | 4 | providerEventSchema, |
| 5 | providerRespondToRequestInputSchema, | ||
| 5 | providerSendTurnInputSchema, | 6 | providerSendTurnInputSchema, |
| 6 | providerSessionStartInputSchema, | 7 | providerSessionStartInputSchema, |
| 7 | } from "./provider"; | 8 | } from "./provider"; |
| ... | @@ -45,4 +46,40 @@ describe("providerEventSchema", () => { | ... | @@ -45,4 +46,40 @@ describe("providerEventSchema", () => { |
| 45 | }); | 46 | }); |
| 46 | expect(parsed.method).toBe("item/agentMessage/delta"); | 47 | expect(parsed.method).toBe("item/agentMessage/delta"); |
| 47 | }); | 48 | }); |
| 49 | |||
| 50 | it("accepts request approval metadata", () => { | ||
| 51 | const parsed = providerEventSchema.parse({ | ||
| 52 | id: "evt_2", | ||
| 53 | kind: "request", | ||
| 54 | provider: "codex", | ||
| 55 | sessionId: "sess_1", | ||
| 56 | createdAt: "2026-01-01T00:00:00.000Z", | ||
| 57 | method: "item/commandExecution/requestApproval", | ||
| 58 | requestId: "req_123", | ||
| 59 | requestKind: "command", | ||
| 60 | }); | ||
| 61 | expect(parsed.requestId).toBe("req_123"); | ||
| 62 | expect(parsed.requestKind).toBe("command"); | ||
| 63 | }); | ||
| 64 | }); | ||
| 65 | |||
| 66 | describe("providerRespondToRequestInputSchema", () => { | ||
| 67 | it("accepts valid decisions", () => { | ||
| 68 | const parsed = providerRespondToRequestInputSchema.parse({ | ||
| 69 | sessionId: "sess_1", | ||
| 70 | requestId: "req_1", | ||
| 71 | decision: "acceptForSession", | ||
| 72 | }); | ||
| 73 | expect(parsed.decision).toBe("acceptForSession"); | ||
| 74 | }); | ||
| 75 | |||
| 76 | it("rejects unknown decisions", () => { | ||
| 77 | expect(() => | ||
| 78 | providerRespondToRequestInputSchema.parse({ | ||
| 79 | sessionId: "sess_1", | ||
| 80 | requestId: "req_1", | ||
| 81 | decision: "always", | ||
| 82 | }), | ||
| 83 | ).toThrow(); | ||
| 84 | }); | ||
| 48 | }); | 85 | }); |
packages/contracts/src/provider.ts+24| ... | @@ -15,6 +15,15 @@ export const providerSandboxModeSchema = z.enum([ | ... | @@ -15,6 +15,15 @@ export const providerSandboxModeSchema = z.enum([ |
| 15 | "danger-full-access", | 15 | "danger-full-access", |
| 16 | ]); | 16 | ]); |
| 17 | 17 | ||
| 18 | export const providerRequestKindSchema = z.enum(["command", "file-change"]); | ||
| 19 | |||
| 20 | export const providerApprovalDecisionSchema = z.enum([ | ||
| 21 | "accept", | ||
| 22 | "acceptForSession", | ||
| 23 | "decline", | ||
| 24 | "cancel", | ||
| 25 | ]); | ||
| 26 | |||
| 18 | export const providerSessionStatusSchema = z.enum([ | 27 | export const providerSessionStatusSchema = z.enum([ |
| 19 | "connecting", | 28 | "connecting", |
| 20 | "ready", | 29 | "ready", |
| ... | @@ -65,6 +74,12 @@ export const providerStopSessionInputSchema = z.object({ | ... | @@ -65,6 +74,12 @@ export const providerStopSessionInputSchema = z.object({ |
| 65 | sessionId: z.string().min(1), | 74 | sessionId: z.string().min(1), |
| 66 | }); | 75 | }); |
| 67 | 76 | ||
| 77 | export const providerRespondToRequestInputSchema = z.object({ | ||
| 78 | sessionId: z.string().min(1), | ||
| 79 | requestId: z.string().min(1), | ||
| 80 | decision: providerApprovalDecisionSchema, | ||
| 81 | }); | ||
| 82 | |||
| 68 | export const providerEventKindSchema = z.enum([ | 83 | export const providerEventKindSchema = z.enum([ |
| 69 | "session", | 84 | "session", |
| 70 | "notification", | 85 | "notification", |
| ... | @@ -83,6 +98,8 @@ export const providerEventSchema = z.object({ | ... | @@ -83,6 +98,8 @@ export const providerEventSchema = z.object({ |
| 83 | threadId: z.string().min(1).optional(), | 98 | threadId: z.string().min(1).optional(), |
| 84 | turnId: z.string().min(1).optional(), | 99 | turnId: z.string().min(1).optional(), |
| 85 | itemId: z.string().min(1).optional(), | 100 | itemId: z.string().min(1).optional(), |
| 101 | requestId: z.string().min(1).optional(), | ||
| 102 | requestKind: providerRequestKindSchema.optional(), | ||
| 86 | textDelta: z.string().optional(), | 103 | textDelta: z.string().optional(), |
| 87 | payload: z.unknown().optional(), | 104 | payload: z.unknown().optional(), |
| 88 | }); | 105 | }); |
| ... | @@ -92,6 +109,10 @@ export type ProviderApprovalPolicy = z.infer< | ... | @@ -92,6 +109,10 @@ export type ProviderApprovalPolicy = z.infer< |
| 92 | typeof providerApprovalPolicySchema | 109 | typeof providerApprovalPolicySchema |
| 93 | >; | 110 | >; |
| 94 | export type ProviderSandboxMode = z.infer<typeof providerSandboxModeSchema>; | 111 | export type ProviderSandboxMode = z.infer<typeof providerSandboxModeSchema>; |
| 112 | export type ProviderRequestKind = z.infer<typeof providerRequestKindSchema>; | ||
| 113 | export type ProviderApprovalDecision = z.infer< | ||
| 114 | typeof providerApprovalDecisionSchema | ||
| 115 | >; | ||
| 95 | export type ProviderSessionStatus = z.infer<typeof providerSessionStatusSchema>; | 116 | export type ProviderSessionStatus = z.infer<typeof providerSessionStatusSchema>; |
| 96 | export type ProviderSession = z.infer<typeof providerSessionSchema>; | 117 | export type ProviderSession = z.infer<typeof providerSessionSchema>; |
| 97 | export type ProviderSessionStartInput = z.input< | 118 | export type ProviderSessionStartInput = z.input< |
| ... | @@ -107,5 +128,8 @@ export type ProviderInterruptTurnInput = z.input< | ... | @@ -107,5 +128,8 @@ export type ProviderInterruptTurnInput = z.input< |
| 107 | export type ProviderStopSessionInput = z.input< | 128 | export type ProviderStopSessionInput = z.input< |
| 108 | typeof providerStopSessionInputSchema | 129 | typeof providerStopSessionInputSchema |
| 109 | >; | 130 | >; |
| 131 | export type ProviderRespondToRequestInput = z.input< | ||
| 132 | typeof providerRespondToRequestInputSchema | ||
| 133 | >; | ||
| 110 | export type ProviderEventKind = z.infer<typeof providerEventKindSchema>; | 134 | export type ProviderEventKind = z.infer<typeof providerEventKindSchema>; |
| 111 | export type ProviderEvent = z.infer<typeof providerEventSchema>; | 135 | export type ProviderEvent = z.infer<typeof providerEventSchema>; |