| 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 | 170 | expect(second.capabilities.attachmentUploads).toBe(true); |
| 171 | 171 | expect(second.capabilities.fileAttachments).toEqual({ maxUploadBytes: 50 * 1024 * 1024 }); |
| 172 | 172 | expect(second.capabilities.pullRequests).toBe(true); |
| 173 | expect(second.capabilities.requiredWorktreeBootstrap).toBe(true); | |
| 173 | 174 | expect(second.capabilities.usagePriceOverrides).toBe(true); |
| 174 | 175 | expect(second.capabilities.threadActiveReorder).toBe(true); |
| 175 | 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 | 222 | fileAttachments: { maxUploadBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES }, |
| 223 | 223 | pullRequests: true, |
| 224 | 224 | inlineMessageContext: true, |
| 225 | requiredWorktreeBootstrap: true, | |
| 225 | 226 | threadSettlement: true, |
| 226 | 227 | threadAutoSettlement: true, |
| 227 | 228 | storageCleanup: true, |
apps/server/src/server.test.ts+96| ... | ... | @@ -11184,6 +11184,102 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 11184 | 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 | 11283 | it.effect("falls back to the project checkout when worktree mode targets a non-repository", () => |
| 11188 | 11284 | Effect.gen(function* () { |
| 11189 | 11285 | const dispatchedCommands: Array<OrchestrationCommand> = []; |
apps/server/src/ws.ts+12-3| ... | ... | @@ -1369,6 +1369,12 @@ const makeWsRpcLayer = ( |
| 1369 | 1369 | } |
| 1370 | 1370 | |
| 1371 | 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 | 1378 | // Not a git repo, or the base has no commit: the thread runs in |
| 1373 | 1379 | // the project checkout instead. The card says so and moves on. |
| 1374 | 1380 | yield* track( |
| ... | ... | @@ -1401,8 +1407,8 @@ const makeWsRpcLayer = ( |
| 1401 | 1407 | // every delete for the prior incarnation committed before it. |
| 1402 | 1408 | // Drain through that event before setup or turn start can own |
| 1403 | 1409 | // terminals and provider sessions under the reused thread id. |
| 1404 | yield* threadDeletionReactor.drainThrough(created.sequence); | |
| 1405 | 1410 | createdThread = true; |
| 1411 | yield* threadDeletionReactor.drainThrough(created.sequence); | |
| 1406 | 1412 | // Persist the send now rather than with the turn: the thread is |
| 1407 | 1413 | // real from here on, so any client (or a reload) sees the message |
| 1408 | 1414 | // while the worktree is still being prepared. The turn start |
| ... | ... | @@ -1606,13 +1612,16 @@ const makeWsRpcLayer = ( |
| 1606 | 1612 | ), |
| 1607 | 1613 | onSuccess: (threadDeleted) => |
| 1608 | 1614 | Effect.fail( |
| 1609 | threadDeleted | |
| 1615 | threadDeleted || | |
| 1616 | (bootstrap?.createThread && | |
| 1617 | bootstrap.prepareWorktree?.requireWorktree === true && | |
| 1618 | !createdThread) | |
| 1610 | 1619 | ? new OrchestrationDispatchCommandError({ |
| 1611 | 1620 | message: dispatchError.message, |
| 1612 | 1621 | ...(dispatchError.cause !== undefined |
| 1613 | 1622 | ? { cause: dispatchError.cause } |
| 1614 | 1623 | : {}), |
| 1615 | bootstrapThreadDisposition: "deleted", | |
| 1624 | bootstrapThreadDisposition: threadDeleted ? "deleted" : "not-created", | |
| 1616 | 1625 | }) |
| 1617 | 1626 | : dispatchError, |
| 1618 | 1627 | ), |
apps/web/src/components/BranchToolbar.tsx+26-10| ... | ... | @@ -66,6 +66,7 @@ export interface BranchToolbarHandle { |
| 66 | 66 | } |
| 67 | 67 | |
| 68 | 68 | interface BranchToolbarProps { |
| 69 | forceNewWorktree?: boolean; | |
| 69 | 70 | ref?: Ref<BranchToolbarHandle>; |
| 70 | 71 | environmentId: EnvironmentId; |
| 71 | 72 | threadId: ThreadId; |
| ... | ... | @@ -89,6 +90,7 @@ interface BranchToolbarProps { |
| 89 | 90 | } |
| 90 | 91 | |
| 91 | 92 | interface MobileRunContextSelectorProps { |
| 93 | forceNewWorktree: boolean; | |
| 92 | 94 | autoEnvironmentLabel?: string | undefined; |
| 93 | 95 | onAutoEnvironment?: (() => void) | undefined; |
| 94 | 96 | envLocked: boolean; |
| ... | ... | @@ -106,6 +108,7 @@ interface MobileRunContextSelectorProps { |
| 106 | 108 | } |
| 107 | 109 | |
| 108 | 110 | const MobileRunContextSelector = memo(function MobileRunContextSelector({ |
| 111 | forceNewWorktree, | |
| 109 | 112 | autoEnvironmentLabel, |
| 110 | 113 | onAutoEnvironment, |
| 111 | 114 | envLocked, |
| ... | ... | @@ -132,11 +135,13 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ |
| 132 | 135 | : activeWorktreePath |
| 133 | 136 | ? FolderGitIcon |
| 134 | 137 | : FolderIcon; |
| 135 | const workspaceLabel = envModeLocked | |
| 136 | ? resolveLockedWorkspaceLabel(activeWorktreePath) | |
| 137 | : effectiveEnvMode === "worktree" | |
| 138 | ? resolveEnvModeLabel("worktree") | |
| 139 | : resolveCurrentWorkspaceLabel(activeWorktreePath); | |
| 138 | const workspaceLabel = forceNewWorktree | |
| 139 | ? resolveEnvModeLabel("worktree") | |
| 140 | : envModeLocked | |
| 141 | ? resolveLockedWorkspaceLabel(activeWorktreePath) | |
| 142 | : effectiveEnvMode === "worktree" | |
| 143 | ? resolveEnvModeLabel("worktree") | |
| 144 | : resolveCurrentWorkspaceLabel(activeWorktreePath); | |
| 140 | 145 | const isLocked = envLocked || envModeLocked; |
| 141 | 146 | const workspaceIcon = ( |
| 142 | 147 | <Tooltip> |
| ... | ... | @@ -269,7 +274,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ |
| 269 | 274 | onEnvModeChange(value as EnvMode); |
| 270 | 275 | }} |
| 271 | 276 | > |
| 272 | <MenuRadioItem disabled={envModeLocked} value="local"> | |
| 277 | <MenuRadioItem disabled={envModeLocked || forceNewWorktree} value="local"> | |
| 273 | 278 | <span className="flex min-w-0 items-center gap-1.5"> |
| 274 | 279 | {activeWorktreePath ? ( |
| 275 | 280 | <FolderGitIcon className="size-3" /> |
| ... | ... | @@ -476,6 +481,7 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { |
| 476 | 481 | } |
| 477 | 482 | |
| 478 | 483 | export const BranchToolbar = memo(function BranchToolbar({ |
| 484 | forceNewWorktree = false, | |
| 479 | 485 | ref, |
| 480 | 486 | environmentId, |
| 481 | 487 | threadId, |
| ... | ... | @@ -514,9 +520,11 @@ export const BranchToolbar = memo(function BranchToolbar({ |
| 514 | 520 | : null; |
| 515 | 521 | const activeProject = useProject(activeProjectRef); |
| 516 | 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 | 526 | const effectiveEnvMode = |
| 519 | effectiveEnvModeOverride ?? | |
| 527 | (forceNewWorktree ? "worktree" : effectiveEnvModeOverride) ?? | |
| 520 | 528 | resolveEffectiveEnvMode({ |
| 521 | 529 | activeWorktreePath, |
| 522 | 530 | hasServerThread: serverThread !== null, |
| ... | ... | @@ -527,7 +535,8 @@ export const BranchToolbar = memo(function BranchToolbar({ |
| 527 | 535 | // "Previous worktree" hops a draft into the most recently active worktree |
| 528 | 536 | // of this project — the "keep going where I just was" follow-up flow. Only |
| 529 | 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 | 540 | const projectRefsForWorktreeLookup = useMemo( |
| 532 | 541 | () => (canUsePreviousWorktree && activeProjectRef ? [activeProjectRef] : []), |
| 533 | 542 | [canUsePreviousWorktree, activeProjectRef], |
| ... | ... | @@ -606,6 +615,7 @@ export const BranchToolbar = memo(function BranchToolbar({ |
| 606 | 615 | {showGitControls ? ( |
| 607 | 616 | <div className="contents @3xl/composer-surface:hidden"> |
| 608 | 617 | <MobileRunContextSelector |
| 618 | forceNewWorktree={forceNewWorktree} | |
| 609 | 619 | autoEnvironmentLabel={autoEnvironmentLabel} |
| 610 | 620 | onAutoEnvironment={onAutoEnvironment} |
| 611 | 621 | envLocked={envLocked} |
| ... | ... | @@ -652,6 +662,7 @@ export const BranchToolbar = memo(function BranchToolbar({ |
| 652 | 662 | )} |
| 653 | 663 | {showGitControls ? ( |
| 654 | 664 | <BranchToolbarEnvModeSelector |
| 665 | forceNewWorktree={forceNewWorktree} | |
| 655 | 666 | envLocked={envModeLocked} |
| 656 | 667 | effectiveEnvMode={effectiveEnvMode} |
| 657 | 668 | activeWorktreePath={activeWorktreePath} |
| ... | ... | @@ -677,13 +688,18 @@ export const BranchToolbar = memo(function BranchToolbar({ |
| 677 | 688 | |
| 678 | 689 | {showGitControls ? ( |
| 679 | 690 | <BranchToolbarBranchSelector |
| 691 | forceNewWorktree={forceNewWorktree} | |
| 680 | 692 | ref={branchSelectorRef} |
| 681 | 693 | className="min-w-0 flex-initial justify-end @3xl/composer-surface:ml-auto" |
| 682 | 694 | environmentId={environmentId} |
| 683 | 695 | threadId={threadId} |
| 684 | 696 | {...(draftId ? { draftId } : {})} |
| 685 | 697 | envLocked={envLocked} |
| 686 | {...(effectiveEnvModeOverride ? { effectiveEnvModeOverride } : {})} | |
| 698 | {...(forceNewWorktree | |
| 699 | ? { effectiveEnvModeOverride: "worktree" } | |
| 700 | : effectiveEnvModeOverride | |
| 701 | ? { effectiveEnvModeOverride } | |
| 702 | : {})} | |
| 687 | 703 | {...(activeThreadBranchOverride !== undefined ? { activeThreadBranchOverride } : {})} |
| 688 | 704 | {...(onActiveThreadBranchOverrideChange ? { onActiveThreadBranchOverrideChange } : {})} |
| 689 | 705 | startFromOrigin={startFromOrigin} |
apps/web/src/components/BranchToolbarBranchSelector.tsx+5-1| ... | ... | @@ -79,6 +79,7 @@ export interface BranchToolbarBranchSelectorHandle { |
| 79 | 79 | } |
| 80 | 80 | |
| 81 | 81 | interface BranchToolbarBranchSelectorProps { |
| 82 | forceNewWorktree?: boolean; | |
| 82 | 83 | ref?: Ref<BranchToolbarBranchSelectorHandle>; |
| 83 | 84 | className?: string; |
| 84 | 85 | environmentId: EnvironmentId; |
| ... | ... | @@ -99,6 +100,7 @@ function toBranchActionErrorMessage(error: unknown): string { |
| 99 | 100 | } |
| 100 | 101 | |
| 101 | 102 | export function BranchToolbarBranchSelector({ |
| 103 | forceNewWorktree = false, | |
| 102 | 104 | ref, |
| 103 | 105 | className, |
| 104 | 106 | environmentId, |
| ... | ... | @@ -152,7 +154,9 @@ export function BranchToolbarBranchSelector({ |
| 152 | 154 | activeThreadBranchOverride !== undefined |
| 153 | 155 | ? activeThreadBranchOverride |
| 154 | 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 | 160 | const activeProjectCwd = activeProject?.workspaceRoot ?? null; |
| 157 | 161 | const branchCwd = activeWorktreePath ?? activeProjectCwd; |
| 158 | 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 | 22 | const PREVIOUS_WORKTREE_SELECT_VALUE = "previous-worktree"; |
| 23 | 23 | |
| 24 | 24 | interface BranchToolbarEnvModeSelectorProps { |
| 25 | forceNewWorktree?: boolean; | |
| 25 | 26 | envLocked: boolean; |
| 26 | 27 | effectiveEnvMode: EnvMode; |
| 27 | 28 | activeWorktreePath: string | null; |
| ... | ... | @@ -31,6 +32,7 @@ interface BranchToolbarEnvModeSelectorProps { |
| 31 | 32 | } |
| 32 | 33 | |
| 33 | 34 | export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSelector({ |
| 35 | forceNewWorktree = false, | |
| 34 | 36 | envLocked, |
| 35 | 37 | effectiveEnvMode, |
| 36 | 38 | activeWorktreePath, |
| ... | ... | @@ -51,7 +53,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe |
| 51 | 53 | [activeWorktreePath, previousWorktreeLabel, showPreviousWorktree], |
| 52 | 54 | ); |
| 53 | 55 | |
| 54 | if (envLocked) { | |
| 56 | if (envLocked || forceNewWorktree) { | |
| 55 | 57 | return ( |
| 56 | 58 | <Tooltip> |
| 57 | 59 | <TooltipTrigger |
| ... | ... | @@ -59,7 +61,9 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe |
| 59 | 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 | 62 | data-composer-context-control |
| 61 | 63 | > |
| 62 | {activeWorktreePath ? ( | |
| 64 | {forceNewWorktree ? ( | |
| 65 | <FolderGit2Icon className="size-3 shrink-0" /> | |
| 66 | ) : activeWorktreePath ? ( | |
| 63 | 67 | <FolderGitIcon className="size-3 shrink-0" /> |
| 64 | 68 | ) : ( |
| 65 | 69 | <FolderIcon className="size-3 shrink-0" /> |
| ... | ... | @@ -72,11 +76,17 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe |
| 72 | 76 | data-composer-label-motion |
| 73 | 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 | 82 | </span> |
| 77 | 83 | </span> |
| 78 | 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 | 90 | </Tooltip> |
| 81 | 91 | ); |
| 82 | 92 | } |
apps/web/src/components/ChatView.tsx+389-12| ... | ... | @@ -46,7 +46,10 @@ import { |
| 46 | 46 | type WorktreeSetupSnapshot, |
| 47 | 47 | } from "@t3tools/contracts"; |
| 48 | 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 | 53 | import { readPastedComposerContext } from "./composerInlineTokenPaste"; |
| 51 | 54 | import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste"; |
| 52 | 55 | import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; |
| ... | ... | @@ -82,9 +85,11 @@ import { |
| 82 | 85 | } from "@t3tools/shared/terminalLabels"; |
| 83 | 86 | import { Debouncer } from "@tanstack/react-pacer"; |
| 84 | 87 | import { useAtomValue } from "@effect/atom-react"; |
| 88 | import { Atom } from "effect/unstable/reactivity"; | |
| 85 | 89 | import { |
| 86 | 90 | lazy, |
| 87 | 91 | memo, |
| 92 | type SetStateAction, | |
| 88 | 93 | Suspense, |
| 89 | 94 | useCallback, |
| 90 | 95 | useEffect, |
| ... | ... | @@ -265,6 +270,10 @@ import { useRemoveClonedProject } from "../hooks/useRemoveClonedProject"; |
| 265 | 270 | import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl"; |
| 266 | 271 | import { useThreadActions } from "../hooks/useThreadActions"; |
| 267 | 272 | import { resolveAppModelSelectionForInstance } from "../modelSelection"; |
| 273 | import { | |
| 274 | getComposerPromptInjectionState, | |
| 275 | getComposerProviderState, | |
| 276 | } from "./chat/composerProviderState"; | |
| 268 | 277 | import { confirmTerminalClose, isTerminalCloseConfirmPending } from "../lib/terminalCloseConfirm"; |
| 269 | 278 | import { isPreviewFocused } from "../lib/previewFocus"; |
| 270 | 279 | import { getTerminalFocusOwner } from "../lib/terminalFocus"; |
| ... | ... | @@ -443,6 +452,7 @@ import { |
| 443 | 452 | resolveBackgroundDraftWorkspaceOptions, |
| 444 | 453 | resolveComposerInteractionMode, |
| 445 | 454 | resolveComposerProviderSelection, |
| 455 | getAntigravitySendBlockReason, | |
| 446 | 456 | resolveDraftHeroState, |
| 447 | 457 | findRecordedWorktreeSetup, |
| 448 | 458 | resolveVisibleWorktreeSetup, |
| ... | ... | @@ -698,6 +708,14 @@ function pasteTextToFocusComposer(event: ClipboardEvent): string | null { |
| 698 | 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 | 719 | function formatOutgoingPrompt(params: { |
| 702 | 720 | provider: ProviderDriverKind; |
| 703 | 721 | model: string | null; |
| ... | ... | @@ -1771,7 +1789,23 @@ export default function ChatView(props: ChatViewProps) { |
| 1771 | 1789 | const [timelineOverflows, setTimelineOverflows] = useState(false); |
| 1772 | 1790 | const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({}); |
| 1773 | 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 | 1809 | const environmentUnavailableSendToastSlotRef = useRef(0); |
| 1776 | 1810 | const feedbackUploadsInFlightRef = useRef(new Set<string>()); |
| 1777 | 1811 | const terminalUiOpenByThreadRef = useRef<Record<string, boolean>>({}); |
| ... | ... | @@ -7302,6 +7336,31 @@ export default function ChatView(props: ChatViewProps) { |
| 7302 | 7336 | notifyDirectAnnotationAttached(); |
| 7303 | 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 | 7364 | const { |
| 7306 | 7365 | images: sendContextImages, |
| 7307 | 7366 | files: composerFiles, |
| ... | ... | @@ -7379,7 +7438,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7379 | 7438 | composerReviewComments.length === 0 |
| 7380 | 7439 | ? parseCodexFeedbackCommand(trimmed) |
| 7381 | 7440 | : null; |
| 7382 | if (feedbackCommand && !queuedMessage) { | |
| 7441 | if (feedbackCommand && !queuedMessage && multipleModelSelections === null) { | |
| 7383 | 7442 | if (!isServerThread || activeThread.session === null) { |
| 7384 | 7443 | toastManager.add( |
| 7385 | 7444 | stackedThreadToast({ |
| ... | ... | @@ -7502,7 +7561,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7502 | 7561 | composerReviewComments.length === 0 |
| 7503 | 7562 | ? parseStandaloneComposerSlashCommand(trimmed) |
| 7504 | 7563 | : null; |
| 7505 | if (standaloneSlashCommand && !queuedMessage) { | |
| 7564 | if (standaloneSlashCommand && !queuedMessage && multipleModelSelections === null) { | |
| 7506 | 7565 | handleInteractionModeChange(standaloneSlashCommand); |
| 7507 | 7566 | promptRef.current = ""; |
| 7508 | 7567 | clearComposerDraftContent(composerDraftTarget); |
| ... | ... | @@ -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 | 7759 | sendInFlightRef.current = true; |
| 7760 | const sendGeneration = ++composerSendGenerationRef.current; | |
| 7656 | 7761 | // Every early return above leaves a queued message in the queue for a |
| 7657 | 7762 | // later retry. From here on a failure hands it back to the composer. |
| 7658 | 7763 | if (queuedMessage) { |
| ... | ... | @@ -7728,7 +7833,9 @@ export default function ChatView(props: ChatViewProps) { |
| 7728 | 7833 | } |
| 7729 | 7834 | |
| 7730 | 7835 | const resolvedSubmissionIntent = |
| 7731 | submissionIntent === "background" && isLocalDraftThread ? "background" : "foreground"; | |
| 7836 | (multipleModelSelections !== null || submissionIntent === "background") && isLocalDraftThread | |
| 7837 | ? "background" | |
| 7838 | : "foreground"; | |
| 7732 | 7839 | if ( |
| 7733 | 7840 | shouldDockDraftHeroForSubmission({ |
| 7734 | 7841 | isDraftHeroState, |
| ... | ... | @@ -7763,7 +7870,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7763 | 7870 | return; |
| 7764 | 7871 | } |
| 7765 | 7872 | beginLocalDispatch({ |
| 7766 | preparingWorktree: Boolean(baseBranchForWorktree), | |
| 7873 | preparingWorktree: multipleModelSelections !== null || Boolean(baseBranchForWorktree), | |
| 7767 | 7874 | submissionIntent: resolvedSubmissionIntent, |
| 7768 | 7875 | }); |
| 7769 | 7876 | |
| ... | ... | @@ -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 | 8163 | const optimisticAttachments = composerAttachmentsSnapshot.map((attachment) => |
| 7796 | 8164 | attachment.type === "image" |
| 7797 | 8165 | ? { |
| ... | ... | @@ -8868,7 +9236,7 @@ export default function ChatView(props: ChatViewProps) { |
| 8868 | 9236 | ); |
| 8869 | 9237 | |
| 8870 | 9238 | const onProviderModelSelect = useCallback( |
| 8871 | (instanceId: ProviderInstanceId, model: string) => { | |
| 9239 | (instanceId: ProviderInstanceId, model: string, options?: { focusComposer?: boolean }) => { | |
| 8872 | 9240 | if (!activeThread) return; |
| 8873 | 9241 | // Look up the configured instance so model normalization and custom |
| 8874 | 9242 | // model lookup stay scoped to that exact instance. Unknown instance ids |
| ... | ... | @@ -8880,7 +9248,7 @@ export default function ChatView(props: ChatViewProps) { |
| 8880 | 9248 | resolvedDriverKind !== null && |
| 8881 | 9249 | resolvedDriverKind !== lockedProvider |
| 8882 | 9250 | ) { |
| 8883 | scheduleComposerFocus(); | |
| 9251 | if (options?.focusComposer !== false) scheduleComposerFocus(); | |
| 8884 | 9252 | return; |
| 8885 | 9253 | } |
| 8886 | 9254 | if (lockedProvider !== null && activeThread.session?.providerInstanceId) { |
| ... | ... | @@ -8892,7 +9260,7 @@ export default function ChatView(props: ChatViewProps) { |
| 8892 | 9260 | entry?.continuation?.groupKey && |
| 8893 | 9261 | currentEntry.continuation.groupKey !== entry.continuation.groupKey |
| 8894 | 9262 | ) { |
| 8895 | scheduleComposerFocus(); | |
| 9263 | if (options?.focusComposer !== false) scheduleComposerFocus(); | |
| 8896 | 9264 | return; |
| 8897 | 9265 | } |
| 8898 | 9266 | } |
| ... | ... | @@ -8903,7 +9271,7 @@ export default function ChatView(props: ChatViewProps) { |
| 8903 | 9271 | model, |
| 8904 | 9272 | ); |
| 8905 | 9273 | if (!resolvedModel) { |
| 8906 | scheduleComposerFocus(); | |
| 9274 | if (options?.focusComposer !== false) scheduleComposerFocus(); | |
| 8907 | 9275 | return; |
| 8908 | 9276 | } |
| 8909 | 9277 | const nextModelSelection: ModelSelection = { |
| ... | ... | @@ -8923,7 +9291,7 @@ export default function ChatView(props: ChatViewProps) { |
| 8923 | 9291 | title: modelChangeBlockReason.title, |
| 8924 | 9292 | description: modelChangeBlockReason.description, |
| 8925 | 9293 | }); |
| 8926 | scheduleComposerFocus(); | |
| 9294 | if (options?.focusComposer !== false) scheduleComposerFocus(); | |
| 8927 | 9295 | return; |
| 8928 | 9296 | } |
| 8929 | 9297 | setComposerDraftModelSelection( |
| ... | ... | @@ -8932,7 +9300,7 @@ export default function ChatView(props: ChatViewProps) { |
| 8932 | 9300 | { explicit: true }, |
| 8933 | 9301 | ); |
| 8934 | 9302 | setStickyComposerModelSelection(nextModelSelection); |
| 8935 | scheduleComposerFocus(); | |
| 9303 | if (options?.focusComposer !== false) scheduleComposerFocus(); | |
| 8936 | 9304 | }, |
| 8937 | 9305 | [ |
| 8938 | 9306 | activeThread, |
| ... | ... | @@ -8946,6 +9314,7 @@ export default function ChatView(props: ChatViewProps) { |
| 8946 | 9314 | ); |
| 8947 | 9315 | const onEnvModeChange = useCallback( |
| 8948 | 9316 | (mode: DraftThreadEnvMode) => { |
| 9317 | if (multipleModelSelections !== null) return; | |
| 8949 | 9318 | if (canOverrideServerThreadEnvMode) { |
| 8950 | 9319 | setPendingServerThreadEnvMode(mode); |
| 8951 | 9320 | scheduleComposerFocus(); |
| ... | ... | @@ -8968,6 +9337,7 @@ export default function ChatView(props: ChatViewProps) { |
| 8968 | 9337 | composerDraftTarget, |
| 8969 | 9338 | draftThread?.worktreePath, |
| 8970 | 9339 | isLocalDraftThread, |
| 9340 | multipleModelSelections, | |
| 8971 | 9341 | activeProjectSettings.settings.newWorktreesStartFromOrigin, |
| 8972 | 9342 | setPendingServerThreadEnvMode, |
| 8973 | 9343 | scheduleComposerFocus, |
| ... | ... | @@ -9632,6 +10002,12 @@ export default function ChatView(props: ChatViewProps) { |
| 9632 | 10002 | <ComposerSurface.Host> |
| 9633 | 10003 | <div ref={attachDraftHeroComposerAnchorRef} className="relative z-10"> |
| 9634 | 10004 | <ChatComposer |
| 10005 | multipleModelSelections={multipleModelSelections} | |
| 10006 | supportsMultipleModels={ | |
| 10007 | serverConfig?.environment.capabilities.requiredWorktreeBootstrap === | |
| 10008 | true | |
| 10009 | } | |
| 10010 | onMultipleModelSelectionsChange={setMultipleModelSelections} | |
| 9635 | 10011 | composerRef={composerRef} |
| 9636 | 10012 | composerDraftTarget={composerDraftTarget} |
| 9637 | 10013 | environmentId={environmentId} |
| ... | ... | @@ -9769,6 +10145,7 @@ export default function ChatView(props: ChatViewProps) { |
| 9769 | 10145 | {mountComposerContextStrip && ( |
| 9770 | 10146 | <div className="pointer-events-auto"> |
| 9771 | 10147 | <BranchToolbar |
| 10148 | forceNewWorktree={multipleModelSelections !== null} | |
| 9772 | 10149 | ref={branchToolbarRef} |
| 9773 | 10150 | environmentId={activeThread.environmentId} |
| 9774 | 10151 | threadId={activeThread.id} |
apps/web/src/components/chat/ChatComposer.tsx+77-6| ... | ... | @@ -245,6 +245,7 @@ import { |
| 245 | 245 | import { useEnvironmentQuery } from "~/state/query"; |
| 246 | 246 | import { useDebouncedValue } from "~/state/queries"; |
| 247 | 247 | import { ProviderModelPicker } from "./ProviderModelPicker"; |
| 248 | import { resolveModelPickerSelectedModel } from "./ModelPickerContent"; | |
| 248 | 249 | import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu"; |
| 249 | 250 | import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; |
| 250 | 251 | import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; |
| ... | ... | @@ -1267,6 +1268,7 @@ export interface ChatComposerHandle { |
| 1267 | 1268 | selectedPromptEffort: string | null; |
| 1268 | 1269 | selectedModelOptionsForDispatch: unknown; |
| 1269 | 1270 | selectedModelSelection: ModelSelection; |
| 1271 | multipleModelSelections: ReadonlyArray<ModelSelection> | null; | |
| 1270 | 1272 | providerAvailable: boolean; |
| 1271 | 1273 | selectedProvider: ProviderDriverKind; |
| 1272 | 1274 | selectedModel: string; |
| ... | ... | @@ -1276,6 +1278,7 @@ export interface ChatComposerHandle { |
| 1276 | 1278 | }; |
| 1277 | 1279 | /** Validate the fully composed text immediately before a provider turn starts. */ |
| 1278 | 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 | 1295 | routeKind: "server" | "draft"; |
| 1293 | 1296 | routeThreadRef: ScopedThreadRef; |
| 1294 | 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 | 1304 | // Thread context |
| 1297 | 1305 | activeThreadId: ThreadId | null; |
| ... | ... | @@ -1420,7 +1428,11 @@ export interface ChatComposerProps { |
| 1420 | 1428 | cursorAdjacentToMention: boolean, |
| 1421 | 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 | 1436 | onOpenProviderSetup: (instanceId: ProviderInstanceId) => void; |
| 1425 | 1437 | getModelDisabledReason: (instanceId: ProviderInstanceId, model: string) => string | null; |
| 1426 | 1438 | toggleInteractionMode: () => void; |
| ... | ... | @@ -1449,6 +1461,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1449 | 1461 | routeKind, |
| 1450 | 1462 | routeThreadRef, |
| 1451 | 1463 | draftId, |
| 1464 | multipleModelSelections, | |
| 1465 | supportsMultipleModels, | |
| 1466 | onMultipleModelSelectionsChange: setMultipleModelSelections, | |
| 1452 | 1467 | activeThreadId, |
| 1453 | 1468 | activeThreadEnvironmentId: _activeThreadEnvironmentId, |
| 1454 | 1469 | activeThread, |
| ... | ... | @@ -1847,7 +1862,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1847 | 1862 | ); |
| 1848 | 1863 | const selectedInstanceId = |
| 1849 | 1864 | selectedProviderEntry?.instanceId ?? NO_PROVIDER_MODEL_SELECTION.instanceId; |
| 1850 | const noProviderAvailable = selectedProviderEntry === undefined; | |
| 1865 | const noProviderAvailable = | |
| 1866 | selectedProviderEntry === undefined && multipleModelSelections === null; | |
| 1851 | 1867 | // Before the catalog arrives, every thread resolves to "no provider". Send |
| 1852 | 1868 | // stays blocked either way; only the chrome waits, keeping the picker with |
| 1853 | 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 | 1899 | ); |
| 1884 | 1900 | const sendDisabledReason = |
| 1885 | 1901 | externalSendDisabledReason ?? |
| 1902 | (multipleModelSelections?.length === 0 ? "Select at least one model." : null) ?? | |
| 1886 | 1903 | (activePendingProgress |
| 1887 | 1904 | ? attachmentBlockReason |
| 1888 | : (attachmentBlockReason ?? providerSendBlockReason)); | |
| 1905 | : (attachmentBlockReason ?? | |
| 1906 | (multipleModelSelections === null ? providerSendBlockReason : null))); | |
| 1889 | 1907 | const isSendDisabled = sendDisabledReason !== null; |
| 1890 | 1908 | const selectedProviderStatus = useMemo( |
| 1891 | 1909 | () => selectedProviderEntry?.snapshot ?? null, |
| ... | ... | @@ -4943,7 +4961,42 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4943 | 4961 | ) : null} |
| 4944 | 4962 | <ProviderModelPicker |
| 4945 | 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 | 5000 | activeInstanceId={ |
| 4948 | 5001 | providerCatalogPending |
| 4949 | 5002 | ? (activeThreadModelSelection?.instanceId ?? selectedInstanceId) |
| ... | ... | @@ -4983,7 +5036,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4983 | 5036 | : {})} |
| 4984 | 5037 | onOpenChange={setIsComposerModelPickerOpen} |
| 4985 | 5038 | getModelDisabledReason={getModelDisabledReason} |
| 4986 | onInstanceModelChange={onProviderModelSelect} | |
| 5039 | onInstanceModelChange={(instanceId, model) => { | |
| 5040 | setMultipleModelSelections(null); | |
| 5041 | onProviderModelSelect(instanceId, model); | |
| 5042 | }} | |
| 4987 | 5043 | onOpenProviderSetup={onOpenProviderSetup} |
| 4988 | 5044 | /> |
| 4989 | 5045 | |
| ... | ... | @@ -5902,13 +5958,25 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5902 | 5958 | selectedPromptEffort, |
| 5903 | 5959 | selectedModelOptionsForDispatch, |
| 5904 | 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 | 5973 | selectedProvider, |
| 5907 | 5974 | selectedModel, |
| 5908 | 5975 | selectedProviderModels, |
| 5909 | 5976 | interactionMode, |
| 5910 | 5977 | interactionModeEnabled: planModeUiEnabled, |
| 5911 | 5978 | }), |
| 5979 | setMultipleModelSelections, | |
| 5912 | 5980 | validateProviderInput: (providerInput: string) => { |
| 5913 | 5981 | const validationMessage = getComposerSubmissionValidationMessage({ |
| 5914 | 5982 | prompt: promptRef.current, |
| ... | ... | @@ -5951,6 +6019,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5951 | 6019 | selectedModel, |
| 5952 | 6020 | selectedModelOptionsForDispatch, |
| 5953 | 6021 | selectedModelSelection, |
| 6022 | multipleModelSelections, | |
| 6023 | setMultipleModelSelections, | |
| 6024 | routeKind, | |
| 5954 | 6025 | noProviderAvailable, |
| 5955 | 6026 | providerSendBlockReason, |
| 5956 | 6027 | selectedPromptEffort, |
apps/web/src/components/chat/ModelListRow.tsx+5-1| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import { type ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts"; |
| 2 | 2 | import { memo } from "react"; |
| 3 | import { StarIcon } from "lucide-react"; | |
| 3 | import { CheckIcon, StarIcon } from "lucide-react"; | |
| 4 | 4 | import { |
| 5 | 5 | getDisplayModelName, |
| 6 | 6 | getTriggerDisplayModelLabel, |
| ... | ... | @@ -31,6 +31,7 @@ export const ModelListRow = memo(function ModelListRow(props: { |
| 31 | 31 | providerAccentColor?: string | undefined; |
| 32 | 32 | isFavorite: boolean; |
| 33 | 33 | isSelected: boolean; |
| 34 | showSelection?: boolean; | |
| 34 | 35 | showProvider: boolean; |
| 35 | 36 | preferShortName?: boolean; |
| 36 | 37 | useTriggerLabel?: boolean; |
| ... | ... | @@ -94,6 +95,9 @@ export const ModelListRow = memo(function ModelListRow(props: { |
| 94 | 95 | </div> |
| 95 | 96 | |
| 96 | 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 | 101 | {props.jumpLabel ? ( |
| 98 | 102 | <Kbd className="h-4 min-w-0 rounded-sm px-1.5 text-[10px]">{props.jumpLabel}</Kbd> |
| 99 | 103 | ) : null} |
apps/web/src/components/chat/ModelPickerContent.tsx+55-11| ... | ... | @@ -151,6 +151,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 151 | 151 | /** The instance currently selected in the composer (combobox "value"). */ |
| 152 | 152 | activeInstanceId: ProviderInstanceId; |
| 153 | 153 | model: string; |
| 154 | selectedModels?: ReadonlyArray<{ instanceId: ProviderInstanceId; model: string }>; | |
| 155 | onToggleModel?: (instanceId: ProviderInstanceId, model: string) => void; | |
| 154 | 156 | /** |
| 155 | 157 | * When set, the picker is locked to the given driver kind — typically |
| 156 | 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 | 188 | instanceEntries, |
| 187 | 189 | getModelDisabledReason, |
| 188 | 190 | onInstanceModelChange, |
| 191 | onToggleModel, | |
| 189 | 192 | } = props; |
| 190 | 193 | const [searchQuery, setSearchQuery] = useState(""); |
| 191 | 194 | const [showTopScrollFade, setShowTopScrollFade] = useState(false); |
| ... | ... | @@ -207,6 +210,23 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 207 | 210 | const activeModelKey = activeModelSlug |
| 208 | 211 | ? modelPickerModelKey(props.activeInstanceId, activeModelSlug) |
| 209 | 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 | 230 | const activeInstanceHasSelectableUnavailableModel = |
| 211 | 231 | activeEntry !== undefined && |
| 212 | 232 | (modelOptionsByInstance.get(props.activeInstanceId) ?? []).some((option) => |
| ... | ... | @@ -575,7 +595,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 575 | 595 | }, []); |
| 576 | 596 | |
| 577 | 597 | const handleModelSelect = useCallback( |
| 578 | (modelSlug: string, instanceId: ProviderInstanceId) => { | |
| 598 | (modelSlug: string, instanceId: ProviderInstanceId, additive = false) => { | |
| 579 | 599 | if (getModelDisabledReason?.(instanceId, modelSlug)) { |
| 580 | 600 | return; |
| 581 | 601 | } |
| ... | ... | @@ -592,10 +612,20 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 592 | 612 | // normalization rules, so pass the driver kind here. |
| 593 | 613 | const resolvedModel = resolveSelectableModel(entry.driverKind, modelSlug, options); |
| 594 | 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 | 631 | const toggleFavorite = useCallback( |
| ... | ... | @@ -701,8 +731,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 701 | 731 | return mapping.size > 0 ? mapping : EMPTY_MODEL_JUMP_LABELS; |
| 702 | 732 | }, [keybindings, modelJumpCommandByKey, modelJumpShortcutContext]); |
| 703 | 733 | const modelListExtraData = useMemo( |
| 704 | () => ({ favoritesSet, modelJumpLabelByKey }), | |
| 705 | [favoritesSet, modelJumpLabelByKey], | |
| 734 | () => ({ favoritesSet, modelJumpLabelByKey, activeModelKey, selectedModelKeySet }), | |
| 735 | [favoritesSet, modelJumpLabelByKey, activeModelKey, selectedModelKeySet], | |
| 706 | 736 | ); |
| 707 | 737 | |
| 708 | 738 | useEffect(() => { |
| ... | ... | @@ -804,7 +834,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 804 | 834 | )} |
| 805 | 835 | |
| 806 | 836 | {/* Main content area */} |
| 807 | <Combobox | |
| 837 | <Combobox<string, boolean> | |
| 808 | 838 | inline |
| 809 | 839 | items={allItemKeys} |
| 810 | 840 | filteredItems={filteredItemKeys} |
| ... | ... | @@ -812,7 +842,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 812 | 842 | autoHighlight |
| 813 | 843 | open |
| 814 | 844 | virtualized |
| 815 | value={activeModelKey} | |
| 845 | multiple={onToggleModel !== undefined} | |
| 846 | value={onToggleModel ? [...selectedModelKeySet] : activeModelKey} | |
| 816 | 847 | onItemHighlighted={(modelKey, eventDetails) => { |
| 817 | 848 | highlightedModelKeyRef.current = typeof modelKey === "string" ? modelKey : null; |
| 818 | 849 | if (eventDetails.reason === "keyboard" && eventDetails.index >= 0) { |
| ... | ... | @@ -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 | 861 | if (typeof modelKey !== "string") { |
| 827 | 862 | return; |
| 828 | 863 | } |
| ... | ... | @@ -833,7 +868,11 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 833 | 868 | } |
| 834 | 869 | const model = parseModelPickerModelKey(modelKey); |
| 835 | 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 | 940 | } |
| 902 | 941 | const model = parseModelPickerModelKey(highlightedModelKeyRef.current); |
| 903 | 942 | if (model) { |
| 904 | handleModelSelect(model.slug, model.instanceId); | |
| 943 | handleModelSelect(model.slug, model.instanceId, e.shiftKey); | |
| 905 | 944 | } |
| 906 | 945 | return; |
| 907 | 946 | } |
| ... | ... | @@ -967,7 +1006,12 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 967 | 1006 | isFavorite={favoritesSet.has( |
| 968 | 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 | 1015 | showProvider |
| 972 | 1016 | preferShortName={!isLocked} |
| 973 | 1017 | useTriggerLabel={false} |
apps/web/src/components/chat/ProviderModelPicker.tsx+66-6| ... | ... | @@ -34,6 +34,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 34 | 34 | */ |
| 35 | 35 | activeInstanceId: ProviderInstanceId; |
| 36 | 36 | model: string; |
| 37 | selectedModels?: ReadonlyArray<{ instanceId: ProviderInstanceId; model: string }>; | |
| 38 | onToggleModel?: (instanceId: ProviderInstanceId, model: string) => void; | |
| 37 | 39 | lockedProvider: ProviderDriverKind | null; |
| 38 | 40 | lockedContinuationGroupKey?: string | null; |
| 39 | 41 | /** Instance entries rendered in the sidebar + used to resolve display name. */ |
| ... | ... | @@ -158,9 +160,37 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 158 | 160 | const shortcutLabel = props.keybindings |
| 159 | 161 | ? shortcutLabelForCommand(props.keybindings, "modelPicker.toggle") |
| 160 | 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 | 191 | const triggerTooltipContent = shortcutLabel |
| 162 | ? `${props.triggerLabel ?? triggerLabel} · ${shortcutLabel}` | |
| 163 | : (props.triggerLabel ?? triggerLabel); | |
| 192 | ? `${props.triggerLabel ?? allModelNames ?? triggerLabel} · ${shortcutLabel}` | |
| 193 | : (props.triggerLabel ?? allModelNames ?? triggerLabel); | |
| 164 | 194 | |
| 165 | 195 | return ( |
| 166 | 196 | <Popover |
| ... | ... | @@ -176,7 +206,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 176 | 206 | <PopoverTrigger |
| 177 | 207 | render={ |
| 178 | 208 | <ComposerControl |
| 179 | aria-label={props.triggerAriaLabel} | |
| 209 | aria-label={props.triggerAriaLabel ?? allModelNames} | |
| 180 | 210 | variant={props.triggerVariant ?? "ghost"} |
| 181 | 211 | size={size} |
| 182 | 212 | data-chat-provider-model-picker="true" |
| ... | ... | @@ -192,7 +222,29 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 192 | 222 | <span |
| 193 | 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 | 248 | <ProviderInstanceIcon |
| 197 | 249 | driverKind={activeEntry.driverKind} |
| 198 | 250 | displayName={activeEntry.displayName} |
| ... | ... | @@ -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 | 272 | </TooltipTrigger> |
| 221 | 273 | <TooltipPopup side="top">{triggerTooltipContent}</TooltipPopup> |
| 222 | 274 | </Tooltip> |
| 223 | {selectedModel?.isUnavailable && props.triggerLabel === undefined ? ( | |
| 275 | {selectedModel?.isUnavailable && !selectedEntries && props.triggerLabel === undefined ? ( | |
| 224 | 276 | <Badge variant="outline" size="sm"> |
| 225 | 277 | Unavailable |
| 226 | 278 | </Badge> |
| ... | ... | @@ -239,6 +291,14 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 239 | 291 | <ModelPickerContent |
| 240 | 292 | activeInstanceId={activeInstanceId} |
| 241 | 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 | 302 | lockedProvider={props.lockedProvider} |
| 243 | 303 | lockedContinuationGroupKey={props.lockedContinuationGroupKey ?? null} |
| 244 | 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 | 20 | next draft keeps the workspace mode and base branch you selected. With **New |
| 21 | 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 | 28 | ## Pin and reorder threads |
| 24 | 29 | |
| 25 | 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 | 1 | import { OrchestrationDispatchCommandError } from "@t3tools/contracts"; |
| 2 | 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 | 6 | describe("wasBootstrapThreadDeleted", () => { |
| 7 | 7 | it("accepts only a confirmed deleted bootstrap thread", () => { |
| ... | ... | @@ -21,3 +21,30 @@ describe("wasBootstrapThreadDeleted", () => { |
| 21 | 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 | 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 | 14 | } as const; |
| 15 | 15 | |
| 16 | 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 | 27 | it("treats a missing pull-request capability as unsupported under version skew", () => { |
| 18 | 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 | 99 | Absent on servers from before inline context shipped, which drop the records and forward |
| 100 | 100 | the links as literal text -- so a client must serialize context the legacy way for them. */ |
| 101 | 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 | 104 | /** Server understands thread.settle / thread.unsettle commands. Absent on |
| 103 | 105 | pre-settlement servers, so clients treat missing as unsupported and |
| 104 | 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 | 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 | 99 | it.effect("parses turn diff input when fromTurnCount <= toTurnCount", () => |
| 88 | 100 | Effect.gen(function* () { |
| 89 | 101 | const parsed = yield* decodeTurnDiffInput({ |
| ... | ... | @@ -545,6 +557,7 @@ it.effect("accepts bootstrap metadata in thread.turn.start", () => |
| 545 | 557 | baseBranch: "main", |
| 546 | 558 | branch: "t3code/example", |
| 547 | 559 | startFromOrigin: true, |
| 560 | requireWorktree: true, | |
| 548 | 561 | }, |
| 549 | 562 | runSetupScript: true, |
| 550 | 563 | }, |
| ... | ... | @@ -553,6 +566,7 @@ it.effect("accepts bootstrap metadata in thread.turn.start", () => |
| 553 | 566 | assert.strictEqual(parsed.bootstrap?.createThread?.projectId, "project-1"); |
| 554 | 567 | assert.strictEqual(parsed.bootstrap?.prepareWorktree?.baseBranch, "main"); |
| 555 | 568 | assert.strictEqual(parsed.bootstrap?.prepareWorktree?.startFromOrigin, true); |
| 569 | assert.strictEqual(parsed.bootstrap?.prepareWorktree?.requireWorktree, true); | |
| 556 | 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 | 1271 | baseBranch: TrimmedNonEmptyString, |
| 1272 | 1272 | branch: Schema.optional(TrimmedNonEmptyString), |
| 1273 | 1273 | startFromOrigin: Schema.optional(Schema.Boolean), |
| 1274 | requireWorktree: Schema.optional(Schema.Boolean), | |
| 1274 | 1275 | }); |
| 1275 | 1276 | |
| 1276 | 1277 | const ThreadTurnStartBootstrap = Schema.Struct({ |
| ... | ... | @@ -2381,7 +2382,7 @@ export class OrchestrationDispatchCommandError extends Schema.TaggedError<Orches |
| 2381 | 2382 | { |
| 2382 | 2383 | message: TrimmedNonEmptyString, |
| 2383 | 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 |