| author | |
| committer | |
| log | 0150c6a53b409ba3bcb45645709b649cf8708354 |
| tree | a5602edb54156195c884c0189570dae4d31c2eff |
| parent | c0dded88b1fc8c1c86d65f684fbfa520139e58dc |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>19 files changed, 814 insertions(+), 56 deletions(-)
apps/server/src/environment/ServerEnvironment.test.ts+1| ... | @@ -170,6 +170,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { | ... | @@ -170,6 +170,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { |
| 170 | expect(second.capabilities.attachmentUploads).toBe(true); | 170 | expect(second.capabilities.attachmentUploads).toBe(true); |
| 171 | expect(second.capabilities.fileAttachments).toEqual({ maxUploadBytes: 50 * 1024 * 1024 }); | 171 | expect(second.capabilities.fileAttachments).toEqual({ maxUploadBytes: 50 * 1024 * 1024 }); |
| 172 | expect(second.capabilities.pullRequests).toBe(true); | 172 | expect(second.capabilities.pullRequests).toBe(true); |
| 173 | expect(second.capabilities.requiredWorktreeBootstrap).toBe(true); | ||
| 173 | expect(second.capabilities.usagePriceOverrides).toBe(true); | 174 | expect(second.capabilities.usagePriceOverrides).toBe(true); |
| 174 | expect(second.capabilities.threadActiveReorder).toBe(true); | 175 | expect(second.capabilities.threadActiveReorder).toBe(true); |
| 175 | expect(second.capabilities.threadTitleRegeneration).toBe(true); | 176 | expect(second.capabilities.threadTitleRegeneration).toBe(true); |
apps/server/src/environment/ServerEnvironment.ts+1| ... | @@ -222,6 +222,7 @@ export const make = Effect.gen(function* () { | ... | @@ -222,6 +222,7 @@ export const make = Effect.gen(function* () { |
| 222 | fileAttachments: { maxUploadBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES }, | 222 | fileAttachments: { maxUploadBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES }, |
| 223 | pullRequests: true, | 223 | pullRequests: true, |
| 224 | inlineMessageContext: true, | 224 | inlineMessageContext: true, |
| 225 | requiredWorktreeBootstrap: true, | ||
| 225 | threadSettlement: true, | 226 | threadSettlement: true, |
| 226 | threadAutoSettlement: true, | 227 | threadAutoSettlement: true, |
| 227 | storageCleanup: true, | 228 | storageCleanup: true, |
apps/server/src/server.test.ts+96| ... | @@ -11184,6 +11184,102 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -11184,6 +11184,102 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 11184 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 11184 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 11185 | ); | 11185 | ); |
| 11186 | 11186 | ||
| 11187 | it.effect.each([ | ||
| 11188 | { caseName: "a non-repository", isRepository: false, failFetch: false }, | ||
| 11189 | { caseName: "a base without a commit", isRepository: true, failFetch: false }, | ||
| 11190 | { caseName: "a fetch failure", isRepository: true, failFetch: true }, | ||
| 11191 | ])( | ||
| 11192 | "rejects required worktree bootstrap before creating a thread for $caseName", | ||
| 11193 | ({ isRepository, failFetch }) => | ||
| 11194 | Effect.gen(function* () { | ||
| 11195 | const dispatchedCommands: Array<OrchestrationCommand> = []; | ||
| 11196 | const createWorktree = vi.fn( | ||
| 11197 | (_: Parameters<GitVcsDriver.GitVcsDriver["Service"]["createWorktree"]>[0]) => | ||
| 11198 | Effect.die(new Error("createWorktree must not run before a valid base is found")), | ||
| 11199 | ); | ||
| 11200 | |||
| 11201 | yield* buildAppUnderTest({ | ||
| 11202 | layers: { | ||
| 11203 | vcsDriver: { | ||
| 11204 | isInsideWorkTree: () => Effect.succeed(isRepository), | ||
| 11205 | }, | ||
| 11206 | gitVcsDriver: { | ||
| 11207 | execute: () => | ||
| 11208 | Effect.succeed({ | ||
| 11209 | ...SUCCESSFUL_GIT_EXECUTION, | ||
| 11210 | exitCode: ChildProcessSpawner.ExitCode(128), | ||
| 11211 | stderr: "fatal: Needed a single revision", | ||
| 11212 | }), | ||
| 11213 | remoteExists: () => Effect.succeed(true), | ||
| 11214 | fetchRemote: () => Effect.die(new Error("fetch failed before thread creation")), | ||
| 11215 | createWorktree, | ||
| 11216 | }, | ||
| 11217 | orchestrationEngine: { | ||
| 11218 | dispatch: (command) => | ||
| 11219 | Effect.sync(() => { | ||
| 11220 | dispatchedCommands.push(command); | ||
| 11221 | return { sequence: dispatchedCommands.length }; | ||
| 11222 | }), | ||
| 11223 | readEvents: () => Stream.empty, | ||
| 11224 | }, | ||
| 11225 | }, | ||
| 11226 | }); | ||
| 11227 | |||
| 11228 | const createdAt = "2026-01-01T00:00:00.000Z"; | ||
| 11229 | const wsUrl = yield* getWsServerUrl("/ws"); | ||
| 11230 | const result = yield* Effect.scoped( | ||
| 11231 | withWsRpcClient(wsUrl, (client) => | ||
| 11232 | client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ | ||
| 11233 | type: "thread.turn.start", | ||
| 11234 | commandId: CommandId.make("cmd-required-worktree"), | ||
| 11235 | threadId: ThreadId.make("thread-required-worktree"), | ||
| 11236 | message: { | ||
| 11237 | messageId: MessageId.make("msg-required-worktree"), | ||
| 11238 | role: "user", | ||
| 11239 | text: "hello", | ||
| 11240 | attachments: [], | ||
| 11241 | }, | ||
| 11242 | modelSelection: defaultModelSelection, | ||
| 11243 | runtimeMode: "full-access", | ||
| 11244 | interactionMode: "default", | ||
| 11245 | bootstrap: { | ||
| 11246 | createThread: { | ||
| 11247 | projectId: defaultProjectId, | ||
| 11248 | title: "Bootstrap Thread", | ||
| 11249 | modelSelection: defaultModelSelection, | ||
| 11250 | runtimeMode: "full-access", | ||
| 11251 | interactionMode: "default", | ||
| 11252 | branch: "main", | ||
| 11253 | worktreePath: null, | ||
| 11254 | createdAt, | ||
| 11255 | }, | ||
| 11256 | prepareWorktree: { | ||
| 11257 | projectCwd: "/tmp/project", | ||
| 11258 | baseBranch: "main", | ||
| 11259 | requireWorktree: true, | ||
| 11260 | startFromOrigin: failFetch, | ||
| 11261 | }, | ||
| 11262 | }, | ||
| 11263 | createdAt, | ||
| 11264 | }), | ||
| 11265 | ).pipe(Effect.result), | ||
| 11266 | ); | ||
| 11267 | |||
| 11268 | assertTrue(result._tag === "Failure"); | ||
| 11269 | assertTrue(result.failure._tag === "OrchestrationDispatchCommandError"); | ||
| 11270 | assert.strictEqual(result.failure.bootstrapThreadDisposition, "not-created"); | ||
| 11271 | assert.include( | ||
| 11272 | result.failure.message, | ||
| 11273 | failFetch ? "fetch failed" : "separate worktree requires", | ||
| 11274 | ); | ||
| 11275 | assert.equal(createWorktree.mock.calls.length, 0); | ||
| 11276 | assert.deepEqual( | ||
| 11277 | dispatchedCommands.map((command) => command.type), | ||
| 11278 | ["thread.activity.append"], | ||
| 11279 | ); | ||
| 11280 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 11281 | ); | ||
| 11282 | |||
| 11187 | it.effect("falls back to the project checkout when worktree mode targets a non-repository", () => | 11283 | it.effect("falls back to the project checkout when worktree mode targets a non-repository", () => |
| 11188 | Effect.gen(function* () { | 11284 | Effect.gen(function* () { |
| 11189 | const dispatchedCommands: Array<OrchestrationCommand> = []; | 11285 | const dispatchedCommands: Array<OrchestrationCommand> = []; |
apps/server/src/ws.ts+12-3| ... | @@ -1369,6 +1369,12 @@ const makeWsRpcLayer = ( | ... | @@ -1369,6 +1369,12 @@ const makeWsRpcLayer = ( |
| 1369 | } | 1369 | } |
| 1370 | 1370 | ||
| 1371 | if (prepareWorktree && !shouldPrepareWorktree) { | 1371 | if (prepareWorktree && !shouldPrepareWorktree) { |
| 1372 | if (prepareWorktree.requireWorktree) { | ||
| 1373 | return yield* new OrchestrationDispatchCommandError({ | ||
| 1374 | message: | ||
| 1375 | "A separate worktree requires a Git repository and a base branch with a commit.", | ||
| 1376 | }); | ||
| 1377 | } | ||
| 1372 | // Not a git repo, or the base has no commit: the thread runs in | 1378 | // Not a git repo, or the base has no commit: the thread runs in |
| 1373 | // the project checkout instead. The card says so and moves on. | 1379 | // the project checkout instead. The card says so and moves on. |
| 1374 | yield* track( | 1380 | yield* track( |
| ... | @@ -1401,8 +1407,8 @@ const makeWsRpcLayer = ( | ... | @@ -1401,8 +1407,8 @@ const makeWsRpcLayer = ( |
| 1401 | // every delete for the prior incarnation committed before it. | 1407 | // every delete for the prior incarnation committed before it. |
| 1402 | // Drain through that event before setup or turn start can own | 1408 | // Drain through that event before setup or turn start can own |
| 1403 | // terminals and provider sessions under the reused thread id. | 1409 | // terminals and provider sessions under the reused thread id. |
| 1404 | yield* threadDeletionReactor.drainThrough(created.sequence); | ||
| 1405 | createdThread = true; | 1410 | createdThread = true; |
| 1411 | yield* threadDeletionReactor.drainThrough(created.sequence); | ||
| 1406 | // Persist the send now rather than with the turn: the thread is | 1412 | // Persist the send now rather than with the turn: the thread is |
| 1407 | // real from here on, so any client (or a reload) sees the message | 1413 | // real from here on, so any client (or a reload) sees the message |
| 1408 | // while the worktree is still being prepared. The turn start | 1414 | // while the worktree is still being prepared. The turn start |
| ... | @@ -1606,13 +1612,16 @@ const makeWsRpcLayer = ( | ... | @@ -1606,13 +1612,16 @@ const makeWsRpcLayer = ( |
| 1606 | ), | 1612 | ), |
| 1607 | onSuccess: (threadDeleted) => | 1613 | onSuccess: (threadDeleted) => |
| 1608 | Effect.fail( | 1614 | Effect.fail( |
| 1609 | threadDeleted | 1615 | threadDeleted || |
| 1616 | (bootstrap?.createThread && | ||
| 1617 | bootstrap.prepareWorktree?.requireWorktree === true && | ||
| 1618 | !createdThread) | ||
| 1610 | ? new OrchestrationDispatchCommandError({ | 1619 | ? new OrchestrationDispatchCommandError({ |
| 1611 | message: dispatchError.message, | 1620 | message: dispatchError.message, |
| 1612 | ...(dispatchError.cause !== undefined | 1621 | ...(dispatchError.cause !== undefined |
| 1613 | ? { cause: dispatchError.cause } | 1622 | ? { cause: dispatchError.cause } |
| 1614 | : {}), | 1623 | : {}), |
| 1615 | bootstrapThreadDisposition: "deleted", | 1624 | bootstrapThreadDisposition: threadDeleted ? "deleted" : "not-created", |
| 1616 | }) | 1625 | }) |
| 1617 | : dispatchError, | 1626 | : dispatchError, |
| 1618 | ), | 1627 | ), |
apps/web/src/components/BranchToolbar.tsx+26-10| ... | @@ -66,6 +66,7 @@ export interface BranchToolbarHandle { | ... | @@ -66,6 +66,7 @@ export interface BranchToolbarHandle { |
| 66 | } | 66 | } |
| 67 | 67 | ||
| 68 | interface BranchToolbarProps { | 68 | interface BranchToolbarProps { |
| 69 | forceNewWorktree?: boolean; | ||
| 69 | ref?: Ref<BranchToolbarHandle>; | 70 | ref?: Ref<BranchToolbarHandle>; |
| 70 | environmentId: EnvironmentId; | 71 | environmentId: EnvironmentId; |
| 71 | threadId: ThreadId; | 72 | threadId: ThreadId; |
| ... | @@ -89,6 +90,7 @@ interface BranchToolbarProps { | ... | @@ -89,6 +90,7 @@ interface BranchToolbarProps { |
| 89 | } | 90 | } |
| 90 | 91 | ||
| 91 | interface MobileRunContextSelectorProps { | 92 | interface MobileRunContextSelectorProps { |
| 93 | forceNewWorktree: boolean; | ||
| 92 | autoEnvironmentLabel?: string | undefined; | 94 | autoEnvironmentLabel?: string | undefined; |
| 93 | onAutoEnvironment?: (() => void) | undefined; | 95 | onAutoEnvironment?: (() => void) | undefined; |
| 94 | envLocked: boolean; | 96 | envLocked: boolean; |
| ... | @@ -106,6 +108,7 @@ interface MobileRunContextSelectorProps { | ... | @@ -106,6 +108,7 @@ interface MobileRunContextSelectorProps { |
| 106 | } | 108 | } |
| 107 | 109 | ||
| 108 | const MobileRunContextSelector = memo(function MobileRunContextSelector({ | 110 | const MobileRunContextSelector = memo(function MobileRunContextSelector({ |
| 111 | forceNewWorktree, | ||
| 109 | autoEnvironmentLabel, | 112 | autoEnvironmentLabel, |
| 110 | onAutoEnvironment, | 113 | onAutoEnvironment, |
| 111 | envLocked, | 114 | envLocked, |
| ... | @@ -132,11 +135,13 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ | ... | @@ -132,11 +135,13 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ |
| 132 | : activeWorktreePath | 135 | : activeWorktreePath |
| 133 | ? FolderGitIcon | 136 | ? FolderGitIcon |
| 134 | : FolderIcon; | 137 | : FolderIcon; |
| 135 | const workspaceLabel = envModeLocked | 138 | const workspaceLabel = forceNewWorktree |
| 136 | ? resolveLockedWorkspaceLabel(activeWorktreePath) | 139 | ? resolveEnvModeLabel("worktree") |
| 137 | : effectiveEnvMode === "worktree" | 140 | : envModeLocked |
| 138 | ? resolveEnvModeLabel("worktree") | 141 | ? resolveLockedWorkspaceLabel(activeWorktreePath) |
| 139 | : resolveCurrentWorkspaceLabel(activeWorktreePath); | 142 | : effectiveEnvMode === "worktree" |
| 143 | ? resolveEnvModeLabel("worktree") | ||
| 144 | : resolveCurrentWorkspaceLabel(activeWorktreePath); | ||
| 140 | const isLocked = envLocked || envModeLocked; | 145 | const isLocked = envLocked || envModeLocked; |
| 141 | const workspaceIcon = ( | 146 | const workspaceIcon = ( |
| 142 | <Tooltip> | 147 | <Tooltip> |
| ... | @@ -269,7 +274,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ | ... | @@ -269,7 +274,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ |
| 269 | onEnvModeChange(value as EnvMode); | 274 | onEnvModeChange(value as EnvMode); |
| 270 | }} | 275 | }} |
| 271 | > | 276 | > |
| 272 | <MenuRadioItem disabled={envModeLocked} value="local"> | 277 | <MenuRadioItem disabled={envModeLocked || forceNewWorktree} value="local"> |
| 273 | <span className="flex min-w-0 items-center gap-1.5"> | 278 | <span className="flex min-w-0 items-center gap-1.5"> |
| 274 | {activeWorktreePath ? ( | 279 | {activeWorktreePath ? ( |
| 275 | <FolderGitIcon className="size-3" /> | 280 | <FolderGitIcon className="size-3" /> |
| ... | @@ -476,6 +481,7 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { | ... | @@ -476,6 +481,7 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { |
| 476 | } | 481 | } |
| 477 | 482 | ||
| 478 | export const BranchToolbar = memo(function BranchToolbar({ | 483 | export const BranchToolbar = memo(function BranchToolbar({ |
| 484 | forceNewWorktree = false, | ||
| 479 | ref, | 485 | ref, |
| 480 | environmentId, | 486 | environmentId, |
| 481 | threadId, | 487 | threadId, |
| ... | @@ -514,9 +520,11 @@ export const BranchToolbar = memo(function BranchToolbar({ | ... | @@ -514,9 +520,11 @@ export const BranchToolbar = memo(function BranchToolbar({ |
| 514 | : null; | 520 | : null; |
| 515 | const activeProject = useProject(activeProjectRef); | 521 | const activeProject = useProject(activeProjectRef); |
| 516 | const hasActiveThread = serverThread !== null || draftThread !== null; | 522 | const hasActiveThread = serverThread !== null || draftThread !== null; |
| 517 | const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; | 523 | const activeWorktreePath = forceNewWorktree |
| 524 | ? null | ||
| 525 | : (serverThread?.worktreePath ?? draftThread?.worktreePath ?? null); | ||
| 518 | const effectiveEnvMode = | 526 | const effectiveEnvMode = |
| 519 | effectiveEnvModeOverride ?? | 527 | (forceNewWorktree ? "worktree" : effectiveEnvModeOverride) ?? |
| 520 | resolveEffectiveEnvMode({ | 528 | resolveEffectiveEnvMode({ |
| 521 | activeWorktreePath, | 529 | activeWorktreePath, |
| 522 | hasServerThread: serverThread !== null, | 530 | hasServerThread: serverThread !== null, |
| ... | @@ -527,7 +535,8 @@ export const BranchToolbar = memo(function BranchToolbar({ | ... | @@ -527,7 +535,8 @@ export const BranchToolbar = memo(function BranchToolbar({ |
| 527 | // "Previous worktree" hops a draft into the most recently active worktree | 535 | // "Previous worktree" hops a draft into the most recently active worktree |
| 528 | // of this project — the "keep going where I just was" follow-up flow. Only | 536 | // of this project — the "keep going where I just was" follow-up flow. Only |
| 529 | // drafts can hop; started server threads have their workspace pinned. | 537 | // drafts can hop; started server threads have their workspace pinned. |
| 530 | const canUsePreviousWorktree = draftThread !== null && serverThread === null && !envModeLocked; | 538 | const canUsePreviousWorktree = |
| 539 | draftThread !== null && serverThread === null && !envModeLocked && !forceNewWorktree; | ||
| 531 | const projectRefsForWorktreeLookup = useMemo( | 540 | const projectRefsForWorktreeLookup = useMemo( |
| 532 | () => (canUsePreviousWorktree && activeProjectRef ? [activeProjectRef] : []), | 541 | () => (canUsePreviousWorktree && activeProjectRef ? [activeProjectRef] : []), |
| 533 | [canUsePreviousWorktree, activeProjectRef], | 542 | [canUsePreviousWorktree, activeProjectRef], |
| ... | @@ -606,6 +615,7 @@ export const BranchToolbar = memo(function BranchToolbar({ | ... | @@ -606,6 +615,7 @@ export const BranchToolbar = memo(function BranchToolbar({ |
| 606 | {showGitControls ? ( | 615 | {showGitControls ? ( |
| 607 | <div className="contents @3xl/composer-surface:hidden"> | 616 | <div className="contents @3xl/composer-surface:hidden"> |
| 608 | <MobileRunContextSelector | 617 | <MobileRunContextSelector |
| 618 | forceNewWorktree={forceNewWorktree} | ||
| 609 | autoEnvironmentLabel={autoEnvironmentLabel} | 619 | autoEnvironmentLabel={autoEnvironmentLabel} |
| 610 | onAutoEnvironment={onAutoEnvironment} | 620 | onAutoEnvironment={onAutoEnvironment} |
| 611 | envLocked={envLocked} | 621 | envLocked={envLocked} |
| ... | @@ -652,6 +662,7 @@ export const BranchToolbar = memo(function BranchToolbar({ | ... | @@ -652,6 +662,7 @@ export const BranchToolbar = memo(function BranchToolbar({ |
| 652 | )} | 662 | )} |
| 653 | {showGitControls ? ( | 663 | {showGitControls ? ( |
| 654 | <BranchToolbarEnvModeSelector | 664 | <BranchToolbarEnvModeSelector |
| 665 | forceNewWorktree={forceNewWorktree} | ||
| 655 | envLocked={envModeLocked} | 666 | envLocked={envModeLocked} |
| 656 | effectiveEnvMode={effectiveEnvMode} | 667 | effectiveEnvMode={effectiveEnvMode} |
| 657 | activeWorktreePath={activeWorktreePath} | 668 | activeWorktreePath={activeWorktreePath} |
| ... | @@ -677,13 +688,18 @@ export const BranchToolbar = memo(function BranchToolbar({ | ... | @@ -677,13 +688,18 @@ export const BranchToolbar = memo(function BranchToolbar({ |
| 677 | 688 | ||
| 678 | {showGitControls ? ( | 689 | {showGitControls ? ( |
| 679 | <BranchToolbarBranchSelector | 690 | <BranchToolbarBranchSelector |
| 691 | forceNewWorktree={forceNewWorktree} | ||
| 680 | ref={branchSelectorRef} | 692 | ref={branchSelectorRef} |
| 681 | className="min-w-0 flex-initial justify-end @3xl/composer-surface:ml-auto" | 693 | className="min-w-0 flex-initial justify-end @3xl/composer-surface:ml-auto" |
| 682 | environmentId={environmentId} | 694 | environmentId={environmentId} |
| 683 | threadId={threadId} | 695 | threadId={threadId} |
| 684 | {...(draftId ? { draftId } : {})} | 696 | {...(draftId ? { draftId } : {})} |
| 685 | envLocked={envLocked} | 697 | envLocked={envLocked} |
| 686 | {...(effectiveEnvModeOverride ? { effectiveEnvModeOverride } : {})} | 698 | {...(forceNewWorktree |
| 699 | ? { effectiveEnvModeOverride: "worktree" } | ||
| 700 | : effectiveEnvModeOverride | ||
| 701 | ? { effectiveEnvModeOverride } | ||
| 702 | : {})} | ||
| 687 | {...(activeThreadBranchOverride !== undefined ? { activeThreadBranchOverride } : {})} | 703 | {...(activeThreadBranchOverride !== undefined ? { activeThreadBranchOverride } : {})} |
| 688 | {...(onActiveThreadBranchOverrideChange ? { onActiveThreadBranchOverrideChange } : {})} | 704 | {...(onActiveThreadBranchOverrideChange ? { onActiveThreadBranchOverrideChange } : {})} |
| 689 | startFromOrigin={startFromOrigin} | 705 | startFromOrigin={startFromOrigin} |
apps/web/src/components/BranchToolbarBranchSelector.tsx+5-1| ... | @@ -79,6 +79,7 @@ export interface BranchToolbarBranchSelectorHandle { | ... | @@ -79,6 +79,7 @@ export interface BranchToolbarBranchSelectorHandle { |
| 79 | } | 79 | } |
| 80 | 80 | ||
| 81 | interface BranchToolbarBranchSelectorProps { | 81 | interface BranchToolbarBranchSelectorProps { |
| 82 | forceNewWorktree?: boolean; | ||
| 82 | ref?: Ref<BranchToolbarBranchSelectorHandle>; | 83 | ref?: Ref<BranchToolbarBranchSelectorHandle>; |
| 83 | className?: string; | 84 | className?: string; |
| 84 | environmentId: EnvironmentId; | 85 | environmentId: EnvironmentId; |
| ... | @@ -99,6 +100,7 @@ function toBranchActionErrorMessage(error: unknown): string { | ... | @@ -99,6 +100,7 @@ function toBranchActionErrorMessage(error: unknown): string { |
| 99 | } | 100 | } |
| 100 | 101 | ||
| 101 | export function BranchToolbarBranchSelector({ | 102 | export function BranchToolbarBranchSelector({ |
| 103 | forceNewWorktree = false, | ||
| 102 | ref, | 104 | ref, |
| 103 | className, | 105 | className, |
| 104 | environmentId, | 106 | environmentId, |
| ... | @@ -152,7 +154,9 @@ export function BranchToolbarBranchSelector({ | ... | @@ -152,7 +154,9 @@ export function BranchToolbarBranchSelector({ |
| 152 | activeThreadBranchOverride !== undefined | 154 | activeThreadBranchOverride !== undefined |
| 153 | ? activeThreadBranchOverride | 155 | ? activeThreadBranchOverride |
| 154 | : (serverThread?.branch ?? draftThread?.branch ?? null); | 156 | : (serverThread?.branch ?? draftThread?.branch ?? null); |
| 155 | const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; | 157 | const activeWorktreePath = forceNewWorktree |
| 158 | ? null | ||
| 159 | : (serverThread?.worktreePath ?? draftThread?.worktreePath ?? null); | ||
| 156 | const activeProjectCwd = activeProject?.workspaceRoot ?? null; | 160 | const activeProjectCwd = activeProject?.workspaceRoot ?? null; |
| 157 | const branchCwd = activeWorktreePath ?? activeProjectCwd; | 161 | const branchCwd = activeWorktreePath ?? activeProjectCwd; |
| 158 | const hasServerThread = serverThread !== null; | 162 | const hasServerThread = serverThread !== null; |
apps/web/src/components/BranchToolbarEnvModeSelector.tsx+14-4| ... | @@ -22,6 +22,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; | ... | @@ -22,6 +22,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 22 | const PREVIOUS_WORKTREE_SELECT_VALUE = "previous-worktree"; | 22 | const PREVIOUS_WORKTREE_SELECT_VALUE = "previous-worktree"; |
| 23 | 23 | ||
| 24 | interface BranchToolbarEnvModeSelectorProps { | 24 | interface BranchToolbarEnvModeSelectorProps { |
| 25 | forceNewWorktree?: boolean; | ||
| 25 | envLocked: boolean; | 26 | envLocked: boolean; |
| 26 | effectiveEnvMode: EnvMode; | 27 | effectiveEnvMode: EnvMode; |
| 27 | activeWorktreePath: string | null; | 28 | activeWorktreePath: string | null; |
| ... | @@ -31,6 +32,7 @@ interface BranchToolbarEnvModeSelectorProps { | ... | @@ -31,6 +32,7 @@ interface BranchToolbarEnvModeSelectorProps { |
| 31 | } | 32 | } |
| 32 | 33 | ||
| 33 | export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSelector({ | 34 | export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSelector({ |
| 35 | forceNewWorktree = false, | ||
| 34 | envLocked, | 36 | envLocked, |
| 35 | effectiveEnvMode, | 37 | effectiveEnvMode, |
| 36 | activeWorktreePath, | 38 | activeWorktreePath, |
| ... | @@ -51,7 +53,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe | ... | @@ -51,7 +53,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe |
| 51 | [activeWorktreePath, previousWorktreeLabel, showPreviousWorktree], | 53 | [activeWorktreePath, previousWorktreeLabel, showPreviousWorktree], |
| 52 | ); | 54 | ); |
| 53 | 55 | ||
| 54 | if (envLocked) { | 56 | if (envLocked || forceNewWorktree) { |
| 55 | return ( | 57 | return ( |
| 56 | <Tooltip> | 58 | <Tooltip> |
| 57 | <TooltipTrigger | 59 | <TooltipTrigger |
| ... | @@ -59,7 +61,9 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe | ... | @@ -59,7 +61,9 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe |
| 59 | className="inline-flex h-7 min-w-0 items-center gap-1 border border-transparent px-[calc(--spacing(2)-1px)] font-normal text-muted-foreground/70 text-xs sm:h-6" | 61 | className="inline-flex h-7 min-w-0 items-center gap-1 border border-transparent px-[calc(--spacing(2)-1px)] font-normal text-muted-foreground/70 text-xs sm:h-6" |
| 60 | data-composer-context-control | 62 | data-composer-context-control |
| 61 | > | 63 | > |
| 62 | {activeWorktreePath ? ( | 64 | {forceNewWorktree ? ( |
| 65 | <FolderGit2Icon className="size-3 shrink-0" /> | ||
| 66 | ) : activeWorktreePath ? ( | ||
| 63 | <FolderGitIcon className="size-3 shrink-0" /> | 67 | <FolderGitIcon className="size-3 shrink-0" /> |
| 64 | ) : ( | 68 | ) : ( |
| 65 | <FolderIcon className="size-3 shrink-0" /> | 69 | <FolderIcon className="size-3 shrink-0" /> |
| ... | @@ -72,11 +76,17 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe | ... | @@ -72,11 +76,17 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe |
| 72 | data-composer-label-motion | 76 | data-composer-label-motion |
| 73 | className="block w-full min-w-0 max-w-[240px] truncate transition-opacity duration-180 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[compact]/composer-context:opacity-0 motion-reduce:transition-none" | 77 | className="block w-full min-w-0 max-w-[240px] truncate transition-opacity duration-180 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[compact]/composer-context:opacity-0 motion-reduce:transition-none" |
| 74 | > | 78 | > |
| 75 | {resolveLockedWorkspaceLabel(activeWorktreePath)} | 79 | {forceNewWorktree |
| 80 | ? resolveEnvModeLabel("worktree") | ||
| 81 | : resolveLockedWorkspaceLabel(activeWorktreePath)} | ||
| 76 | </span> | 82 | </span> |
| 77 | </span> | 83 | </span> |
| 78 | </TooltipTrigger> | 84 | </TooltipTrigger> |
| 79 | <TooltipPopup>{resolveLockedWorkspaceLabel(activeWorktreePath)}</TooltipPopup> | 85 | <TooltipPopup> |
| 86 | {forceNewWorktree | ||
| 87 | ? "Each model starts in its own worktree." | ||
| 88 | : resolveLockedWorkspaceLabel(activeWorktreePath)} | ||
| 89 | </TooltipPopup> | ||
| 80 | </Tooltip> | 90 | </Tooltip> |
| 81 | ); | 91 | ); |
| 82 | } | 92 | } |
apps/web/src/components/ChatView.tsx+389-12| ... | @@ -46,7 +46,10 @@ import { | ... | @@ -46,7 +46,10 @@ import { |
| 46 | type WorktreeSetupSnapshot, | 46 | type WorktreeSetupSnapshot, |
| 47 | } from "@t3tools/contracts"; | 47 | } from "@t3tools/contracts"; |
| 48 | import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; | 48 | import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; |
| 49 | import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors"; | 49 | import { |
| 50 | wasBootstrapThreadDeleted, | ||
| 51 | wasBootstrapThreadNotCreated, | ||
| 52 | } from "@t3tools/client-runtime/errors"; | ||
| 50 | import { readPastedComposerContext } from "./composerInlineTokenPaste"; | 53 | import { readPastedComposerContext } from "./composerInlineTokenPaste"; |
| 51 | import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste"; | 54 | import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste"; |
| 52 | import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; | 55 | import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; |
| ... | @@ -82,9 +85,11 @@ import { | ... | @@ -82,9 +85,11 @@ import { |
| 82 | } from "@t3tools/shared/terminalLabels"; | 85 | } from "@t3tools/shared/terminalLabels"; |
| 83 | import { Debouncer } from "@tanstack/react-pacer"; | 86 | import { Debouncer } from "@tanstack/react-pacer"; |
| 84 | import { useAtomValue } from "@effect/atom-react"; | 87 | import { useAtomValue } from "@effect/atom-react"; |
| 88 | import { Atom } from "effect/unstable/reactivity"; | ||
| 85 | import { | 89 | import { |
| 86 | lazy, | 90 | lazy, |
| 87 | memo, | 91 | memo, |
| 92 | type SetStateAction, | ||
| 88 | Suspense, | 93 | Suspense, |
| 89 | useCallback, | 94 | useCallback, |
| 90 | useEffect, | 95 | useEffect, |
| ... | @@ -265,6 +270,10 @@ import { useRemoveClonedProject } from "../hooks/useRemoveClonedProject"; | ... | @@ -265,6 +270,10 @@ import { useRemoveClonedProject } from "../hooks/useRemoveClonedProject"; |
| 265 | import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl"; | 270 | import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl"; |
| 266 | import { useThreadActions } from "../hooks/useThreadActions"; | 271 | import { useThreadActions } from "../hooks/useThreadActions"; |
| 267 | import { resolveAppModelSelectionForInstance } from "../modelSelection"; | 272 | import { resolveAppModelSelectionForInstance } from "../modelSelection"; |
| 273 | import { | ||
| 274 | getComposerPromptInjectionState, | ||
| 275 | getComposerProviderState, | ||
| 276 | } from "./chat/composerProviderState"; | ||
| 268 | import { confirmTerminalClose, isTerminalCloseConfirmPending } from "../lib/terminalCloseConfirm"; | 277 | import { confirmTerminalClose, isTerminalCloseConfirmPending } from "../lib/terminalCloseConfirm"; |
| 269 | import { isPreviewFocused } from "../lib/previewFocus"; | 278 | import { isPreviewFocused } from "../lib/previewFocus"; |
| 270 | import { getTerminalFocusOwner } from "../lib/terminalFocus"; | 279 | import { getTerminalFocusOwner } from "../lib/terminalFocus"; |
| ... | @@ -443,6 +452,7 @@ import { | ... | @@ -443,6 +452,7 @@ import { |
| 443 | resolveBackgroundDraftWorkspaceOptions, | 452 | resolveBackgroundDraftWorkspaceOptions, |
| 444 | resolveComposerInteractionMode, | 453 | resolveComposerInteractionMode, |
| 445 | resolveComposerProviderSelection, | 454 | resolveComposerProviderSelection, |
| 455 | getAntigravitySendBlockReason, | ||
| 446 | resolveDraftHeroState, | 456 | resolveDraftHeroState, |
| 447 | findRecordedWorktreeSetup, | 457 | findRecordedWorktreeSetup, |
| 448 | resolveVisibleWorktreeSetup, | 458 | resolveVisibleWorktreeSetup, |
| ... | @@ -698,6 +708,14 @@ function pasteTextToFocusComposer(event: ClipboardEvent): string | null { | ... | @@ -698,6 +708,14 @@ function pasteTextToFocusComposer(event: ClipboardEvent): string | null { |
| 698 | return text.length > 0 ? text : null; | 708 | return text.length > 0 ? text : null; |
| 699 | } | 709 | } |
| 700 | 710 | ||
| 711 | const draftFanoutStateAtom = Atom.family((_routeKey: string) => | ||
| 712 | Atom.make({ | ||
| 713 | selections: null as ReadonlyArray<ModelSelection> | null, | ||
| 714 | sendInFlight: { current: false }, | ||
| 715 | uncertainSubmissions: { current: new Map<string, ThreadId>() }, | ||
| 716 | }).pipe(Atom.keepAlive), | ||
| 717 | ); | ||
| 718 | |||
| 701 | function formatOutgoingPrompt(params: { | 719 | function formatOutgoingPrompt(params: { |
| 702 | provider: ProviderDriverKind; | 720 | provider: ProviderDriverKind; |
| 703 | model: string | null; | 721 | model: string | null; |
| ... | @@ -1771,7 +1789,23 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1771,7 +1789,23 @@ export default function ChatView(props: ChatViewProps) { |
| 1771 | const [timelineOverflows, setTimelineOverflows] = useState(false); | 1789 | const [timelineOverflows, setTimelineOverflows] = useState(false); |
| 1772 | const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({}); | 1790 | const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({}); |
| 1773 | const attachmentPreviewPromotionInFlightByMessageIdRef = useRef<Record<string, true>>({}); | 1791 | const attachmentPreviewPromotionInFlightByMessageIdRef = useRef<Record<string, true>>({}); |
| 1774 | const sendInFlightRef = useRef(false); | 1792 | const fanoutStateAtom = draftFanoutStateAtom(routeThreadKey); |
| 1793 | const fanoutState = useAtomValue(fanoutStateAtom); | ||
| 1794 | const sendInFlightRef = fanoutState.sendInFlight; | ||
| 1795 | const composerSendGenerationRef = useRef(0); | ||
| 1796 | const multipleModelSelections = fanoutState.selections; | ||
| 1797 | const setMultipleModelSelections = useCallback( | ||
| 1798 | (selections: SetStateAction<ReadonlyArray<ModelSelection> | null>) => { | ||
| 1799 | appAtomRegistry.update(fanoutStateAtom, (current) => ({ | ||
| 1800 | ...current, | ||
| 1801 | selections: typeof selections === "function" ? selections(current.selections) : selections, | ||
| 1802 | })); | ||
| 1803 | }, | ||
| 1804 | [fanoutStateAtom], | ||
| 1805 | ); | ||
| 1806 | const multipleModelSelectionsRef = useRef(multipleModelSelections); | ||
| 1807 | multipleModelSelectionsRef.current = multipleModelSelections; | ||
| 1808 | const uncertainMultipleSubmissionsRef = fanoutState.uncertainSubmissions; | ||
| 1775 | const environmentUnavailableSendToastSlotRef = useRef(0); | 1809 | const environmentUnavailableSendToastSlotRef = useRef(0); |
| 1776 | const feedbackUploadsInFlightRef = useRef(new Set<string>()); | 1810 | const feedbackUploadsInFlightRef = useRef(new Set<string>()); |
| 1777 | const terminalUiOpenByThreadRef = useRef<Record<string, boolean>>({}); | 1811 | const terminalUiOpenByThreadRef = useRef<Record<string, boolean>>({}); |
| ... | @@ -7302,6 +7336,31 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -7302,6 +7336,31 @@ export default function ChatView(props: ChatViewProps) { |
| 7302 | notifyDirectAnnotationAttached(); | 7336 | notifyDirectAnnotationAttached(); |
| 7303 | return; | 7337 | return; |
| 7304 | } | 7338 | } |
| 7339 | const multipleModelSelections = queuedMessage ? null : sendCtx.multipleModelSelections; | ||
| 7340 | if ( | ||
| 7341 | multipleModelSelections !== null && | ||
| 7342 | serverConfig?.environment.capabilities.requiredWorktreeBootstrap !== true | ||
| 7343 | ) { | ||
| 7344 | setThreadError(activeThread.id, "Update this server before starting multiple models."); | ||
| 7345 | return; | ||
| 7346 | } | ||
| 7347 | if ( | ||
| 7348 | multipleModelSelections !== null && | ||
| 7349 | (!isLocalDraftThread || | ||
| 7350 | !isGitRepo || | ||
| 7351 | !activeThreadBranch || | ||
| 7352 | multipleModelSelections.length === 0) | ||
| 7353 | ) { | ||
| 7354 | toastManager.add( | ||
| 7355 | stackedThreadToast({ | ||
| 7356 | type: "warning", | ||
| 7357 | title: "Choose models and a base branch", | ||
| 7358 | description: | ||
| 7359 | "Multiple models need a new thread in a Git project. Each gets its own worktree.", | ||
| 7360 | }), | ||
| 7361 | ); | ||
| 7362 | return; | ||
| 7363 | } | ||
| 7305 | const { | 7364 | const { |
| 7306 | images: sendContextImages, | 7365 | images: sendContextImages, |
| 7307 | files: composerFiles, | 7366 | files: composerFiles, |
| ... | @@ -7379,7 +7438,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -7379,7 +7438,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7379 | composerReviewComments.length === 0 | 7438 | composerReviewComments.length === 0 |
| 7380 | ? parseCodexFeedbackCommand(trimmed) | 7439 | ? parseCodexFeedbackCommand(trimmed) |
| 7381 | : null; | 7440 | : null; |
| 7382 | if (feedbackCommand && !queuedMessage) { | 7441 | if (feedbackCommand && !queuedMessage && multipleModelSelections === null) { |
| 7383 | if (!isServerThread || activeThread.session === null) { | 7442 | if (!isServerThread || activeThread.session === null) { |
| 7384 | toastManager.add( | 7443 | toastManager.add( |
| 7385 | stackedThreadToast({ | 7444 | stackedThreadToast({ |
| ... | @@ -7502,7 +7561,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -7502,7 +7561,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7502 | composerReviewComments.length === 0 | 7561 | composerReviewComments.length === 0 |
| 7503 | ? parseStandaloneComposerSlashCommand(trimmed) | 7562 | ? parseStandaloneComposerSlashCommand(trimmed) |
| 7504 | : null; | 7563 | : null; |
| 7505 | if (standaloneSlashCommand && !queuedMessage) { | 7564 | if (standaloneSlashCommand && !queuedMessage && multipleModelSelections === null) { |
| 7506 | handleInteractionModeChange(standaloneSlashCommand); | 7565 | handleInteractionModeChange(standaloneSlashCommand); |
| 7507 | promptRef.current = ""; | 7566 | promptRef.current = ""; |
| 7508 | clearComposerDraftContent(composerDraftTarget); | 7567 | clearComposerDraftContent(composerDraftTarget); |
| ... | @@ -7652,7 +7711,53 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -7652,7 +7711,53 @@ export default function ChatView(props: ChatViewProps) { |
| 7652 | }; | 7711 | }; |
| 7653 | }; | 7712 | }; |
| 7654 | 7713 | ||
| 7714 | const multipleTargets = []; | ||
| 7715 | for (const selection of multipleModelSelections ?? []) { | ||
| 7716 | const provider = providerInstanceEntries.find( | ||
| 7717 | (entry) => entry.instanceId === selection.instanceId, | ||
| 7718 | ); | ||
| 7719 | if (!provider?.enabled || !provider.isAvailable || provider.status !== "ready") { | ||
| 7720 | setThreadError(threadIdForSend, `Provider for ${selection.model} is unavailable.`); | ||
| 7721 | return; | ||
| 7722 | } | ||
| 7723 | const providerBlockReason = getAntigravitySendBlockReason(provider.snapshot, selection.model); | ||
| 7724 | if (providerBlockReason) { | ||
| 7725 | setThreadError(threadIdForSend, providerBlockReason); | ||
| 7726 | return; | ||
| 7727 | } | ||
| 7728 | const providerState = getComposerProviderState({ | ||
| 7729 | provider: provider.driverKind, | ||
| 7730 | model: selection.model, | ||
| 7731 | models: provider.models, | ||
| 7732 | modelOptions: selection.options, | ||
| 7733 | promptInjectionState: getComposerPromptInjectionState(messageTextForSend), | ||
| 7734 | planModeEnabled: settings.planModeEnabled, | ||
| 7735 | }); | ||
| 7736 | const text = formatOutgoingPrompt({ | ||
| 7737 | provider: provider.driverKind, | ||
| 7738 | model: selection.model, | ||
| 7739 | models: provider.models, | ||
| 7740 | effort: providerState.promptEffort, | ||
| 7741 | text: messageTextForSend || ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, | ||
| 7742 | }); | ||
| 7743 | if (composerRef.current?.validateProviderInput(text) === false) return; | ||
| 7744 | multipleTargets.push({ | ||
| 7745 | selection: createModelSelection( | ||
| 7746 | selection.instanceId, | ||
| 7747 | selection.model, | ||
| 7748 | providerState.modelOptionsForDispatch, | ||
| 7749 | ), | ||
| 7750 | text, | ||
| 7751 | interactionMode: resolveComposerInteractionMode({ | ||
| 7752 | planModeEnabled: settings.planModeEnabled, | ||
| 7753 | provider: provider.snapshot, | ||
| 7754 | interactionMode: sendInteractionMode, | ||
| 7755 | }).interactionMode, | ||
| 7756 | }); | ||
| 7757 | } | ||
| 7758 | |||
| 7655 | sendInFlightRef.current = true; | 7759 | sendInFlightRef.current = true; |
| 7760 | const sendGeneration = ++composerSendGenerationRef.current; | ||
| 7656 | // Every early return above leaves a queued message in the queue for a | 7761 | // Every early return above leaves a queued message in the queue for a |
| 7657 | // later retry. From here on a failure hands it back to the composer. | 7762 | // later retry. From here on a failure hands it back to the composer. |
| 7658 | if (queuedMessage) { | 7763 | if (queuedMessage) { |
| ... | @@ -7728,7 +7833,9 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -7728,7 +7833,9 @@ export default function ChatView(props: ChatViewProps) { |
| 7728 | } | 7833 | } |
| 7729 | 7834 | ||
| 7730 | const resolvedSubmissionIntent = | 7835 | const resolvedSubmissionIntent = |
| 7731 | submissionIntent === "background" && isLocalDraftThread ? "background" : "foreground"; | 7836 | (multipleModelSelections !== null || submissionIntent === "background") && isLocalDraftThread |
| 7837 | ? "background" | ||
| 7838 | : "foreground"; | ||
| 7732 | if ( | 7839 | if ( |
| 7733 | shouldDockDraftHeroForSubmission({ | 7840 | shouldDockDraftHeroForSubmission({ |
| 7734 | isDraftHeroState, | 7841 | isDraftHeroState, |
| ... | @@ -7763,7 +7870,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -7763,7 +7870,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7763 | return; | 7870 | return; |
| 7764 | } | 7871 | } |
| 7765 | beginLocalDispatch({ | 7872 | beginLocalDispatch({ |
| 7766 | preparingWorktree: Boolean(baseBranchForWorktree), | 7873 | preparingWorktree: multipleModelSelections !== null || Boolean(baseBranchForWorktree), |
| 7767 | submissionIntent: resolvedSubmissionIntent, | 7874 | submissionIntent: resolvedSubmissionIntent, |
| 7768 | }); | 7875 | }); |
| 7769 | 7876 | ||
| ... | @@ -7792,6 +7899,267 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -7792,6 +7899,267 @@ export default function ChatView(props: ChatViewProps) { |
| 7792 | }; | 7899 | }; |
| 7793 | }), | 7900 | }), |
| 7794 | ); | 7901 | ); |
| 7902 | if (multipleModelSelections !== null) { | ||
| 7903 | const failedSelections: ModelSelection[] = []; | ||
| 7904 | let clearedDraft = false; | ||
| 7905 | let releasedComposer = false; | ||
| 7906 | let canRestoreDraft = () => false; | ||
| 7907 | let startedCount = 0; | ||
| 7908 | try { | ||
| 7909 | const attachments = await turnAttachmentsPromise; | ||
| 7910 | const fileBlockReason = readLiveAttachmentCapabilities().fileBlockReason; | ||
| 7911 | if (fileBlockReason !== null) throw new Error(fileBlockReason); | ||
| 7912 | const context = buildOutgoingMessageContext( | ||
| 7913 | attachments.map((attachment, index) => | ||
| 7914 | "id" in attachment && attachment.id !== undefined | ||
| 7915 | ? attachment.id | ||
| 7916 | : composerAttachmentsSnapshot[index]!.id, | ||
| 7917 | ), | ||
| 7918 | ); | ||
| 7919 | const title = truncate( | ||
| 7920 | assistantCitationsToPlainText(stripInlineContextReferences(trimmed)).trim() || | ||
| 7921 | composerAttachmentsSnapshot[0]?.name || | ||
| 7922 | "New thread", | ||
| 7923 | ); | ||
| 7924 | promptRef.current = ""; | ||
| 7925 | clearComposerDraftContent(composerDraftTarget); | ||
| 7926 | composerRef.current?.resetCursorState(); | ||
| 7927 | clearedDraft = true; | ||
| 7928 | const clearedDraftSnapshot = useComposerDraftStore | ||
| 7929 | .getState() | ||
| 7930 | .getComposerDraft(composerDraftTarget); | ||
| 7931 | const submittedSelections = multipleModelSelectionsRef.current; | ||
| 7932 | canRestoreDraft = () => | ||
| 7933 | currentRouteThreadKeyRef.current === routeThreadKey && | ||
| 7934 | composerSendGenerationRef.current === sendGeneration && | ||
| 7935 | useComposerDraftStore.getState().getComposerDraft(composerDraftTarget) === | ||
| 7936 | clearedDraftSnapshot && | ||
| 7937 | multipleModelSelectionsRef.current === submittedSelections; | ||
| 7938 | setThreadError(threadIdForSend, null); | ||
| 7939 | const starts = Promise.all( | ||
| 7940 | multipleTargets.map(async (target) => { | ||
| 7941 | const retryKey = JSON.stringify([ | ||
| 7942 | routeThreadKey, | ||
| 7943 | target.selection.instanceId, | ||
| 7944 | target.selection.model, | ||
| 7945 | ]); | ||
| 7946 | const uncertainThreadId = uncertainMultipleSubmissionsRef.current.get(retryKey); | ||
| 7947 | const targetThreadId = uncertainThreadId ?? newThreadId(); | ||
| 7948 | let requestMayHaveStarted = false; | ||
| 7949 | try { | ||
| 7950 | if (uncertainThreadId) { | ||
| 7951 | throw new Error( | ||
| 7952 | "The previous request may have started. Open its thread to check before sending again.", | ||
| 7953 | ); | ||
| 7954 | } | ||
| 7955 | const supportsInlineMessageContext = | ||
| 7956 | appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment | ||
| 7957 | .capabilities.inlineMessageContext === true; | ||
| 7958 | requestMayHaveStarted = true; | ||
| 7959 | const result = await startThreadTurn({ | ||
| 7960 | environmentId, | ||
| 7961 | input: { | ||
| 7962 | threadId: targetThreadId, | ||
| 7963 | message: { | ||
| 7964 | messageId: newMessageId(), | ||
| 7965 | role: "user", | ||
| 7966 | text: | ||
| 7967 | context && !supportsInlineMessageContext | ||
| 7968 | ? serializeLegacyContextMessage({ | ||
| 7969 | text: target.text, | ||
| 7970 | records: context.records, | ||
| 7971 | }) | ||
| 7972 | : target.text, | ||
| 7973 | attachments, | ||
| 7974 | ...(context && supportsInlineMessageContext ? { context } : {}), | ||
| 7975 | }, | ||
| 7976 | modelSelection: target.selection, | ||
| 7977 | titleSeed: title, | ||
| 7978 | runtimeMode, | ||
| 7979 | interactionMode: target.interactionMode, | ||
| 7980 | bootstrap: { | ||
| 7981 | createThread: { | ||
| 7982 | projectId: activeProject.id, | ||
| 7983 | title, | ||
| 7984 | modelSelection: target.selection, | ||
| 7985 | runtimeMode, | ||
| 7986 | interactionMode: target.interactionMode, | ||
| 7987 | branch: activeThreadBranch, | ||
| 7988 | worktreePath: null, | ||
| 7989 | createdAt: messageCreatedAt, | ||
| 7990 | }, | ||
| 7991 | prepareWorktree: { | ||
| 7992 | projectCwd: activeProject.workspaceRoot, | ||
| 7993 | baseBranch: activeThreadBranch!, | ||
| 7994 | requireWorktree: true, | ||
| 7995 | branch: buildTemporaryWorktreeBranchName(randomHex), | ||
| 7996 | ...(startFromOrigin ? { startFromOrigin: true } : {}), | ||
| 7997 | }, | ||
| 7998 | runSetupScript: true, | ||
| 7999 | }, | ||
| 8000 | createdAt: messageCreatedAt, | ||
| 8001 | }, | ||
| 8002 | }); | ||
| 8003 | if (result._tag === "Failure") { | ||
| 8004 | const error = squashAtomCommandFailure(result); | ||
| 8005 | if (wasBootstrapThreadDeleted(error) || wasBootstrapThreadNotCreated(error)) { | ||
| 8006 | requestMayHaveStarted = false; | ||
| 8007 | } | ||
| 8008 | throw error; | ||
| 8009 | } | ||
| 8010 | startedCount += 1; | ||
| 8011 | } catch (error) { | ||
| 8012 | if (requestMayHaveStarted && !uncertainMultipleSubmissionsRef.current.has(retryKey)) { | ||
| 8013 | uncertainMultipleSubmissionsRef.current.set(retryKey, targetThreadId); | ||
| 8014 | } | ||
| 8015 | failedSelections.push(target.selection); | ||
| 8016 | const retainedThreadId = uncertainMultipleSubmissionsRef.current.get(retryKey); | ||
| 8017 | const failureToastId = toastManager.add( | ||
| 8018 | stackedThreadToast({ | ||
| 8019 | type: "error", | ||
| 8020 | title: `Could not start ${target.selection.model}`, | ||
| 8021 | description: error instanceof Error ? error.message : "Failed to send message.", | ||
| 8022 | ...(retainedThreadId | ||
| 8023 | ? { | ||
| 8024 | timeout: 0, | ||
| 8025 | data: { | ||
| 8026 | secondaryActionProps: { | ||
| 8027 | children: "Allow retry", | ||
| 8028 | onClick: () => { | ||
| 8029 | void readLocalApi() | ||
| 8030 | ?.dialogs.confirm( | ||
| 8031 | "The previous request may already be running. Check its thread first. Allow another send that could create a duplicate thread?", | ||
| 8032 | ) | ||
| 8033 | .then( | ||
| 8034 | (confirmed) => { | ||
| 8035 | if ( | ||
| 8036 | confirmed && | ||
| 8037 | uncertainMultipleSubmissionsRef.current.get(retryKey) === | ||
| 8038 | retainedThreadId | ||
| 8039 | ) { | ||
| 8040 | uncertainMultipleSubmissionsRef.current.delete(retryKey); | ||
| 8041 | toastManager.close(failureToastId); | ||
| 8042 | } | ||
| 8043 | }, | ||
| 8044 | () => undefined, | ||
| 8045 | ); | ||
| 8046 | }, | ||
| 8047 | }, | ||
| 8048 | }, | ||
| 8049 | actionProps: { | ||
| 8050 | children: "Open thread", | ||
| 8051 | onClick: () => { | ||
| 8052 | void navigate({ | ||
| 8053 | to: "/$environmentId/$threadId", | ||
| 8054 | params: buildThreadRouteParams( | ||
| 8055 | scopeThreadRef(environmentId, retainedThreadId), | ||
| 8056 | ), | ||
| 8057 | }); | ||
| 8058 | }, | ||
| 8059 | }, | ||
| 8060 | } | ||
| 8061 | : {}), | ||
| 8062 | }), | ||
| 8063 | ); | ||
| 8064 | } | ||
| 8065 | }), | ||
| 8066 | ); | ||
| 8067 | // Each request now owns its background thread. The original draft is | ||
| 8068 | // ready for another prompt while checkout and setup scripts finish. | ||
| 8069 | sendInFlightRef.current = false; | ||
| 8070 | resetLocalDispatch(); | ||
| 8071 | releasedComposer = true; | ||
| 8072 | await starts; | ||
| 8073 | if (startedCount > 0) { | ||
| 8074 | toastManager.add( | ||
| 8075 | stackedThreadToast({ | ||
| 8076 | type: "success", | ||
| 8077 | title: `Started ${startedCount} ${startedCount === 1 ? "thread" : "threads"} in background`, | ||
| 8078 | }), | ||
| 8079 | ); | ||
| 8080 | } | ||
| 8081 | if (failedSelections.length === 0 && turnUsesAttachmentUploads) { | ||
| 8082 | releaseDraftAttachments(composerAttachmentsSnapshot); | ||
| 8083 | } | ||
| 8084 | } catch (error) { | ||
| 8085 | failedSelections.push(...multipleModelSelections); | ||
| 8086 | setThreadError( | ||
| 8087 | threadIdForSend, | ||
| 8088 | error instanceof Error ? error.message : "Failed to send messages.", | ||
| 8089 | ); | ||
| 8090 | } finally { | ||
| 8091 | const restoreFailedDraft = () => { | ||
| 8092 | setMultipleModelSelections(failedSelections); | ||
| 8093 | if (clearedDraft) { | ||
| 8094 | setComposerDraftPrompt(composerDraftTarget, messageTextForSend); | ||
| 8095 | addComposerDraftImages( | ||
| 8096 | composerDraftTarget, | ||
| 8097 | composerImagesSnapshot.map(cloneComposerImageForRetry), | ||
| 8098 | ); | ||
| 8099 | addComposerDraftFiles(composerDraftTarget, composerFilesSnapshot); | ||
| 8100 | setComposerDraftTerminalContexts(composerDraftTarget, composerTerminalContextsSnapshot); | ||
| 8101 | setComposerDraftPreviewAnnotations( | ||
| 8102 | composerDraftTarget, | ||
| 8103 | composerPreviewAnnotationsSnapshot, | ||
| 8104 | ); | ||
| 8105 | setComposerDraftReviewComments(composerDraftTarget, composerReviewCommentsSnapshot); | ||
| 8106 | if (composerRef.current && currentRouteThreadKeyRef.current === routeThreadKey) { | ||
| 8107 | promptRef.current = messageTextForSend; | ||
| 8108 | composerRef.current.resetCursorState({ | ||
| 8109 | cursor: collapseExpandedComposerCursor( | ||
| 8110 | messageTextForSend, | ||
| 8111 | messageTextForSend.length, | ||
| 8112 | ), | ||
| 8113 | prompt: messageTextForSend, | ||
| 8114 | detectTrigger: true, | ||
| 8115 | }); | ||
| 8116 | } | ||
| 8117 | } | ||
| 8118 | }; | ||
| 8119 | if (failedSelections.length > 0) { | ||
| 8120 | if (canRestoreDraft() && composerRef.current) { | ||
| 8121 | restoreFailedDraft(); | ||
| 8122 | } else if (clearedDraft) { | ||
| 8123 | const recoveryToastId = toastManager.add( | ||
| 8124 | stackedThreadToast({ | ||
| 8125 | type: "error", | ||
| 8126 | title: "A background prompt could not be sent", | ||
| 8127 | description: | ||
| 8128 | "Your newer draft is unchanged. Restore the failed prompt when this composer is empty.", | ||
| 8129 | timeout: 0, | ||
| 8130 | actionProps: { | ||
| 8131 | children: "Restore prompt", | ||
| 8132 | onClick: () => { | ||
| 8133 | if ( | ||
| 8134 | !draftId || | ||
| 8135 | !useComposerDraftStore.getState().getDraftSession(draftId) || | ||
| 8136 | sendInFlightRef.current || | ||
| 8137 | composerDraftHasUserContent( | ||
| 8138 | useComposerDraftStore.getState().getComposerDraft(composerDraftTarget), | ||
| 8139 | ) | ||
| 8140 | ) { | ||
| 8141 | toastManager.update(recoveryToastId, { | ||
| 8142 | description: | ||
| 8143 | "Return to the original draft and send or clear its current prompt before restoring.", | ||
| 8144 | }); | ||
| 8145 | return; | ||
| 8146 | } | ||
| 8147 | restoreFailedDraft(); | ||
| 8148 | void navigate({ to: "/draft/$draftId", params: { draftId } }); | ||
| 8149 | toastManager.close(recoveryToastId); | ||
| 8150 | }, | ||
| 8151 | }, | ||
| 8152 | }), | ||
| 8153 | ); | ||
| 8154 | } | ||
| 8155 | } | ||
| 8156 | if (!releasedComposer) { | ||
| 8157 | sendInFlightRef.current = false; | ||
| 8158 | resetLocalDispatch(); | ||
| 8159 | } | ||
| 8160 | } | ||
| 8161 | return; | ||
| 8162 | } | ||
| 7795 | const optimisticAttachments = composerAttachmentsSnapshot.map((attachment) => | 8163 | const optimisticAttachments = composerAttachmentsSnapshot.map((attachment) => |
| 7796 | attachment.type === "image" | 8164 | attachment.type === "image" |
| 7797 | ? { | 8165 | ? { |
| ... | @@ -8868,7 +9236,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -8868,7 +9236,7 @@ export default function ChatView(props: ChatViewProps) { |
| 8868 | ); | 9236 | ); |
| 8869 | 9237 | ||
| 8870 | const onProviderModelSelect = useCallback( | 9238 | const onProviderModelSelect = useCallback( |
| 8871 | (instanceId: ProviderInstanceId, model: string) => { | 9239 | (instanceId: ProviderInstanceId, model: string, options?: { focusComposer?: boolean }) => { |
| 8872 | if (!activeThread) return; | 9240 | if (!activeThread) return; |
| 8873 | // Look up the configured instance so model normalization and custom | 9241 | // Look up the configured instance so model normalization and custom |
| 8874 | // model lookup stay scoped to that exact instance. Unknown instance ids | 9242 | // model lookup stay scoped to that exact instance. Unknown instance ids |
| ... | @@ -8880,7 +9248,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -8880,7 +9248,7 @@ export default function ChatView(props: ChatViewProps) { |
| 8880 | resolvedDriverKind !== null && | 9248 | resolvedDriverKind !== null && |
| 8881 | resolvedDriverKind !== lockedProvider | 9249 | resolvedDriverKind !== lockedProvider |
| 8882 | ) { | 9250 | ) { |
| 8883 | scheduleComposerFocus(); | 9251 | if (options?.focusComposer !== false) scheduleComposerFocus(); |
| 8884 | return; | 9252 | return; |
| 8885 | } | 9253 | } |
| 8886 | if (lockedProvider !== null && activeThread.session?.providerInstanceId) { | 9254 | if (lockedProvider !== null && activeThread.session?.providerInstanceId) { |
| ... | @@ -8892,7 +9260,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -8892,7 +9260,7 @@ export default function ChatView(props: ChatViewProps) { |
| 8892 | entry?.continuation?.groupKey && | 9260 | entry?.continuation?.groupKey && |
| 8893 | currentEntry.continuation.groupKey !== entry.continuation.groupKey | 9261 | currentEntry.continuation.groupKey !== entry.continuation.groupKey |
| 8894 | ) { | 9262 | ) { |
| 8895 | scheduleComposerFocus(); | 9263 | if (options?.focusComposer !== false) scheduleComposerFocus(); |
| 8896 | return; | 9264 | return; |
| 8897 | } | 9265 | } |
| 8898 | } | 9266 | } |
| ... | @@ -8903,7 +9271,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -8903,7 +9271,7 @@ export default function ChatView(props: ChatViewProps) { |
| 8903 | model, | 9271 | model, |
| 8904 | ); | 9272 | ); |
| 8905 | if (!resolvedModel) { | 9273 | if (!resolvedModel) { |
| 8906 | scheduleComposerFocus(); | 9274 | if (options?.focusComposer !== false) scheduleComposerFocus(); |
| 8907 | return; | 9275 | return; |
| 8908 | } | 9276 | } |
| 8909 | const nextModelSelection: ModelSelection = { | 9277 | const nextModelSelection: ModelSelection = { |
| ... | @@ -8923,7 +9291,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -8923,7 +9291,7 @@ export default function ChatView(props: ChatViewProps) { |
| 8923 | title: modelChangeBlockReason.title, | 9291 | title: modelChangeBlockReason.title, |
| 8924 | description: modelChangeBlockReason.description, | 9292 | description: modelChangeBlockReason.description, |
| 8925 | }); | 9293 | }); |
| 8926 | scheduleComposerFocus(); | 9294 | if (options?.focusComposer !== false) scheduleComposerFocus(); |
| 8927 | return; | 9295 | return; |
| 8928 | } | 9296 | } |
| 8929 | setComposerDraftModelSelection( | 9297 | setComposerDraftModelSelection( |
| ... | @@ -8932,7 +9300,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -8932,7 +9300,7 @@ export default function ChatView(props: ChatViewProps) { |
| 8932 | { explicit: true }, | 9300 | { explicit: true }, |
| 8933 | ); | 9301 | ); |
| 8934 | setStickyComposerModelSelection(nextModelSelection); | 9302 | setStickyComposerModelSelection(nextModelSelection); |
| 8935 | scheduleComposerFocus(); | 9303 | if (options?.focusComposer !== false) scheduleComposerFocus(); |
| 8936 | }, | 9304 | }, |
| 8937 | [ | 9305 | [ |
| 8938 | activeThread, | 9306 | activeThread, |
| ... | @@ -8946,6 +9314,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -8946,6 +9314,7 @@ export default function ChatView(props: ChatViewProps) { |
| 8946 | ); | 9314 | ); |
| 8947 | const onEnvModeChange = useCallback( | 9315 | const onEnvModeChange = useCallback( |
| 8948 | (mode: DraftThreadEnvMode) => { | 9316 | (mode: DraftThreadEnvMode) => { |
| 9317 | if (multipleModelSelections !== null) return; | ||
| 8949 | if (canOverrideServerThreadEnvMode) { | 9318 | if (canOverrideServerThreadEnvMode) { |
| 8950 | setPendingServerThreadEnvMode(mode); | 9319 | setPendingServerThreadEnvMode(mode); |
| 8951 | scheduleComposerFocus(); | 9320 | scheduleComposerFocus(); |
| ... | @@ -8968,6 +9337,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -8968,6 +9337,7 @@ export default function ChatView(props: ChatViewProps) { |
| 8968 | composerDraftTarget, | 9337 | composerDraftTarget, |
| 8969 | draftThread?.worktreePath, | 9338 | draftThread?.worktreePath, |
| 8970 | isLocalDraftThread, | 9339 | isLocalDraftThread, |
| 9340 | multipleModelSelections, | ||
| 8971 | activeProjectSettings.settings.newWorktreesStartFromOrigin, | 9341 | activeProjectSettings.settings.newWorktreesStartFromOrigin, |
| 8972 | setPendingServerThreadEnvMode, | 9342 | setPendingServerThreadEnvMode, |
| 8973 | scheduleComposerFocus, | 9343 | scheduleComposerFocus, |
| ... | @@ -9632,6 +10002,12 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -9632,6 +10002,12 @@ export default function ChatView(props: ChatViewProps) { |
| 9632 | <ComposerSurface.Host> | 10002 | <ComposerSurface.Host> |
| 9633 | <div ref={attachDraftHeroComposerAnchorRef} className="relative z-10"> | 10003 | <div ref={attachDraftHeroComposerAnchorRef} className="relative z-10"> |
| 9634 | <ChatComposer | 10004 | <ChatComposer |
| 10005 | multipleModelSelections={multipleModelSelections} | ||
| 10006 | supportsMultipleModels={ | ||
| 10007 | serverConfig?.environment.capabilities.requiredWorktreeBootstrap === | ||
| 10008 | true | ||
| 10009 | } | ||
| 10010 | onMultipleModelSelectionsChange={setMultipleModelSelections} | ||
| 9635 | composerRef={composerRef} | 10011 | composerRef={composerRef} |
| 9636 | composerDraftTarget={composerDraftTarget} | 10012 | composerDraftTarget={composerDraftTarget} |
| 9637 | environmentId={environmentId} | 10013 | environmentId={environmentId} |
| ... | @@ -9769,6 +10145,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -9769,6 +10145,7 @@ export default function ChatView(props: ChatViewProps) { |
| 9769 | {mountComposerContextStrip && ( | 10145 | {mountComposerContextStrip && ( |
| 9770 | <div className="pointer-events-auto"> | 10146 | <div className="pointer-events-auto"> |
| 9771 | <BranchToolbar | 10147 | <BranchToolbar |
| 10148 | forceNewWorktree={multipleModelSelections !== null} | ||
| 9772 | ref={branchToolbarRef} | 10149 | ref={branchToolbarRef} |
| 9773 | environmentId={activeThread.environmentId} | 10150 | environmentId={activeThread.environmentId} |
| 9774 | threadId={activeThread.id} | 10151 | threadId={activeThread.id} |
apps/web/src/components/chat/ChatComposer.tsx+77-6| ... | @@ -245,6 +245,7 @@ import { | ... | @@ -245,6 +245,7 @@ import { |
| 245 | import { useEnvironmentQuery } from "~/state/query"; | 245 | import { useEnvironmentQuery } from "~/state/query"; |
| 246 | import { useDebouncedValue } from "~/state/queries"; | 246 | import { useDebouncedValue } from "~/state/queries"; |
| 247 | import { ProviderModelPicker } from "./ProviderModelPicker"; | 247 | import { ProviderModelPicker } from "./ProviderModelPicker"; |
| 248 | import { resolveModelPickerSelectedModel } from "./ModelPickerContent"; | ||
| 248 | import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu"; | 249 | import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu"; |
| 249 | import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; | 250 | import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; |
| 250 | import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; | 251 | import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; |
| ... | @@ -1267,6 +1268,7 @@ export interface ChatComposerHandle { | ... | @@ -1267,6 +1268,7 @@ export interface ChatComposerHandle { |
| 1267 | selectedPromptEffort: string | null; | 1268 | selectedPromptEffort: string | null; |
| 1268 | selectedModelOptionsForDispatch: unknown; | 1269 | selectedModelOptionsForDispatch: unknown; |
| 1269 | selectedModelSelection: ModelSelection; | 1270 | selectedModelSelection: ModelSelection; |
| 1271 | multipleModelSelections: ReadonlyArray<ModelSelection> | null; | ||
| 1270 | providerAvailable: boolean; | 1272 | providerAvailable: boolean; |
| 1271 | selectedProvider: ProviderDriverKind; | 1273 | selectedProvider: ProviderDriverKind; |
| 1272 | selectedModel: string; | 1274 | selectedModel: string; |
| ... | @@ -1276,6 +1278,7 @@ export interface ChatComposerHandle { | ... | @@ -1276,6 +1278,7 @@ export interface ChatComposerHandle { |
| 1276 | }; | 1278 | }; |
| 1277 | /** Validate the fully composed text immediately before a provider turn starts. */ | 1279 | /** Validate the fully composed text immediately before a provider turn starts. */ |
| 1278 | validateProviderInput: (providerInput: string) => boolean; | 1280 | validateProviderInput: (providerInput: string) => boolean; |
| 1281 | setMultipleModelSelections: (selections: ReadonlyArray<ModelSelection>) => void; | ||
| 1279 | } | 1282 | } |
| 1280 | 1283 | ||
| 1281 | // -------------------------------------------------------------------------- | 1284 | // -------------------------------------------------------------------------- |
| ... | @@ -1292,6 +1295,11 @@ export interface ChatComposerProps { | ... | @@ -1292,6 +1295,11 @@ export interface ChatComposerProps { |
| 1292 | routeKind: "server" | "draft"; | 1295 | routeKind: "server" | "draft"; |
| 1293 | routeThreadRef: ScopedThreadRef; | 1296 | routeThreadRef: ScopedThreadRef; |
| 1294 | draftId: DraftId | null; | 1297 | draftId: DraftId | null; |
| 1298 | multipleModelSelections: ReadonlyArray<ModelSelection> | null; | ||
| 1299 | supportsMultipleModels: boolean; | ||
| 1300 | onMultipleModelSelectionsChange: React.Dispatch< | ||
| 1301 | React.SetStateAction<ReadonlyArray<ModelSelection> | null> | ||
| 1302 | >; | ||
| 1295 | 1303 | ||
| 1296 | // Thread context | 1304 | // Thread context |
| 1297 | activeThreadId: ThreadId | null; | 1305 | activeThreadId: ThreadId | null; |
| ... | @@ -1420,7 +1428,11 @@ export interface ChatComposerProps { | ... | @@ -1420,7 +1428,11 @@ export interface ChatComposerProps { |
| 1420 | cursorAdjacentToMention: boolean, | 1428 | cursorAdjacentToMention: boolean, |
| 1421 | ) => void; | 1429 | ) => void; |
| 1422 | 1430 | ||
| 1423 | onProviderModelSelect: (instanceId: ProviderInstanceId, model: string) => void; | 1431 | onProviderModelSelect: ( |
| 1432 | instanceId: ProviderInstanceId, | ||
| 1433 | model: string, | ||
| 1434 | options?: { focusComposer?: boolean }, | ||
| 1435 | ) => void; | ||
| 1424 | onOpenProviderSetup: (instanceId: ProviderInstanceId) => void; | 1436 | onOpenProviderSetup: (instanceId: ProviderInstanceId) => void; |
| 1425 | getModelDisabledReason: (instanceId: ProviderInstanceId, model: string) => string | null; | 1437 | getModelDisabledReason: (instanceId: ProviderInstanceId, model: string) => string | null; |
| 1426 | toggleInteractionMode: () => void; | 1438 | toggleInteractionMode: () => void; |
| ... | @@ -1449,6 +1461,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -1449,6 +1461,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1449 | routeKind, | 1461 | routeKind, |
| 1450 | routeThreadRef, | 1462 | routeThreadRef, |
| 1451 | draftId, | 1463 | draftId, |
| 1464 | multipleModelSelections, | ||
| 1465 | supportsMultipleModels, | ||
| 1466 | onMultipleModelSelectionsChange: setMultipleModelSelections, | ||
| 1452 | activeThreadId, | 1467 | activeThreadId, |
| 1453 | activeThreadEnvironmentId: _activeThreadEnvironmentId, | 1468 | activeThreadEnvironmentId: _activeThreadEnvironmentId, |
| 1454 | activeThread, | 1469 | activeThread, |
| ... | @@ -1847,7 +1862,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -1847,7 +1862,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1847 | ); | 1862 | ); |
| 1848 | const selectedInstanceId = | 1863 | const selectedInstanceId = |
| 1849 | selectedProviderEntry?.instanceId ?? NO_PROVIDER_MODEL_SELECTION.instanceId; | 1864 | selectedProviderEntry?.instanceId ?? NO_PROVIDER_MODEL_SELECTION.instanceId; |
| 1850 | const noProviderAvailable = selectedProviderEntry === undefined; | 1865 | const noProviderAvailable = |
| 1866 | selectedProviderEntry === undefined && multipleModelSelections === null; | ||
| 1851 | // Before the catalog arrives, every thread resolves to "no provider". Send | 1867 | // Before the catalog arrives, every thread resolves to "no provider". Send |
| 1852 | // stays blocked either way; only the chrome waits, keeping the picker with | 1868 | // stays blocked either way; only the chrome waits, keeping the picker with |
| 1853 | // the thread's own selection instead of swapping in the setup button and | 1869 | // the thread's own selection instead of swapping in the setup button and |
| ... | @@ -1883,9 +1899,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -1883,9 +1899,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1883 | ); | 1899 | ); |
| 1884 | const sendDisabledReason = | 1900 | const sendDisabledReason = |
| 1885 | externalSendDisabledReason ?? | 1901 | externalSendDisabledReason ?? |
| 1902 | (multipleModelSelections?.length === 0 ? "Select at least one model." : null) ?? | ||
| 1886 | (activePendingProgress | 1903 | (activePendingProgress |
| 1887 | ? attachmentBlockReason | 1904 | ? attachmentBlockReason |
| 1888 | : (attachmentBlockReason ?? providerSendBlockReason)); | 1905 | : (attachmentBlockReason ?? |
| 1906 | (multipleModelSelections === null ? providerSendBlockReason : null))); | ||
| 1889 | const isSendDisabled = sendDisabledReason !== null; | 1907 | const isSendDisabled = sendDisabledReason !== null; |
| 1890 | const selectedProviderStatus = useMemo( | 1908 | const selectedProviderStatus = useMemo( |
| 1891 | () => selectedProviderEntry?.snapshot ?? null, | 1909 | () => selectedProviderEntry?.snapshot ?? null, |
| ... | @@ -4943,7 +4961,42 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -4943,7 +4961,42 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4943 | ) : null} | 4961 | ) : null} |
| 4944 | <ProviderModelPicker | 4962 | <ProviderModelPicker |
| 4945 | isComposerOwned | 4963 | isComposerOwned |
| 4946 | disabled={providerCatalogPending} | 4964 | disabled={providerCatalogPending || isSendBusy} |
| 4965 | {...(routeKind === "draft" && supportsMultipleModels | ||
| 4966 | ? { | ||
| 4967 | ...(multipleModelSelections !== null | ||
| 4968 | ? { selectedModels: multipleModelSelections } | ||
| 4969 | : {}), | ||
| 4970 | onToggleModel: (instanceId: ProviderInstanceId, model: string) => { | ||
| 4971 | const current = multipleModelSelections ?? [selectedModelSelection]; | ||
| 4972 | const matchesModel = (selection: ModelSelection) => { | ||
| 4973 | if (selection.instanceId !== instanceId) return false; | ||
| 4974 | const entry = providerInstanceEntries.find( | ||
| 4975 | (entry) => entry.instanceId === selection.instanceId, | ||
| 4976 | ); | ||
| 4977 | const resolvedModel = resolveModelPickerSelectedModel({ | ||
| 4978 | driverKind: entry?.driverKind, | ||
| 4979 | model: selection.model, | ||
| 4980 | options: modelOptionsByInstance.get(selection.instanceId) ?? [], | ||
| 4981 | }); | ||
| 4982 | return (resolvedModel?.slug ?? selection.model) === model; | ||
| 4983 | }; | ||
| 4984 | const exists = current.some(matchesModel); | ||
| 4985 | const next = exists | ||
| 4986 | ? current.filter((selection) => !matchesModel(selection)) | ||
| 4987 | : [...current, createModelSelection(instanceId, model)]; | ||
| 4988 | if (next.length > 1) { | ||
| 4989 | setMultipleModelSelections(next); | ||
| 4990 | } else { | ||
| 4991 | setMultipleModelSelections(null); | ||
| 4992 | const remaining = next[0] ?? selectedModelSelection; | ||
| 4993 | onProviderModelSelect(remaining.instanceId, remaining.model, { | ||
| 4994 | focusComposer: false, | ||
| 4995 | }); | ||
| 4996 | } | ||
| 4997 | }, | ||
| 4998 | } | ||
| 4999 | : {})} | ||
| 4947 | activeInstanceId={ | 5000 | activeInstanceId={ |
| 4948 | providerCatalogPending | 5001 | providerCatalogPending |
| 4949 | ? (activeThreadModelSelection?.instanceId ?? selectedInstanceId) | 5002 | ? (activeThreadModelSelection?.instanceId ?? selectedInstanceId) |
| ... | @@ -4983,7 +5036,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -4983,7 +5036,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4983 | : {})} | 5036 | : {})} |
| 4984 | onOpenChange={setIsComposerModelPickerOpen} | 5037 | onOpenChange={setIsComposerModelPickerOpen} |
| 4985 | getModelDisabledReason={getModelDisabledReason} | 5038 | getModelDisabledReason={getModelDisabledReason} |
| 4986 | onInstanceModelChange={onProviderModelSelect} | 5039 | onInstanceModelChange={(instanceId, model) => { |
| 5040 | setMultipleModelSelections(null); | ||
| 5041 | onProviderModelSelect(instanceId, model); | ||
| 5042 | }} | ||
| 4987 | onOpenProviderSetup={onOpenProviderSetup} | 5043 | onOpenProviderSetup={onOpenProviderSetup} |
| 4988 | /> | 5044 | /> |
| 4989 | 5045 | ||
| ... | @@ -5902,13 +5958,25 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -5902,13 +5958,25 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5902 | selectedPromptEffort, | 5958 | selectedPromptEffort, |
| 5903 | selectedModelOptionsForDispatch, | 5959 | selectedModelOptionsForDispatch, |
| 5904 | selectedModelSelection, | 5960 | selectedModelSelection, |
| 5905 | providerAvailable: !noProviderAvailable && providerSendBlockReason === null, | 5961 | multipleModelSelections: |
| 5962 | routeKind === "draft" && multipleModelSelections !== null | ||
| 5963 | ? multipleModelSelections.map((selection) => | ||
| 5964 | selection.instanceId === selectedModelSelection.instanceId && | ||
| 5965 | selection.model === selectedModelSelection.model | ||
| 5966 | ? selectedModelSelection | ||
| 5967 | : selection, | ||
| 5968 | ) | ||
| 5969 | : null, | ||
| 5970 | providerAvailable: | ||
| 5971 | multipleModelSelections !== null || | ||
| 5972 | (!noProviderAvailable && providerSendBlockReason === null), | ||
| 5906 | selectedProvider, | 5973 | selectedProvider, |
| 5907 | selectedModel, | 5974 | selectedModel, |
| 5908 | selectedProviderModels, | 5975 | selectedProviderModels, |
| 5909 | interactionMode, | 5976 | interactionMode, |
| 5910 | interactionModeEnabled: planModeUiEnabled, | 5977 | interactionModeEnabled: planModeUiEnabled, |
| 5911 | }), | 5978 | }), |
| 5979 | setMultipleModelSelections, | ||
| 5912 | validateProviderInput: (providerInput: string) => { | 5980 | validateProviderInput: (providerInput: string) => { |
| 5913 | const validationMessage = getComposerSubmissionValidationMessage({ | 5981 | const validationMessage = getComposerSubmissionValidationMessage({ |
| 5914 | prompt: promptRef.current, | 5982 | prompt: promptRef.current, |
| ... | @@ -5951,6 +6019,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -5951,6 +6019,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5951 | selectedModel, | 6019 | selectedModel, |
| 5952 | selectedModelOptionsForDispatch, | 6020 | selectedModelOptionsForDispatch, |
| 5953 | selectedModelSelection, | 6021 | selectedModelSelection, |
| 6022 | multipleModelSelections, | ||
| 6023 | setMultipleModelSelections, | ||
| 6024 | routeKind, | ||
| 5954 | noProviderAvailable, | 6025 | noProviderAvailable, |
| 5955 | providerSendBlockReason, | 6026 | providerSendBlockReason, |
| 5956 | selectedPromptEffort, | 6027 | selectedPromptEffort, |
apps/web/src/components/chat/ModelListRow.tsx+5-1| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | import { type ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts"; | 1 | import { type ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts"; |
| 2 | import { memo } from "react"; | 2 | import { memo } from "react"; |
| 3 | import { StarIcon } from "lucide-react"; | 3 | import { CheckIcon, StarIcon } from "lucide-react"; |
| 4 | import { | 4 | import { |
| 5 | getDisplayModelName, | 5 | getDisplayModelName, |
| 6 | getTriggerDisplayModelLabel, | 6 | getTriggerDisplayModelLabel, |
| ... | @@ -31,6 +31,7 @@ export const ModelListRow = memo(function ModelListRow(props: { | ... | @@ -31,6 +31,7 @@ export const ModelListRow = memo(function ModelListRow(props: { |
| 31 | providerAccentColor?: string | undefined; | 31 | providerAccentColor?: string | undefined; |
| 32 | isFavorite: boolean; | 32 | isFavorite: boolean; |
| 33 | isSelected: boolean; | 33 | isSelected: boolean; |
| 34 | showSelection?: boolean; | ||
| 34 | showProvider: boolean; | 35 | showProvider: boolean; |
| 35 | preferShortName?: boolean; | 36 | preferShortName?: boolean; |
| 36 | useTriggerLabel?: boolean; | 37 | useTriggerLabel?: boolean; |
| ... | @@ -94,6 +95,9 @@ export const ModelListRow = memo(function ModelListRow(props: { | ... | @@ -94,6 +95,9 @@ export const ModelListRow = memo(function ModelListRow(props: { |
| 94 | </div> | 95 | </div> |
| 95 | 96 | ||
| 96 | <div className="flex shrink-0 items-center gap-1.5"> | 97 | <div className="flex shrink-0 items-center gap-1.5"> |
| 98 | {props.showSelection && props.isSelected ? ( | ||
| 99 | <CheckIcon className="size-3.5" aria-hidden="true" /> | ||
| 100 | ) : null} | ||
| 97 | {props.jumpLabel ? ( | 101 | {props.jumpLabel ? ( |
| 98 | <Kbd className="h-4 min-w-0 rounded-sm px-1.5 text-[10px]">{props.jumpLabel}</Kbd> | 102 | <Kbd className="h-4 min-w-0 rounded-sm px-1.5 text-[10px]">{props.jumpLabel}</Kbd> |
| 99 | ) : null} | 103 | ) : null} |
apps/web/src/components/chat/ModelPickerContent.tsx+55-11| ... | @@ -151,6 +151,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { | ... | @@ -151,6 +151,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 151 | /** The instance currently selected in the composer (combobox "value"). */ | 151 | /** The instance currently selected in the composer (combobox "value"). */ |
| 152 | activeInstanceId: ProviderInstanceId; | 152 | activeInstanceId: ProviderInstanceId; |
| 153 | model: string; | 153 | model: string; |
| 154 | selectedModels?: ReadonlyArray<{ instanceId: ProviderInstanceId; model: string }>; | ||
| 155 | onToggleModel?: (instanceId: ProviderInstanceId, model: string) => void; | ||
| 154 | /** | 156 | /** |
| 155 | * When set, the picker is locked to the given driver kind — typically | 157 | * When set, the picker is locked to the given driver kind — typically |
| 156 | * because the user is editing a previously-sent message and can't change | 158 | * because the user is editing a previously-sent message and can't change |
| ... | @@ -186,6 +188,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { | ... | @@ -186,6 +188,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 186 | instanceEntries, | 188 | instanceEntries, |
| 187 | getModelDisabledReason, | 189 | getModelDisabledReason, |
| 188 | onInstanceModelChange, | 190 | onInstanceModelChange, |
| 191 | onToggleModel, | ||
| 189 | } = props; | 192 | } = props; |
| 190 | const [searchQuery, setSearchQuery] = useState(""); | 193 | const [searchQuery, setSearchQuery] = useState(""); |
| 191 | const [showTopScrollFade, setShowTopScrollFade] = useState(false); | 194 | const [showTopScrollFade, setShowTopScrollFade] = useState(false); |
| ... | @@ -207,6 +210,23 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { | ... | @@ -207,6 +210,23 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 207 | const activeModelKey = activeModelSlug | 210 | const activeModelKey = activeModelSlug |
| 208 | ? modelPickerModelKey(props.activeInstanceId, activeModelSlug) | 211 | ? modelPickerModelKey(props.activeInstanceId, activeModelSlug) |
| 209 | : null; | 212 | : null; |
| 213 | const selectedModelKeys = useMemo( | ||
| 214 | () => | ||
| 215 | props.selectedModels?.map((selection) => { | ||
| 216 | const entry = instanceEntries.find((entry) => entry.instanceId === selection.instanceId); | ||
| 217 | const model = resolveModelPickerSelectedModel({ | ||
| 218 | driverKind: entry?.driverKind, | ||
| 219 | model: selection.model, | ||
| 220 | options: modelOptionsByInstance.get(selection.instanceId) ?? [], | ||
| 221 | }); | ||
| 222 | return modelPickerModelKey(selection.instanceId, model?.slug ?? selection.model); | ||
| 223 | }), | ||
| 224 | [instanceEntries, modelOptionsByInstance, props.selectedModels], | ||
| 225 | ); | ||
| 226 | const selectedModelKeySet = useMemo( | ||
| 227 | () => new Set(selectedModelKeys ?? (activeModelKey ? [activeModelKey] : [])), | ||
| 228 | [selectedModelKeys, activeModelKey], | ||
| 229 | ); | ||
| 210 | const activeInstanceHasSelectableUnavailableModel = | 230 | const activeInstanceHasSelectableUnavailableModel = |
| 211 | activeEntry !== undefined && | 231 | activeEntry !== undefined && |
| 212 | (modelOptionsByInstance.get(props.activeInstanceId) ?? []).some((option) => | 232 | (modelOptionsByInstance.get(props.activeInstanceId) ?? []).some((option) => |
| ... | @@ -575,7 +595,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { | ... | @@ -575,7 +595,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 575 | }, []); | 595 | }, []); |
| 576 | 596 | ||
| 577 | const handleModelSelect = useCallback( | 597 | const handleModelSelect = useCallback( |
| 578 | (modelSlug: string, instanceId: ProviderInstanceId) => { | 598 | (modelSlug: string, instanceId: ProviderInstanceId, additive = false) => { |
| 579 | if (getModelDisabledReason?.(instanceId, modelSlug)) { | 599 | if (getModelDisabledReason?.(instanceId, modelSlug)) { |
| 580 | return; | 600 | return; |
| 581 | } | 601 | } |
| ... | @@ -592,10 +612,20 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { | ... | @@ -592,10 +612,20 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 592 | // normalization rules, so pass the driver kind here. | 612 | // normalization rules, so pass the driver kind here. |
| 593 | const resolvedModel = resolveSelectableModel(entry.driverKind, modelSlug, options); | 613 | const resolvedModel = resolveSelectableModel(entry.driverKind, modelSlug, options); |
| 594 | if (resolvedModel) { | 614 | if (resolvedModel) { |
| 595 | onInstanceModelChange(instanceId, resolvedModel); | 615 | if (additive && onToggleModel) { |
| 616 | onToggleModel(instanceId, resolvedModel); | ||
| 617 | } else { | ||
| 618 | onInstanceModelChange(instanceId, resolvedModel); | ||
| 619 | } | ||
| 596 | } | 620 | } |
| 597 | }, | 621 | }, |
| 598 | [entryByInstanceId, getModelDisabledReason, modelOptionsByInstance, onInstanceModelChange], | 622 | [ |
| 623 | entryByInstanceId, | ||
| 624 | getModelDisabledReason, | ||
| 625 | modelOptionsByInstance, | ||
| 626 | onInstanceModelChange, | ||
| 627 | onToggleModel, | ||
| 628 | ], | ||
| 599 | ); | 629 | ); |
| 600 | 630 | ||
| 601 | const toggleFavorite = useCallback( | 631 | const toggleFavorite = useCallback( |
| ... | @@ -701,8 +731,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { | ... | @@ -701,8 +731,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 701 | return mapping.size > 0 ? mapping : EMPTY_MODEL_JUMP_LABELS; | 731 | return mapping.size > 0 ? mapping : EMPTY_MODEL_JUMP_LABELS; |
| 702 | }, [keybindings, modelJumpCommandByKey, modelJumpShortcutContext]); | 732 | }, [keybindings, modelJumpCommandByKey, modelJumpShortcutContext]); |
| 703 | const modelListExtraData = useMemo( | 733 | const modelListExtraData = useMemo( |
| 704 | () => ({ favoritesSet, modelJumpLabelByKey }), | 734 | () => ({ favoritesSet, modelJumpLabelByKey, activeModelKey, selectedModelKeySet }), |
| 705 | [favoritesSet, modelJumpLabelByKey], | 735 | [favoritesSet, modelJumpLabelByKey, activeModelKey, selectedModelKeySet], |
| 706 | ); | 736 | ); |
| 707 | 737 | ||
| 708 | useEffect(() => { | 738 | useEffect(() => { |
| ... | @@ -804,7 +834,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { | ... | @@ -804,7 +834,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 804 | )} | 834 | )} |
| 805 | 835 | ||
| 806 | {/* Main content area */} | 836 | {/* Main content area */} |
| 807 | <Combobox | 837 | <Combobox<string, boolean> |
| 808 | inline | 838 | inline |
| 809 | items={allItemKeys} | 839 | items={allItemKeys} |
| 810 | filteredItems={filteredItemKeys} | 840 | filteredItems={filteredItemKeys} |
| ... | @@ -812,7 +842,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { | ... | @@ -812,7 +842,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 812 | autoHighlight | 842 | autoHighlight |
| 813 | open | 843 | open |
| 814 | virtualized | 844 | virtualized |
| 815 | value={activeModelKey} | 845 | multiple={onToggleModel !== undefined} |
| 846 | value={onToggleModel ? [...selectedModelKeySet] : activeModelKey} | ||
| 816 | onItemHighlighted={(modelKey, eventDetails) => { | 847 | onItemHighlighted={(modelKey, eventDetails) => { |
| 817 | highlightedModelKeyRef.current = typeof modelKey === "string" ? modelKey : null; | 848 | highlightedModelKeyRef.current = typeof modelKey === "string" ? modelKey : null; |
| 818 | if (eventDetails.reason === "keyboard" && eventDetails.index >= 0) { | 849 | if (eventDetails.reason === "keyboard" && eventDetails.index >= 0) { |
| ... | @@ -822,7 +853,11 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { | ... | @@ -822,7 +853,11 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 822 | }); | 853 | }); |
| 823 | } | 854 | } |
| 824 | }} | 855 | }} |
| 825 | onValueChange={(modelKey) => { | 856 | onValueChange={(value, details) => { |
| 857 | const modelKey = Array.isArray(value) | ||
| 858 | ? (value.find((key) => !selectedModelKeySet.has(key)) ?? | ||
| 859 | [...selectedModelKeySet].find((key) => !value.includes(key))) | ||
| 860 | : value; | ||
| 826 | if (typeof modelKey !== "string") { | 861 | if (typeof modelKey !== "string") { |
| 827 | return; | 862 | return; |
| 828 | } | 863 | } |
| ... | @@ -833,7 +868,11 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { | ... | @@ -833,7 +868,11 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 833 | } | 868 | } |
| 834 | const model = parseModelPickerModelKey(modelKey); | 869 | const model = parseModelPickerModelKey(modelKey); |
| 835 | if (model) { | 870 | if (model) { |
| 836 | handleModelSelect(model.slug, model.instanceId); | 871 | handleModelSelect( |
| 872 | model.slug, | ||
| 873 | model.instanceId, | ||
| 874 | "shiftKey" in details.event && details.event.shiftKey === true, | ||
| 875 | ); | ||
| 837 | } | 876 | } |
| 838 | }} | 877 | }} |
| 839 | > | 878 | > |
| ... | @@ -901,7 +940,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { | ... | @@ -901,7 +940,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 901 | } | 940 | } |
| 902 | const model = parseModelPickerModelKey(highlightedModelKeyRef.current); | 941 | const model = parseModelPickerModelKey(highlightedModelKeyRef.current); |
| 903 | if (model) { | 942 | if (model) { |
| 904 | handleModelSelect(model.slug, model.instanceId); | 943 | handleModelSelect(model.slug, model.instanceId, e.shiftKey); |
| 905 | } | 944 | } |
| 906 | return; | 945 | return; |
| 907 | } | 946 | } |
| ... | @@ -967,7 +1006,12 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { | ... | @@ -967,7 +1006,12 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 967 | isFavorite={favoritesSet.has( | 1006 | isFavorite={favoritesSet.has( |
| 968 | providerModelKey(model.instanceId, model.slug), | 1007 | providerModelKey(model.instanceId, model.slug), |
| 969 | )} | 1008 | )} |
| 970 | isSelected={modelKey === activeModelKey} | 1009 | isSelected={ |
| 1010 | selectedModelKeys !== undefined | ||
| 1011 | ? selectedModelKeySet.has(modelKey) | ||
| 1012 | : modelKey === activeModelKey | ||
| 1013 | } | ||
| 1014 | showSelection={selectedModelKeys !== undefined} | ||
| 971 | showProvider | 1015 | showProvider |
| 972 | preferShortName={!isLocked} | 1016 | preferShortName={!isLocked} |
| 973 | useTriggerLabel={false} | 1017 | useTriggerLabel={false} |
apps/web/src/components/chat/ProviderModelPicker.tsx+66-6| ... | @@ -34,6 +34,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { | ... | @@ -34,6 +34,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 34 | */ | 34 | */ |
| 35 | activeInstanceId: ProviderInstanceId; | 35 | activeInstanceId: ProviderInstanceId; |
| 36 | model: string; | 36 | model: string; |
| 37 | selectedModels?: ReadonlyArray<{ instanceId: ProviderInstanceId; model: string }>; | ||
| 38 | onToggleModel?: (instanceId: ProviderInstanceId, model: string) => void; | ||
| 37 | lockedProvider: ProviderDriverKind | null; | 39 | lockedProvider: ProviderDriverKind | null; |
| 38 | lockedContinuationGroupKey?: string | null; | 40 | lockedContinuationGroupKey?: string | null; |
| 39 | /** Instance entries rendered in the sidebar + used to resolve display name. */ | 41 | /** Instance entries rendered in the sidebar + used to resolve display name. */ |
| ... | @@ -158,9 +160,37 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { | ... | @@ -158,9 +160,37 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 158 | const shortcutLabel = props.keybindings | 160 | const shortcutLabel = props.keybindings |
| 159 | ? shortcutLabelForCommand(props.keybindings, "modelPicker.toggle") | 161 | ? shortcutLabelForCommand(props.keybindings, "modelPicker.toggle") |
| 160 | : null; | 162 | : null; |
| 163 | const selectedEntries = props.selectedModels?.map((selection) => { | ||
| 164 | const entry = props.instanceEntries.find( | ||
| 165 | (candidate) => candidate.instanceId === selection.instanceId, | ||
| 166 | ); | ||
| 167 | const model = resolveModelPickerSelectedModel({ | ||
| 168 | driverKind: entry?.driverKind, | ||
| 169 | model: selection.model, | ||
| 170 | options: props.modelOptionsByInstance.get(selection.instanceId) ?? [], | ||
| 171 | }); | ||
| 172 | return { | ||
| 173 | ...selection, | ||
| 174 | entry, | ||
| 175 | label: model | ||
| 176 | ? `${getTriggerDisplayModelName(model)}${model.isUnavailable ? " (Unavailable)" : ""}` | ||
| 177 | : selection.model, | ||
| 178 | }; | ||
| 179 | }); | ||
| 180 | const multipleLabel = selectedEntries | ||
| 181 | ? selectedEntries.length === 0 | ||
| 182 | ? "Choose models" | ||
| 183 | : `${selectedEntries | ||
| 184 | .slice(0, 2) | ||
| 185 | .map((selection) => selection.label) | ||
| 186 | .join(", ")}${selectedEntries.length > 2 ? `, ${selectedEntries.length - 2} more` : ""}` | ||
| 187 | : undefined; | ||
| 188 | const allModelNames = selectedEntries | ||
| 189 | ? selectedEntries.map((selection) => selection.label).join(", ") || "Choose models" | ||
| 190 | : undefined; | ||
| 161 | const triggerTooltipContent = shortcutLabel | 191 | const triggerTooltipContent = shortcutLabel |
| 162 | ? `${props.triggerLabel ?? triggerLabel} · ${shortcutLabel}` | 192 | ? `${props.triggerLabel ?? allModelNames ?? triggerLabel} · ${shortcutLabel}` |
| 163 | : (props.triggerLabel ?? triggerLabel); | 193 | : (props.triggerLabel ?? allModelNames ?? triggerLabel); |
| 164 | 194 | ||
| 165 | return ( | 195 | return ( |
| 166 | <Popover | 196 | <Popover |
| ... | @@ -176,7 +206,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { | ... | @@ -176,7 +206,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 176 | <PopoverTrigger | 206 | <PopoverTrigger |
| 177 | render={ | 207 | render={ |
| 178 | <ComposerControl | 208 | <ComposerControl |
| 179 | aria-label={props.triggerAriaLabel} | 209 | aria-label={props.triggerAriaLabel ?? allModelNames} |
| 180 | variant={props.triggerVariant ?? "ghost"} | 210 | variant={props.triggerVariant ?? "ghost"} |
| 181 | size={size} | 211 | size={size} |
| 182 | data-chat-provider-model-picker="true" | 212 | data-chat-provider-model-picker="true" |
| ... | @@ -192,7 +222,29 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { | ... | @@ -192,7 +222,29 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 192 | <span | 222 | <span |
| 193 | className={cn("flex min-w-0 flex-1 items-center", size === "xs" ? "gap-1" : "gap-1.5")} | 223 | className={cn("flex min-w-0 flex-1 items-center", size === "xs" ? "gap-1" : "gap-1.5")} |
| 194 | > | 224 | > |
| 195 | {activeEntry && props.triggerLabel === undefined ? ( | 225 | {selectedEntries && props.triggerLabel === undefined ? ( |
| 226 | <span className="flex shrink-0 items-center -space-x-1" aria-hidden="true"> | ||
| 227 | {selectedEntries | ||
| 228 | .slice(0, 3) | ||
| 229 | .map((selection) => | ||
| 230 | selection.entry ? ( | ||
| 231 | <ProviderInstanceIcon | ||
| 232 | key={`${selection.instanceId}:${selection.model}`} | ||
| 233 | driverKind={selection.entry.driverKind} | ||
| 234 | displayName={selection.entry.displayName} | ||
| 235 | accentColor={selection.entry.accentColor} | ||
| 236 | className="size-4 rounded-full bg-[var(--chat-composer-glass-surface,var(--background))] ring-2 ring-[var(--chat-composer-glass-surface,var(--background))]" | ||
| 237 | iconClassName="size-4" | ||
| 238 | /> | ||
| 239 | ) : null, | ||
| 240 | )} | ||
| 241 | {selectedEntries.length > 3 ? ( | ||
| 242 | <span className="relative z-30 flex size-4 items-center justify-center rounded-full bg-[var(--chat-composer-glass-surface,var(--background))] text-[9px] ring-2 ring-[var(--chat-composer-glass-surface,var(--background))]"> | ||
| 243 | +{selectedEntries.length - 3} | ||
| 244 | </span> | ||
| 245 | ) : null} | ||
| 246 | </span> | ||
| 247 | ) : activeEntry && props.triggerLabel === undefined ? ( | ||
| 196 | <ProviderInstanceIcon | 248 | <ProviderInstanceIcon |
| 197 | driverKind={activeEntry.driverKind} | 249 | driverKind={activeEntry.driverKind} |
| 198 | displayName={activeEntry.displayName} | 250 | displayName={activeEntry.displayName} |
| ... | @@ -216,11 +268,11 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { | ... | @@ -216,11 +268,11 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 216 | /> | 268 | /> |
| 217 | } | 269 | } |
| 218 | > | 270 | > |
| 219 | {props.triggerLabel ?? triggerTitle} | 271 | {props.triggerLabel ?? multipleLabel ?? triggerTitle} |
| 220 | </TooltipTrigger> | 272 | </TooltipTrigger> |
| 221 | <TooltipPopup side="top">{triggerTooltipContent}</TooltipPopup> | 273 | <TooltipPopup side="top">{triggerTooltipContent}</TooltipPopup> |
| 222 | </Tooltip> | 274 | </Tooltip> |
| 223 | {selectedModel?.isUnavailable && props.triggerLabel === undefined ? ( | 275 | {selectedModel?.isUnavailable && !selectedEntries && props.triggerLabel === undefined ? ( |
| 224 | <Badge variant="outline" size="sm"> | 276 | <Badge variant="outline" size="sm"> |
| 225 | Unavailable | 277 | Unavailable |
| 226 | </Badge> | 278 | </Badge> |
| ... | @@ -239,6 +291,14 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { | ... | @@ -239,6 +291,14 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 239 | <ModelPickerContent | 291 | <ModelPickerContent |
| 240 | activeInstanceId={activeInstanceId} | 292 | activeInstanceId={activeInstanceId} |
| 241 | model={props.model} | 293 | model={props.model} |
| 294 | {...(props.selectedModels !== undefined ? { selectedModels: props.selectedModels } : {})} | ||
| 295 | {...(props.onToggleModel | ||
| 296 | ? { | ||
| 297 | onToggleModel: (instanceId: ProviderInstanceId, model: string) => { | ||
| 298 | if (!props.disabled) props.onToggleModel?.(instanceId, model); | ||
| 299 | }, | ||
| 300 | } | ||
| 301 | : {})} | ||
| 242 | lockedProvider={props.lockedProvider} | 302 | lockedProvider={props.lockedProvider} |
| 243 | lockedContinuationGroupKey={props.lockedContinuationGroupKey ?? null} | 303 | lockedContinuationGroupKey={props.lockedContinuationGroupKey ?? null} |
| 244 | instanceEntries={props.instanceEntries} | 304 | instanceEntries={props.instanceEntries} |
docs/user/thread-sidebar.md+5| ... | @@ -20,6 +20,11 @@ on Windows and Linux to start a new thread and immediately open another draft. T | ... | @@ -20,6 +20,11 @@ on Windows and Linux to start a new thread and immediately open another draft. T |
| 20 | next draft keeps the workspace mode and base branch you selected. With **New | 20 | next draft keeps the workspace mode and base branch you selected. With **New |
| 21 | worktree**, each background submission creates its own worktree. | 21 | worktree**, each background submission creates its own worktree. |
| 22 | 22 | ||
| 23 | To send the same prompt to several models on web or desktop, **Shift-click** models | ||
| 24 | in a new thread's model picker to add or remove them. A regular click returns to a | ||
| 25 | single model. Choose a base branch and send. Each selection starts a separate thread | ||
| 26 | and worktree while you stay in the new thread composer. This requires a Git project. | ||
| 27 | |||
| 23 | ## Pin and reorder threads | 28 | ## Pin and reorder threads |
| 24 | 29 | ||
| 25 | Pin a thread from its menu to keep it above your active work. | 30 | Pin a thread from its menu to keep it above your active work. |
packages/client-runtime/src/errors/orchestration.test.ts+28-1| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | import { OrchestrationDispatchCommandError } from "@t3tools/contracts"; | 1 | import { OrchestrationDispatchCommandError } from "@t3tools/contracts"; |
| 2 | import { describe, expect, it } from "vite-plus/test"; | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | ||
| 4 | import { wasBootstrapThreadDeleted } from "./orchestration.ts"; | 4 | import { wasBootstrapThreadDeleted, wasBootstrapThreadNotCreated } from "./orchestration.ts"; |
| 5 | 5 | ||
| 6 | describe("wasBootstrapThreadDeleted", () => { | 6 | describe("wasBootstrapThreadDeleted", () => { |
| 7 | it("accepts only a confirmed deleted bootstrap thread", () => { | 7 | it("accepts only a confirmed deleted bootstrap thread", () => { |
| ... | @@ -21,3 +21,30 @@ describe("wasBootstrapThreadDeleted", () => { | ... | @@ -21,3 +21,30 @@ describe("wasBootstrapThreadDeleted", () => { |
| 21 | expect(wasBootstrapThreadDeleted(new Error("connection lost"))).toBe(false); | 21 | expect(wasBootstrapThreadDeleted(new Error("connection lost"))).toBe(false); |
| 22 | }); | 22 | }); |
| 23 | }); | 23 | }); |
| 24 | |||
| 25 | describe("wasBootstrapThreadNotCreated", () => { | ||
| 26 | it("accepts only a confirmed never-created bootstrap thread", () => { | ||
| 27 | const notCreated = new OrchestrationDispatchCommandError({ | ||
| 28 | message: "A separate worktree requires a base commit.", | ||
| 29 | bootstrapThreadDisposition: "not-created", | ||
| 30 | }); | ||
| 31 | expect(wasBootstrapThreadNotCreated(notCreated)).toBe(true); | ||
| 32 | expect(wasBootstrapThreadDeleted(notCreated)).toBe(false); | ||
| 33 | expect( | ||
| 34 | wasBootstrapThreadNotCreated( | ||
| 35 | new OrchestrationDispatchCommandError({ | ||
| 36 | message: "Failed to create worktree.", | ||
| 37 | bootstrapThreadDisposition: "deleted", | ||
| 38 | }), | ||
| 39 | ), | ||
| 40 | ).toBe(false); | ||
| 41 | expect( | ||
| 42 | wasBootstrapThreadNotCreated( | ||
| 43 | new OrchestrationDispatchCommandError({ | ||
| 44 | message: "Failed to create worktree.", | ||
| 45 | }), | ||
| 46 | ), | ||
| 47 | ).toBe(false); | ||
| 48 | expect(wasBootstrapThreadNotCreated(new Error("connection lost"))).toBe(false); | ||
| 49 | }); | ||
| 50 | }); |
packages/client-runtime/src/errors/orchestration.ts+6| ... | @@ -8,3 +8,9 @@ export function wasBootstrapThreadDeleted(error: unknown): boolean { | ... | @@ -8,3 +8,9 @@ export function wasBootstrapThreadDeleted(error: unknown): boolean { |
| 8 | isOrchestrationDispatchCommandError(error) && error.bootstrapThreadDisposition === "deleted" | 8 | isOrchestrationDispatchCommandError(error) && error.bootstrapThreadDisposition === "deleted" |
| 9 | ); | 9 | ); |
| 10 | } | 10 | } |
| 11 | |||
| 12 | export function wasBootstrapThreadNotCreated(error: unknown): boolean { | ||
| 13 | return ( | ||
| 14 | isOrchestrationDispatchCommandError(error) && error.bootstrapThreadDisposition === "not-created" | ||
| 15 | ); | ||
| 16 | } |
packages/contracts/src/environment.test.ts+10| ... | @@ -14,6 +14,16 @@ const descriptor = { | ... | @@ -14,6 +14,16 @@ const descriptor = { |
| 14 | } as const; | 14 | } as const; |
| 15 | 15 | ||
| 16 | describe("ExecutionEnvironmentDescriptor", () => { | 16 | describe("ExecutionEnvironmentDescriptor", () => { |
| 17 | it("requires an advertised required-worktree bootstrap capability", () => { | ||
| 18 | expect(decodeDescriptor(descriptor).capabilities.requiredWorktreeBootstrap).toBeUndefined(); | ||
| 19 | expect( | ||
| 20 | decodeDescriptor({ | ||
| 21 | ...descriptor, | ||
| 22 | capabilities: { ...descriptor.capabilities, requiredWorktreeBootstrap: true }, | ||
| 23 | }).capabilities.requiredWorktreeBootstrap, | ||
| 24 | ).toBe(true); | ||
| 25 | }); | ||
| 26 | |||
| 17 | it("treats a missing pull-request capability as unsupported under version skew", () => { | 27 | it("treats a missing pull-request capability as unsupported under version skew", () => { |
| 18 | expect(decodeDescriptor(descriptor).capabilities.pullRequests).toBeUndefined(); | 28 | expect(decodeDescriptor(descriptor).capabilities.pullRequests).toBeUndefined(); |
| 19 | }); | 29 | }); |
packages/contracts/src/environment.ts+2| ... | @@ -99,6 +99,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ | ... | @@ -99,6 +99,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ |
| 99 | Absent on servers from before inline context shipped, which drop the records and forward | 99 | Absent on servers from before inline context shipped, which drop the records and forward |
| 100 | the links as literal text -- so a client must serialize context the legacy way for them. */ | 100 | the links as literal text -- so a client must serialize context the legacy way for them. */ |
| 101 | inlineMessageContext: Schema.optionalKey(Schema.Boolean), | 101 | inlineMessageContext: Schema.optionalKey(Schema.Boolean), |
| 102 | /** Server rejects required worktrees instead of falling back to the project checkout. */ | ||
| 103 | requiredWorktreeBootstrap: Schema.optionalKey(Schema.Boolean), | ||
| 102 | /** Server understands thread.settle / thread.unsettle commands. Absent on | 104 | /** Server understands thread.settle / thread.unsettle commands. Absent on |
| 103 | pre-settlement servers, so clients treat missing as unsupported and | 105 | pre-settlement servers, so clients treat missing as unsupported and |
| 104 | never send the commands under version skew. */ | 106 | never send the commands under version skew. */ |
packages/contracts/src/orchestration.test.ts+14| ... | @@ -84,6 +84,18 @@ it.effect("decodes a dispatch error after its bootstrap thread was deleted", () | ... | @@ -84,6 +84,18 @@ it.effect("decodes a dispatch error after its bootstrap thread was deleted", () |
| 84 | }), | 84 | }), |
| 85 | ); | 85 | ); |
| 86 | 86 | ||
| 87 | it.effect("decodes a dispatch error before its bootstrap thread was created", () => | ||
| 88 | Effect.gen(function* () { | ||
| 89 | const error = yield* decodeDispatchCommandError({ | ||
| 90 | _tag: "OrchestrationDispatchCommandError", | ||
| 91 | message: "A separate worktree requires a base commit.", | ||
| 92 | bootstrapThreadDisposition: "not-created", | ||
| 93 | }); | ||
| 94 | |||
| 95 | assert.strictEqual(error.bootstrapThreadDisposition, "not-created"); | ||
| 96 | }), | ||
| 97 | ); | ||
| 98 | |||
| 87 | it.effect("parses turn diff input when fromTurnCount <= toTurnCount", () => | 99 | it.effect("parses turn diff input when fromTurnCount <= toTurnCount", () => |
| 88 | Effect.gen(function* () { | 100 | Effect.gen(function* () { |
| 89 | const parsed = yield* decodeTurnDiffInput({ | 101 | const parsed = yield* decodeTurnDiffInput({ |
| ... | @@ -545,6 +557,7 @@ it.effect("accepts bootstrap metadata in thread.turn.start", () => | ... | @@ -545,6 +557,7 @@ it.effect("accepts bootstrap metadata in thread.turn.start", () => |
| 545 | baseBranch: "main", | 557 | baseBranch: "main", |
| 546 | branch: "t3code/example", | 558 | branch: "t3code/example", |
| 547 | startFromOrigin: true, | 559 | startFromOrigin: true, |
| 560 | requireWorktree: true, | ||
| 548 | }, | 561 | }, |
| 549 | runSetupScript: true, | 562 | runSetupScript: true, |
| 550 | }, | 563 | }, |
| ... | @@ -553,6 +566,7 @@ it.effect("accepts bootstrap metadata in thread.turn.start", () => | ... | @@ -553,6 +566,7 @@ it.effect("accepts bootstrap metadata in thread.turn.start", () => |
| 553 | assert.strictEqual(parsed.bootstrap?.createThread?.projectId, "project-1"); | 566 | assert.strictEqual(parsed.bootstrap?.createThread?.projectId, "project-1"); |
| 554 | assert.strictEqual(parsed.bootstrap?.prepareWorktree?.baseBranch, "main"); | 567 | assert.strictEqual(parsed.bootstrap?.prepareWorktree?.baseBranch, "main"); |
| 555 | assert.strictEqual(parsed.bootstrap?.prepareWorktree?.startFromOrigin, true); | 568 | assert.strictEqual(parsed.bootstrap?.prepareWorktree?.startFromOrigin, true); |
| 569 | assert.strictEqual(parsed.bootstrap?.prepareWorktree?.requireWorktree, true); | ||
| 556 | assert.strictEqual(parsed.bootstrap?.runSetupScript, true); | 570 | assert.strictEqual(parsed.bootstrap?.runSetupScript, true); |
| 557 | }), | 571 | }), |
| 558 | ); | 572 | ); |
packages/contracts/src/orchestration.ts+2-1| ... | @@ -1271,6 +1271,7 @@ const ThreadTurnStartBootstrapPrepareWorktree = Schema.Struct({ | ... | @@ -1271,6 +1271,7 @@ const ThreadTurnStartBootstrapPrepareWorktree = Schema.Struct({ |
| 1271 | baseBranch: TrimmedNonEmptyString, | 1271 | baseBranch: TrimmedNonEmptyString, |
| 1272 | branch: Schema.optional(TrimmedNonEmptyString), | 1272 | branch: Schema.optional(TrimmedNonEmptyString), |
| 1273 | startFromOrigin: Schema.optional(Schema.Boolean), | 1273 | startFromOrigin: Schema.optional(Schema.Boolean), |
| 1274 | requireWorktree: Schema.optional(Schema.Boolean), | ||
| 1274 | }); | 1275 | }); |
| 1275 | 1276 | ||
| 1276 | const ThreadTurnStartBootstrap = Schema.Struct({ | 1277 | const ThreadTurnStartBootstrap = Schema.Struct({ |
| ... | @@ -2381,7 +2382,7 @@ export class OrchestrationDispatchCommandError extends Schema.TaggedError<Orches | ... | @@ -2381,7 +2382,7 @@ export class OrchestrationDispatchCommandError extends Schema.TaggedError<Orches |
| 2381 | { | 2382 | { |
| 2382 | message: TrimmedNonEmptyString, | 2383 | message: TrimmedNonEmptyString, |
| 2383 | cause: Schema.optional(Schema.Defect()), | 2384 | cause: Schema.optional(Schema.Defect()), |
| 2384 | bootstrapThreadDisposition: Schema.optional(Schema.Literal("deleted")), | 2385 | bootstrapThreadDisposition: Schema.optional(Schema.Literals(["deleted", "not-created"])), |
| 2385 | }, | 2386 | }, |
| 2386 | ) {} | 2387 | ) {} |
| 2387 | 2388 |