| author | |
| committer | |
| log | e0b4f4639037ce539e6feac7eaf11995cf5490f8 |
| tree | 2916ae64188524ef2ca3e182f49775d2648b3af5 |
| parent | c3e37094e04de71accf497c6110c5305223e0090 |
| signature | Signed by PGP key B5690EEEBB952194 |
11 files changed, 359 insertions(+), 32 deletions(-)
apps/web/src/components/ChatView.logic.test.ts+55| ... | ... | @@ -26,10 +26,14 @@ import { |
| 26 | 26 | isBranchMismatchDismissedForSession, |
| 27 | 27 | reconcileMountedTerminalThreadIds, |
| 28 | 28 | reconcileRetainedMountedThreadIds, |
| 29 | resolveBackgroundDraftWorkspaceOptions, | |
| 30 | resolveDraftPromotionNavigationTarget, | |
| 29 | 31 | resolveThreadMetadataUpdateForNextTurn, |
| 30 | 32 | resolveSendEnvMode, |
| 33 | resolveDraftHeroState, | |
| 31 | 34 | scheduleEnvironmentReconnectWarning, |
| 32 | 35 | startNewThreadForProject, |
| 36 | shouldDockDraftHeroForSubmission, | |
| 33 | 37 | shouldShowBranchMismatchBanner, |
| 34 | 38 | shouldWriteThreadErrorToCurrentServerThread, |
| 35 | 39 | } from "./ChatView.logic"; |
| ... | ... | @@ -39,6 +43,40 @@ const projectId = ProjectId.make("project-1"); |
| 39 | 43 | const threadId = ThreadId.make("thread-1"); |
| 40 | 44 | const now = "2026-03-29T00:00:00.000Z"; |
| 41 | 45 | |
| 46 | describe("draft hero submission transition", () => { | |
| 47 | it("does not dock the composer before a background submission", () => { | |
| 48 | expect( | |
| 49 | shouldDockDraftHeroForSubmission({ | |
| 50 | isDraftHeroState: true, | |
| 51 | activeThreadKey: "environment-local:thread-1", | |
| 52 | submissionIntent: "background", | |
| 53 | }), | |
| 54 | ).toBe(false); | |
| 55 | }); | |
| 56 | ||
| 57 | it("keeps the composer in the hero layout until navigation after server promotion", () => { | |
| 58 | expect( | |
| 59 | resolveDraftHeroState({ | |
| 60 | isLocalDraftThread: false, | |
| 61 | hasTimelineEntries: true, | |
| 62 | isWorking: true, | |
| 63 | draftHeroDockRequested: false, | |
| 64 | backgroundSubmissionPending: true, | |
| 65 | }), | |
| 66 | ).toBe(true); | |
| 67 | }); | |
| 68 | ||
| 69 | it("does not auto-navigate a background submission after server promotion", () => { | |
| 70 | expect( | |
| 71 | resolveDraftPromotionNavigationTarget({ | |
| 72 | serverThreadRef: { environmentId, threadId }, | |
| 73 | serverThreadStarted: true, | |
| 74 | backgroundSubmissionPending: true, | |
| 75 | }), | |
| 76 | ).toBeNull(); | |
| 77 | }); | |
| 78 | }); | |
| 79 | ||
| 42 | 80 | describe("environment reconnect warning grace", () => { |
| 43 | 81 | afterEach(() => vi.useRealTimers()); |
| 44 | 82 | |
| ... | ... | @@ -382,6 +420,23 @@ describe("resolveSendEnvMode", () => { |
| 382 | 420 | }); |
| 383 | 421 | }); |
| 384 | 422 | |
| 423 | describe("resolveBackgroundDraftWorkspaceOptions", () => { | |
| 424 | it("keeps New worktree selected without reusing the launched worktree", () => { | |
| 425 | expect( | |
| 426 | resolveBackgroundDraftWorkspaceOptions({ | |
| 427 | envMode: "worktree", | |
| 428 | branch: "main", | |
| 429 | startFromOrigin: true, | |
| 430 | }), | |
| 431 | ).toEqual({ | |
| 432 | envMode: "worktree", | |
| 433 | branch: "main", | |
| 434 | worktreePath: null, | |
| 435 | startFromOrigin: true, | |
| 436 | }); | |
| 437 | }); | |
| 438 | }); | |
| 439 | ||
| 385 | 440 | describe("branchMismatchKey", () => { |
| 386 | 441 | it("builds a key from thread id and both branches", () => { |
| 387 | 442 | expect(branchMismatchKey("thread-1", { threadBranch: "feat/a", currentBranch: "feat/b" })).toBe( |
apps/web/src/components/ChatView.logic.ts+66-1| ... | ... | @@ -21,6 +21,7 @@ import { |
| 21 | 21 | type TerminalContextDraft, |
| 22 | 22 | } from "../lib/terminalContext"; |
| 23 | 23 | import type { DraftThreadEnvMode } from "../composerDraftStore"; |
| 24 | import type { ComposerSubmissionIntent } from "../composer-logic"; | |
| 24 | 25 | |
| 25 | 26 | export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; |
| 26 | 27 | export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; |
| ... | ... | @@ -29,6 +30,47 @@ export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000; |
| 29 | 30 | |
| 30 | 31 | export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); |
| 31 | 32 | |
| 33 | export function shouldDockDraftHeroForSubmission(input: { | |
| 34 | isDraftHeroState: boolean; | |
| 35 | activeThreadKey: string | null; | |
| 36 | submissionIntent: ComposerSubmissionIntent; | |
| 37 | }): boolean { | |
| 38 | return ( | |
| 39 | input.submissionIntent === "foreground" && | |
| 40 | input.isDraftHeroState && | |
| 41 | input.activeThreadKey !== null | |
| 42 | ); | |
| 43 | } | |
| 44 | ||
| 45 | export function resolveDraftHeroState(input: { | |
| 46 | isLocalDraftThread: boolean; | |
| 47 | hasTimelineEntries: boolean; | |
| 48 | isWorking: boolean; | |
| 49 | draftHeroDockRequested: boolean; | |
| 50 | backgroundSubmissionPending: boolean; | |
| 51 | }): boolean { | |
| 52 | if (input.backgroundSubmissionPending) { | |
| 53 | return true; | |
| 54 | } | |
| 55 | return ( | |
| 56 | input.isLocalDraftThread && | |
| 57 | !input.hasTimelineEntries && | |
| 58 | !input.isWorking && | |
| 59 | !input.draftHeroDockRequested | |
| 60 | ); | |
| 61 | } | |
| 62 | ||
| 63 | export function resolveDraftPromotionNavigationTarget(input: { | |
| 64 | serverThreadRef: ScopedThreadRef | null; | |
| 65 | serverThreadStarted: boolean; | |
| 66 | backgroundSubmissionPending: boolean; | |
| 67 | }): ScopedThreadRef | null { | |
| 68 | if (input.backgroundSubmissionPending) { | |
| 69 | return null; | |
| 70 | } | |
| 71 | return input.serverThreadStarted ? input.serverThreadRef : null; | |
| 72 | } | |
| 73 | ||
| 32 | 74 | export function scheduleEnvironmentReconnectWarning(showWarning: () => void): () => void { |
| 33 | 75 | const timeoutId = globalThis.setTimeout(showWarning, ENVIRONMENT_RECONNECT_WARNING_GRACE_MS); |
| 34 | 76 | return () => globalThis.clearTimeout(timeoutId); |
| ... | ... | @@ -257,6 +299,24 @@ export function resolveSendEnvMode(input: { |
| 257 | 299 | return input.isGitRepo ? input.requestedEnvMode : "local"; |
| 258 | 300 | } |
| 259 | 301 | |
| 302 | export function resolveBackgroundDraftWorkspaceOptions(input: { | |
| 303 | envMode: DraftThreadEnvMode; | |
| 304 | branch: string | null; | |
| 305 | startFromOrigin: boolean; | |
| 306 | }): { | |
| 307 | envMode: DraftThreadEnvMode; | |
| 308 | branch: string | null; | |
| 309 | worktreePath: null; | |
| 310 | startFromOrigin: boolean; | |
| 311 | } { | |
| 312 | return { | |
| 313 | envMode: input.envMode, | |
| 314 | branch: input.branch, | |
| 315 | worktreePath: null, | |
| 316 | startFromOrigin: input.envMode === "worktree" && input.startFromOrigin, | |
| 317 | }; | |
| 318 | } | |
| 319 | ||
| 260 | 320 | export function cloneComposerImageForRetry( |
| 261 | 321 | image: ComposerImageAttachment, |
| 262 | 322 | ): ComposerImageAttachment { |
| ... | ... | @@ -491,6 +551,7 @@ export async function waitForStartedServerThread( |
| 491 | 551 | export interface LocalDispatchSnapshot { |
| 492 | 552 | startedAt: string; |
| 493 | 553 | preparingWorktree: boolean; |
| 554 | submissionIntent: ComposerSubmissionIntent; | |
| 494 | 555 | latestUserMessageId: ChatMessage["id"] | null; |
| 495 | 556 | latestTurnTurnId: TurnId | null; |
| 496 | 557 | latestTurnRequestedAt: string | null; |
| ... | ... | @@ -502,7 +563,10 @@ export interface LocalDispatchSnapshot { |
| 502 | 563 | |
| 503 | 564 | export function createLocalDispatchSnapshot( |
| 504 | 565 | activeThread: Thread | undefined, |
| 505 | options?: { preparingWorktree?: boolean }, | |
| 566 | options?: { | |
| 567 | preparingWorktree?: boolean; | |
| 568 | submissionIntent?: ComposerSubmissionIntent; | |
| 569 | }, | |
| 506 | 570 | ): LocalDispatchSnapshot { |
| 507 | 571 | const latestTurn = activeThread?.latestTurn ?? null; |
| 508 | 572 | const session = activeThread?.session ?? null; |
| ... | ... | @@ -510,6 +574,7 @@ export function createLocalDispatchSnapshot( |
| 510 | 574 | return { |
| 511 | 575 | startedAt: new Date().toISOString(), |
| 512 | 576 | preparingWorktree: Boolean(options?.preparingWorktree), |
| 577 | submissionIntent: options?.submissionIntent ?? "foreground", | |
| 513 | 578 | latestUserMessageId: latestUserMessage?.id ?? null, |
| 514 | 579 | latestTurnTurnId: latestTurn?.turnId ?? null, |
| 515 | 580 | latestTurnRequestedAt: latestTurn?.requestedAt ?? null, |
apps/web/src/components/ChatView.tsx+96-9| ... | ... | @@ -82,6 +82,7 @@ import { readLocalApi } from "../localApi"; |
| 82 | 82 | import { useDiffPanelStore } from "../diffPanelStore"; |
| 83 | 83 | import { |
| 84 | 84 | collapseExpandedComposerCursor, |
| 85 | type ComposerSubmissionIntent, | |
| 85 | 86 | parseStandaloneComposerSlashCommand, |
| 86 | 87 | } from "../composer-logic"; |
| 87 | 88 | import { |
| ... | ... | @@ -210,10 +211,14 @@ import { |
| 210 | 211 | selectProjectGroupingSettings, |
| 211 | 212 | } from "../logicalProject"; |
| 212 | 213 | import { buildPhysicalToLogicalProjectKeyMap } from "../sidebarProjectGrouping"; |
| 213 | import { buildDraftThreadRouteParams } from "../threadRoutes"; | |
| 214 | import { buildDraftThreadRouteParams, buildThreadRouteParams } from "../threadRoutes"; | |
| 214 | 215 | import { |
| 216 | beginBackgroundDraftSubmissionByRef, | |
| 217 | clearBackgroundDraftSubmissionByRef, | |
| 215 | 218 | type ComposerImageAttachment, |
| 216 | 219 | type DraftThreadEnvMode, |
| 220 | finalizePromotedDraftThreadByRef, | |
| 221 | markPromotedDraftThreadByRef, | |
| 217 | 222 | useComposerDraftStore, |
| 218 | 223 | type DraftId, |
| 219 | 224 | } from "../composerDraftStore"; |
| ... | ... | @@ -315,6 +320,7 @@ import { |
| 315 | 320 | scheduleEnvironmentReconnectWarning, |
| 316 | 321 | hasServerAcknowledgedLocalDispatch, |
| 317 | 322 | isBranchMismatchDismissedForSession, |
| 323 | shouldDockDraftHeroForSubmission, | |
| 318 | 324 | shouldShowBranchMismatchBanner, |
| 319 | 325 | getStartedThreadModelChangeBlockReason, |
| 320 | 326 | LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, |
| ... | ... | @@ -325,6 +331,8 @@ import { |
| 325 | 331 | deriveLockedProvider, |
| 326 | 332 | readFileAsDataUrl, |
| 327 | 333 | reconcileMountedTerminalThreadIds, |
| 334 | resolveBackgroundDraftWorkspaceOptions, | |
| 335 | resolveDraftHeroState, | |
| 328 | 336 | resolveThreadMetadataUpdateForNextTurn, |
| 329 | 337 | resolveSendEnvMode, |
| 330 | 338 | revokeBlobPreviewUrl, |
| ... | ... | @@ -596,14 +604,16 @@ function useLocalDispatchState(input: { |
| 596 | 604 | ); |
| 597 | 605 | const activeLocalDispatch = serverAcknowledgedLocalDispatch ? null : localDispatch; |
| 598 | 606 | const beginLocalDispatch = useCallback( |
| 599 | (options?: { preparingWorktree?: boolean }) => { | |
| 607 | (options?: { preparingWorktree?: boolean; submissionIntent?: ComposerSubmissionIntent }) => { | |
| 600 | 608 | const preparingWorktree = Boolean(options?.preparingWorktree); |
| 601 | 609 | setLocalDispatch((current) => { |
| 602 | 610 | const active = serverAcknowledgedLocalDispatch ? null : current; |
| 603 | 611 | if (active) { |
| 604 | return active.preparingWorktree === preparingWorktree | |
| 612 | const submissionIntent = options?.submissionIntent ?? active.submissionIntent; | |
| 613 | return active.preparingWorktree === preparingWorktree && | |
| 614 | active.submissionIntent === submissionIntent | |
| 605 | 615 | ? active |
| 606 | : { ...active, preparingWorktree }; | |
| 616 | : { ...active, preparingWorktree, submissionIntent }; | |
| 607 | 617 | } |
| 608 | 618 | return createLocalDispatchSnapshot(input.activeThread, options); |
| 609 | 619 | }); |
| ... | ... | @@ -618,6 +628,7 @@ function useLocalDispatchState(input: { |
| 618 | 628 | latestUserMessageAt: latestUserMessage?.createdAt ?? null, |
| 619 | 629 | isPreparingWorktree: activeLocalDispatch?.preparingWorktree ?? false, |
| 620 | 630 | isSendBusy: activeLocalDispatch !== null, |
| 631 | backgroundSubmissionPending: localDispatch?.submissionIntent === "background", | |
| 621 | 632 | }; |
| 622 | 633 | } |
| 623 | 634 | |
| ... | ... | @@ -2346,6 +2357,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 2346 | 2357 | latestUserMessageAt, |
| 2347 | 2358 | isPreparingWorktree, |
| 2348 | 2359 | isSendBusy, |
| 2360 | backgroundSubmissionPending, | |
| 2349 | 2361 | } = useLocalDispatchState({ |
| 2350 | 2362 | activeThread, |
| 2351 | 2363 | activeLatestTurn, |
| ... | ... | @@ -2614,8 +2626,13 @@ function ChatViewContent(props: ChatViewProps) { |
| 2614 | 2626 | const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState<string | null>(null); |
| 2615 | 2627 | const draftHeroDockRequested = |
| 2616 | 2628 | activeThreadKey !== null && dockedDraftHeroThreadKey === activeThreadKey; |
| 2617 | const isDraftHeroState = | |
| 2618 | isLocalDraftThread && timelineEntries.length === 0 && !isWorking && !draftHeroDockRequested; | |
| 2629 | const isDraftHeroState = resolveDraftHeroState({ | |
| 2630 | isLocalDraftThread, | |
| 2631 | hasTimelineEntries: timelineEntries.length > 0, | |
| 2632 | isWorking, | |
| 2633 | draftHeroDockRequested, | |
| 2634 | backgroundSubmissionPending, | |
| 2635 | }); | |
| 2619 | 2636 | const [ |
| 2620 | 2637 | attachDraftHeroTransitionGroupRef, |
| 2621 | 2638 | attachDraftHeroComposerAnchorRef, |
| ... | ... | @@ -5022,6 +5039,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 5022 | 5039 | |
| 5023 | 5040 | const onSend = async ( |
| 5024 | 5041 | e?: { preventDefault: () => void }, |
| 5042 | submissionIntent: ComposerSubmissionIntent = "foreground", | |
| 5025 | 5043 | directAnnotation?: { |
| 5026 | 5044 | annotation: PreviewAnnotationPayload; |
| 5027 | 5045 | image: ComposerImageAttachment | null; |
| ... | ... | @@ -5230,8 +5248,17 @@ function ChatViewContent(props: ChatViewProps) { |
| 5230 | 5248 | return; |
| 5231 | 5249 | } |
| 5232 | 5250 | |
| 5251 | const resolvedSubmissionIntent = | |
| 5252 | submissionIntent === "background" && isLocalDraftThread ? "background" : "foreground"; | |
| 5233 | 5253 | sendInFlightRef.current = true; |
| 5234 | if (isDraftHeroState && activeThreadKey) { | |
| 5254 | if ( | |
| 5255 | shouldDockDraftHeroForSubmission({ | |
| 5256 | isDraftHeroState, | |
| 5257 | activeThreadKey, | |
| 5258 | submissionIntent: resolvedSubmissionIntent, | |
| 5259 | }) && | |
| 5260 | activeThreadKey | |
| 5261 | ) { | |
| 5235 | 5262 | let resolveDockStarted: (() => void) | undefined; |
| 5236 | 5263 | const dockStarted = new Promise<void>((resolve) => { |
| 5237 | 5264 | resolveDockStarted = resolve; |
| ... | ... | @@ -5246,7 +5273,10 @@ function ChatViewContent(props: ChatViewProps) { |
| 5246 | 5273 | void dockTransition.catch(() => resolveDockStarted?.()); |
| 5247 | 5274 | await dockStarted; |
| 5248 | 5275 | } |
| 5249 | beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) }); | |
| 5276 | beginLocalDispatch({ | |
| 5277 | preparingWorktree: Boolean(baseBranchForWorktree), | |
| 5278 | submissionIntent: resolvedSubmissionIntent, | |
| 5279 | }); | |
| 5250 | 5280 | |
| 5251 | 5281 | const messageIdForSend = newMessageId(); |
| 5252 | 5282 | const messageCreatedAt = new Date().toISOString(); |
| ... | ... | @@ -5408,6 +5438,13 @@ function ChatViewContent(props: ChatViewProps) { |
| 5408 | 5438 | } |
| 5409 | 5439 | : undefined; |
| 5410 | 5440 | beginLocalDispatch({ preparingWorktree: false }); |
| 5441 | const backgroundThreadRef = | |
| 5442 | resolvedSubmissionIntent === "background" | |
| 5443 | ? scopeThreadRef(activeThread.environmentId, threadIdForSend) | |
| 5444 | : null; | |
| 5445 | if (backgroundThreadRef) { | |
| 5446 | beginBackgroundDraftSubmissionByRef(backgroundThreadRef); | |
| 5447 | } | |
| 5411 | 5448 | const startResult = await startThreadTurn({ |
| 5412 | 5449 | environmentId, |
| 5413 | 5450 | input: { |
| ... | ... | @@ -5427,10 +5464,60 @@ function ChatViewContent(props: ChatViewProps) { |
| 5427 | 5464 | }, |
| 5428 | 5465 | }); |
| 5429 | 5466 | if (startResult._tag === "Failure") { |
| 5467 | if (backgroundThreadRef) { | |
| 5468 | clearBackgroundDraftSubmissionByRef(backgroundThreadRef); | |
| 5469 | } | |
| 5430 | 5470 | failure = startResult; |
| 5431 | 5471 | } else { |
| 5432 | 5472 | turnStartSucceeded = true; |
| 5433 | 5473 | acknowledgeActiveThreadWoke(); |
| 5474 | if (backgroundThreadRef) { | |
| 5475 | markPromotedDraftThreadByRef(backgroundThreadRef); | |
| 5476 | try { | |
| 5477 | const nextDraft = await handleNewThread( | |
| 5478 | scopeProjectRef(activeProject.environmentId, activeProject.id), | |
| 5479 | resolveBackgroundDraftWorkspaceOptions({ | |
| 5480 | envMode: sendEnvMode, | |
| 5481 | branch: activeThreadBranch, | |
| 5482 | startFromOrigin, | |
| 5483 | }), | |
| 5484 | ); | |
| 5485 | if (nextDraft) { | |
| 5486 | finalizePromotedDraftThreadByRef(backgroundThreadRef); | |
| 5487 | toastManager.add( | |
| 5488 | stackedThreadToast({ | |
| 5489 | type: "success", | |
| 5490 | title: "Started in background", | |
| 5491 | timeout: 5_000, | |
| 5492 | actionProps: { | |
| 5493 | children: "Open", | |
| 5494 | onClick: () => { | |
| 5495 | void navigate({ | |
| 5496 | to: "/$environmentId/$threadId", | |
| 5497 | params: buildThreadRouteParams(backgroundThreadRef), | |
| 5498 | }); | |
| 5499 | }, | |
| 5500 | }, | |
| 5501 | }), | |
| 5502 | ); | |
| 5503 | } else { | |
| 5504 | clearBackgroundDraftSubmissionByRef(backgroundThreadRef); | |
| 5505 | } | |
| 5506 | } catch (error) { | |
| 5507 | clearBackgroundDraftSubmissionByRef(backgroundThreadRef); | |
| 5508 | resetLocalDispatch(); | |
| 5509 | toastManager.add( | |
| 5510 | stackedThreadToast({ | |
| 5511 | type: "warning", | |
| 5512 | title: "Task started in the background", | |
| 5513 | description: | |
| 5514 | error instanceof Error | |
| 5515 | ? `Could not open a fresh composer: ${error.message}` | |
| 5516 | : "Could not open a fresh composer.", | |
| 5517 | }), | |
| 5518 | ); | |
| 5519 | } | |
| 5520 | } | |
| 5434 | 5521 | } |
| 5435 | 5522 | } |
| 5436 | 5523 | |
| ... | ... | @@ -6220,7 +6307,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 6220 | 6307 | configuredUrls={configuredPreviewUrls} |
| 6221 | 6308 | visible |
| 6222 | 6309 | onSendAnnotation={(annotation, image) => { |
| 6223 | void onSend(undefined, { annotation, image }); | |
| 6310 | void onSend(undefined, "foreground", { annotation, image }); | |
| 6224 | 6311 | }} |
| 6225 | 6312 | /> |
| 6226 | 6313 | </Suspense> |
apps/web/src/components/chat/ChatComposer.tsx+16-9| ... | ... | @@ -36,12 +36,13 @@ import { |
| 36 | 36 | import { createPortal } from "react-dom"; |
| 37 | 37 | import { |
| 38 | 38 | clampCollapsedComposerCursor, |
| 39 | type ComposerSubmissionIntent, | |
| 39 | 40 | type ComposerTrigger, |
| 40 | 41 | collapseExpandedComposerCursor, |
| 42 | composerSubmissionIntentForEnter, | |
| 41 | 43 | detectComposerTrigger, |
| 42 | 44 | expandCollapsedComposerCursor, |
| 43 | 45 | replaceTextRange, |
| 44 | shouldSubmitComposerOnEnter, | |
| 45 | 46 | } from "../../composer-logic"; |
| 46 | 47 | import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder"; |
| 47 | 48 | import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic"; |
| ... | ... | @@ -604,7 +605,7 @@ export interface ChatComposerProps { |
| 604 | 605 | composerRef: React.RefObject<ChatComposerHandle | null>; |
| 605 | 606 | |
| 606 | 607 | // Callbacks |
| 607 | onSend: (e?: { preventDefault: () => void }) => void; | |
| 608 | onSend: (e?: { preventDefault: () => void }, intent?: ComposerSubmissionIntent) => void; | |
| 608 | 609 | onInterrupt: () => void; |
| 609 | 610 | onImplementPlanInNewThread: () => void; |
| 610 | 611 | onRespondToApproval: ( |
| ... | ... | @@ -1886,7 +1887,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1886 | 1887 | ]); |
| 1887 | 1888 | |
| 1888 | 1889 | const submitComposer = useCallback( |
| 1889 | (event?: { preventDefault: () => void }) => { | |
| 1890 | (event?: { preventDefault: () => void }, intent: ComposerSubmissionIntent = "foreground") => { | |
| 1890 | 1891 | if (noProviderAvailable || isSendDisabled) { |
| 1891 | 1892 | event?.preventDefault(); |
| 1892 | 1893 | return; |
| ... | ... | @@ -1912,7 +1913,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1912 | 1913 | // ChatView reports its final composed-input preflight through the |
| 1913 | 1914 | // composer handle before its first asynchronous send step. |
| 1914 | 1915 | providerInputRejectedRef.current = false; |
| 1915 | onSend(sendEvent); | |
| 1916 | onSend(sendEvent, intent); | |
| 1916 | 1917 | return !providerInputRejectedRef.current; |
| 1917 | 1918 | }, |
| 1918 | 1919 | }); |
| ... | ... | @@ -1986,11 +1987,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1986 | 1987 | return true; |
| 1987 | 1988 | } |
| 1988 | 1989 | } |
| 1989 | if ( | |
| 1990 | key === "Enter" && | |
| 1991 | shouldSubmitComposerOnEnter({ isMobileViewport, shiftKey: event.shiftKey }) | |
| 1992 | ) { | |
| 1993 | submitComposer(); | |
| 1990 | const submissionIntent = | |
| 1991 | key === "Enter" | |
| 1992 | ? composerSubmissionIntentForEnter({ | |
| 1993 | isMobileViewport, | |
| 1994 | shiftKey: event.shiftKey, | |
| 1995 | modifierKey: event.metaKey || event.ctrlKey, | |
| 1996 | isDraftThread: routeKind === "draft", | |
| 1997 | }) | |
| 1998 | : null; | |
| 1999 | if (submissionIntent) { | |
| 2000 | submitComposer(undefined, submissionIntent); | |
| 1994 | 2001 | return true; |
| 1995 | 2002 | } |
| 1996 | 2003 | return false; |
apps/web/src/composer-logic.test.ts+48-5| ... | ... | @@ -3,26 +3,69 @@ import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | import { |
| 4 | 4 | clampCollapsedComposerCursor, |
| 5 | 5 | collapseExpandedComposerCursor, |
| 6 | composerSubmissionIntentForEnter, | |
| 6 | 7 | detectComposerTrigger, |
| 7 | 8 | expandCollapsedComposerCursor, |
| 8 | 9 | isCollapsedCursorAdjacentToInlineToken, |
| 9 | 10 | parseStandaloneComposerSlashCommand, |
| 10 | 11 | replaceTextRange, |
| 11 | shouldSubmitComposerOnEnter, | |
| 12 | 12 | } from "./composer-logic"; |
| 13 | 13 | import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; |
| 14 | 14 | |
| 15 | describe("shouldSubmitComposerOnEnter", () => { | |
| 15 | describe("composerSubmissionIntentForEnter", () => { | |
| 16 | 16 | it("submits plain Enter on desktop", () => { |
| 17 | expect(shouldSubmitComposerOnEnter({ isMobileViewport: false, shiftKey: false })).toBe(true); | |
| 17 | expect( | |
| 18 | composerSubmissionIntentForEnter({ | |
| 19 | isMobileViewport: false, | |
| 20 | shiftKey: false, | |
| 21 | modifierKey: false, | |
| 22 | isDraftThread: true, | |
| 23 | }), | |
| 24 | ).toBe("foreground"); | |
| 18 | 25 | }); |
| 19 | 26 | |
| 20 | 27 | it("inserts a newline for plain Enter on mobile", () => { |
| 21 | expect(shouldSubmitComposerOnEnter({ isMobileViewport: true, shiftKey: false })).toBe(false); | |
| 28 | expect( | |
| 29 | composerSubmissionIntentForEnter({ | |
| 30 | isMobileViewport: true, | |
| 31 | shiftKey: false, | |
| 32 | modifierKey: false, | |
| 33 | isDraftThread: true, | |
| 34 | }), | |
| 35 | ).toBeNull(); | |
| 22 | 36 | }); |
| 23 | 37 | |
| 24 | 38 | it("inserts a newline for Shift+Enter", () => { |
| 25 | expect(shouldSubmitComposerOnEnter({ isMobileViewport: false, shiftKey: true })).toBe(false); | |
| 39 | expect( | |
| 40 | composerSubmissionIntentForEnter({ | |
| 41 | isMobileViewport: false, | |
| 42 | shiftKey: true, | |
| 43 | modifierKey: false, | |
| 44 | isDraftThread: true, | |
| 45 | }), | |
| 46 | ).toBeNull(); | |
| 47 | }); | |
| 48 | ||
| 49 | it("submits a new thread in the background with Mod+Enter", () => { | |
| 50 | expect( | |
| 51 | composerSubmissionIntentForEnter({ | |
| 52 | isMobileViewport: false, | |
| 53 | shiftKey: false, | |
| 54 | modifierKey: true, | |
| 55 | isDraftThread: true, | |
| 56 | }), | |
| 57 | ).toBe("background"); | |
| 58 | }); | |
| 59 | ||
| 60 | it("keeps Mod+Enter in the foreground for an active thread", () => { | |
| 61 | expect( | |
| 62 | composerSubmissionIntentForEnter({ | |
| 63 | isMobileViewport: false, | |
| 64 | shiftKey: false, | |
| 65 | modifierKey: true, | |
| 66 | isDraftThread: false, | |
| 67 | }), | |
| 68 | ).toBe("foreground"); | |
| 26 | 69 | }); |
| 27 | 70 | }); |
| 28 | 71 |
apps/web/src/composer-logic.ts+9-3| ... | ... | @@ -3,6 +3,7 @@ import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; |
| 3 | 3 | |
| 4 | 4 | export type ComposerTriggerKind = "path" | "slash-command" | "skill"; |
| 5 | 5 | export type ComposerSlashCommand = "model" | "plan" | "default"; |
| 6 | export type ComposerSubmissionIntent = "foreground" | "background"; | |
| 6 | 7 | |
| 7 | 8 | export interface ComposerTrigger { |
| 8 | 9 | kind: ComposerTriggerKind; |
| ... | ... | @@ -11,11 +12,16 @@ export interface ComposerTrigger { |
| 11 | 12 | rangeEnd: number; |
| 12 | 13 | } |
| 13 | 14 | |
| 14 | export function shouldSubmitComposerOnEnter(input: { | |
| 15 | export function composerSubmissionIntentForEnter(input: { | |
| 15 | 16 | isMobileViewport: boolean; |
| 16 | 17 | shiftKey: boolean; |
| 17 | }): boolean { | |
| 18 | return !input.isMobileViewport && !input.shiftKey; | |
| 18 | modifierKey: boolean; | |
| 19 | isDraftThread: boolean; | |
| 20 | }): ComposerSubmissionIntent | null { | |
| 21 | if (input.isMobileViewport || input.shiftKey) { | |
| 22 | return null; | |
| 23 | } | |
| 24 | return input.modifierKey && input.isDraftThread ? "background" : "foreground"; | |
| 19 | 25 | } |
| 20 | 26 | |
| 21 | 27 | const isInlineTokenSegment = ( |
apps/web/src/composerDraftStore.ts+43| ... | ... | @@ -354,6 +354,7 @@ interface ComposerDraftStoreState { |
| 354 | 354 | draftsByThreadKey: Record<string, ComposerThreadDraftState>; |
| 355 | 355 | draftThreadsByThreadKey: Record<string, DraftThreadState>; |
| 356 | 356 | logicalProjectDraftThreadKeyByLogicalProjectKey: Record<string, string>; |
| 357 | backgroundSubmissionThreadKeys: Record<string, true>; | |
| 357 | 358 | stickyModelSelectionByProvider: Partial<Record<ProviderInstanceId, ModelSelection>>; |
| 358 | 359 | stickyActiveProvider: ProviderInstanceId | null; |
| 359 | 360 | /** Returns the editable composer content for a draft session or server thread. */ |
| ... | ... | @@ -2255,6 +2256,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 2255 | 2256 | draftsByThreadKey: {}, |
| 2256 | 2257 | draftThreadsByThreadKey: {}, |
| 2257 | 2258 | logicalProjectDraftThreadKeyByLogicalProjectKey: {}, |
| 2259 | backgroundSubmissionThreadKeys: {}, | |
| 2258 | 2260 | stickyModelSelectionByProvider: {}, |
| 2259 | 2261 | stickyActiveProvider: null, |
| 2260 | 2262 | getComposerDraft: (target) => getComposerDraftState(get(), target), |
| ... | ... | @@ -3578,6 +3580,40 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 3578 | 3580 | |
| 3579 | 3581 | export const useComposerDraftStore = composerDraftStore; |
| 3580 | 3582 | |
| 3583 | export function beginBackgroundDraftSubmissionByRef(threadRef: ScopedThreadRef): void { | |
| 3584 | const threadKey = scopedThreadKey(threadRef); | |
| 3585 | useComposerDraftStore.setState((state) => { | |
| 3586 | if (state.backgroundSubmissionThreadKeys[threadKey]) { | |
| 3587 | return state; | |
| 3588 | } | |
| 3589 | return { | |
| 3590 | backgroundSubmissionThreadKeys: { | |
| 3591 | ...state.backgroundSubmissionThreadKeys, | |
| 3592 | [threadKey]: true, | |
| 3593 | }, | |
| 3594 | }; | |
| 3595 | }); | |
| 3596 | } | |
| 3597 | ||
| 3598 | export function clearBackgroundDraftSubmissionByRef(threadRef: ScopedThreadRef): void { | |
| 3599 | const threadKey = scopedThreadKey(threadRef); | |
| 3600 | useComposerDraftStore.setState((state) => { | |
| 3601 | if (!state.backgroundSubmissionThreadKeys[threadKey]) { | |
| 3602 | return state; | |
| 3603 | } | |
| 3604 | const backgroundSubmissionThreadKeys = { ...state.backgroundSubmissionThreadKeys }; | |
| 3605 | delete backgroundSubmissionThreadKeys[threadKey]; | |
| 3606 | return { backgroundSubmissionThreadKeys }; | |
| 3607 | }); | |
| 3608 | } | |
| 3609 | ||
| 3610 | export function useBackgroundDraftSubmissionPending(threadRef: ScopedThreadRef | null): boolean { | |
| 3611 | const threadKey = threadRef ? scopedThreadKey(threadRef) : null; | |
| 3612 | return useComposerDraftStore( | |
| 3613 | (state) => threadKey !== null && state.backgroundSubmissionThreadKeys[threadKey] === true, | |
| 3614 | ); | |
| 3615 | } | |
| 3616 | ||
| 3581 | 3617 | export function clearComposerDraftsEnvironment(environmentId: EnvironmentId): void { |
| 3582 | 3618 | useComposerDraftStore.setState((state) => { |
| 3583 | 3619 | const removedThreadKeys = new Set<string>(); |
| ... | ... | @@ -3622,11 +3658,17 @@ export function clearComposerDraftsEnvironment(environmentId: EnvironmentId): vo |
| 3622 | 3658 | return false; |
| 3623 | 3659 | }), |
| 3624 | 3660 | ) as Record<string, ComposerThreadDraftState>; |
| 3661 | const nextBackgroundSubmissionThreadKeys = Object.fromEntries( | |
| 3662 | Object.entries(state.backgroundSubmissionThreadKeys).filter( | |
| 3663 | ([threadKey]) => parseScopedThreadKey(threadKey)?.environmentId !== environmentId, | |
| 3664 | ), | |
| 3665 | ) as Record<string, true>; | |
| 3625 | 3666 | |
| 3626 | 3667 | return { |
| 3627 | 3668 | draftsByThreadKey: nextDrafts, |
| 3628 | 3669 | draftThreadsByThreadKey: nextDraftThreads, |
| 3629 | 3670 | logicalProjectDraftThreadKeyByLogicalProjectKey: nextLogicalMappings, |
| 3671 | backgroundSubmissionThreadKeys: nextBackgroundSubmissionThreadKeys, | |
| 3630 | 3672 | }; |
| 3631 | 3673 | }); |
| 3632 | 3674 | composerDebouncedStorage.flush(); |
| ... | ... | @@ -3756,6 +3798,7 @@ export function finalizePromotedDraftThreadByRef(threadRef: ScopedThreadRef): vo |
| 3756 | 3798 | draftStore.finalizePromotedDraftThread(target); |
| 3757 | 3799 | } |
| 3758 | 3800 | } |
| 3801 | clearBackgroundDraftSubmissionByRef(threadRef); | |
| 3759 | 3802 | } |
| 3760 | 3803 | |
| 3761 | 3804 | export function finalizePromotedDraftThreadsByRef( |
apps/web/src/hooks/useHandleNewThread.ts+6-3| ... | ... | @@ -189,9 +189,12 @@ export function useNewThreadHandler() { |
| 189 | 189 | ? scopeThreadRef(storedDraftThread.environmentId, storedDraftThread.threadId) |
| 190 | 190 | : null; |
| 191 | 191 | const reusableStoredDraftThread = |
| 192 | storedDraftThreadRef && readThreadShell(storedDraftThreadRef) !== null | |
| 193 | ? null | |
| 194 | : storedDraftThread; | |
| 192 | storedDraftThread !== null && | |
| 193 | storedDraftThread.promotedTo == null && | |
| 194 | storedDraftThreadRef !== null && | |
| 195 | readThreadShell(storedDraftThreadRef) === null | |
| 196 | ? storedDraftThread | |
| 197 | : null; | |
| 195 | 198 | if (storedDraftThreadRef && reusableStoredDraftThread === null) { |
| 196 | 199 | markPromotedDraftThreadByRef(storedDraftThreadRef); |
| 197 | 200 | } |
apps/web/src/routes/_chat.draft.$draftId.tsx+11-2| ... | ... | @@ -1,10 +1,14 @@ |
| 1 | 1 | import { createFileRoute, useNavigate } from "@tanstack/react-router"; |
| 2 | 2 | import { useEffect } from "react"; |
| 3 | 3 | import ChatView from "../components/ChatView"; |
| 4 | import { threadHasStarted } from "../components/ChatView.logic"; | |
| 4 | import { | |
| 5 | resolveDraftPromotionNavigationTarget, | |
| 6 | threadHasStarted, | |
| 7 | } from "../components/ChatView.logic"; | |
| 5 | 8 | import { |
| 6 | 9 | DraftId, |
| 7 | 10 | markPromotedDraftThreadByRef, |
| 11 | useBackgroundDraftSubmissionPending, | |
| 8 | 12 | useComposerDraftStore, |
| 9 | 13 | } from "../composerDraftStore"; |
| 10 | 14 | import { SidebarInset } from "../components/ui/sidebar"; |
| ... | ... | @@ -28,7 +32,12 @@ function DraftChatThreadRouteView() { |
| 28 | 32 | const serverThreadRef = draftSession?.promotedTo ?? inferredThreadRef; |
| 29 | 33 | const serverThread = useThread(serverThreadRef); |
| 30 | 34 | const serverThreadStarted = threadHasStarted(serverThread); |
| 31 | const canonicalThreadRef = serverThreadStarted ? serverThreadRef : null; | |
| 35 | const backgroundSubmissionPending = useBackgroundDraftSubmissionPending(serverThreadRef); | |
| 36 | const canonicalThreadRef = resolveDraftPromotionNavigationTarget({ | |
| 37 | serverThreadRef, | |
| 38 | serverThreadStarted, | |
| 39 | backgroundSubmissionPending, | |
| 40 | }); | |
| 32 | 41 | |
| 33 | 42 | useEffect(() => { |
| 34 | 43 | if (!inferredThreadRef || draftSession?.promotedTo) { |
docs/user/composer.md+5| ... | ... | @@ -3,3 +3,8 @@ |
| 3 | 3 | Messages can contain up to 120,000 characters. If a draft is longer, T3 Code keeps it in the |
| 4 | 4 | composer and shows how many characters need to be removed. Shorten the draft or split it into |
| 5 | 5 | multiple messages, then send again in the same thread. |
| 6 | ||
| 7 | On desktop, press `Cmd+Enter` on macOS or `Ctrl+Enter` on Windows and Linux from a new thread to | |
| 8 | start it in the background. T3 Code opens another new thread and shows an **Open** action for the | |
| 9 | thread that started. The new thread keeps the selected workspace mode and base branch. If **New | |
| 10 | worktree** is selected, each background thread creates its own worktree. |
docs/user/keybindings.md+4| ... | ... | @@ -67,6 +67,10 @@ at. To keep a worktree, use the explicit "new thread in this worktree" action in |
| 67 | 67 | toolbar. The only difference between the two commands: with the current sidebar and more than one |
| 68 | 68 | project, `chat.new` opens a project chooser first. |
| 69 | 69 | |
| 70 | Background submission from a new thread is the exception. `mod+enter` starts that thread and opens | |
| 71 | another new thread with the same workspace mode and base branch. **New worktree** remains selected, | |
| 72 | but the new thread does not reuse the worktree created for the thread that just started. | |
| 73 | ||
| 70 | 74 | ## `when` Conditions |
| 71 | 75 | |
| 72 | 76 | A `when` expression is evaluated against context keys describing the current UI state. The keys |