| 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,10 +26,14 @@ import { |
| 26 | isBranchMismatchDismissedForSession, | 26 | isBranchMismatchDismissedForSession, |
| 27 | reconcileMountedTerminalThreadIds, | 27 | reconcileMountedTerminalThreadIds, |
| 28 | reconcileRetainedMountedThreadIds, | 28 | reconcileRetainedMountedThreadIds, |
| 29 | resolveBackgroundDraftWorkspaceOptions, | ||
| 30 | resolveDraftPromotionNavigationTarget, | ||
| 29 | resolveThreadMetadataUpdateForNextTurn, | 31 | resolveThreadMetadataUpdateForNextTurn, |
| 30 | resolveSendEnvMode, | 32 | resolveSendEnvMode, |
| 33 | resolveDraftHeroState, | ||
| 31 | scheduleEnvironmentReconnectWarning, | 34 | scheduleEnvironmentReconnectWarning, |
| 32 | startNewThreadForProject, | 35 | startNewThreadForProject, |
| 36 | shouldDockDraftHeroForSubmission, | ||
| 33 | shouldShowBranchMismatchBanner, | 37 | shouldShowBranchMismatchBanner, |
| 34 | shouldWriteThreadErrorToCurrentServerThread, | 38 | shouldWriteThreadErrorToCurrentServerThread, |
| 35 | } from "./ChatView.logic"; | 39 | } from "./ChatView.logic"; |
| ... | @@ -39,6 +43,40 @@ const projectId = ProjectId.make("project-1"); | ... | @@ -39,6 +43,40 @@ const projectId = ProjectId.make("project-1"); |
| 39 | const threadId = ThreadId.make("thread-1"); | 43 | const threadId = ThreadId.make("thread-1"); |
| 40 | const now = "2026-03-29T00:00:00.000Z"; | 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 | describe("environment reconnect warning grace", () => { | 80 | describe("environment reconnect warning grace", () => { |
| 43 | afterEach(() => vi.useRealTimers()); | 81 | afterEach(() => vi.useRealTimers()); |
| 44 | 82 | ||
| ... | @@ -382,6 +420,23 @@ describe("resolveSendEnvMode", () => { | ... | @@ -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 | describe("branchMismatchKey", () => { | 440 | describe("branchMismatchKey", () => { |
| 386 | it("builds a key from thread id and both branches", () => { | 441 | it("builds a key from thread id and both branches", () => { |
| 387 | expect(branchMismatchKey("thread-1", { threadBranch: "feat/a", currentBranch: "feat/b" })).toBe( | 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,6 +21,7 @@ import { |
| 21 | type TerminalContextDraft, | 21 | type TerminalContextDraft, |
| 22 | } from "../lib/terminalContext"; | 22 | } from "../lib/terminalContext"; |
| 23 | import type { DraftThreadEnvMode } from "../composerDraftStore"; | 23 | import type { DraftThreadEnvMode } from "../composerDraftStore"; |
| 24 | import type { ComposerSubmissionIntent } from "../composer-logic"; | ||
| 24 | 25 | ||
| 25 | export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; | 26 | export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; |
| 26 | export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; | 27 | export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; |
| ... | @@ -29,6 +30,47 @@ export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000; | ... | @@ -29,6 +30,47 @@ export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000; |
| 29 | 30 | ||
| 30 | export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); | 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 | export function scheduleEnvironmentReconnectWarning(showWarning: () => void): () => void { | 74 | export function scheduleEnvironmentReconnectWarning(showWarning: () => void): () => void { |
| 33 | const timeoutId = globalThis.setTimeout(showWarning, ENVIRONMENT_RECONNECT_WARNING_GRACE_MS); | 75 | const timeoutId = globalThis.setTimeout(showWarning, ENVIRONMENT_RECONNECT_WARNING_GRACE_MS); |
| 34 | return () => globalThis.clearTimeout(timeoutId); | 76 | return () => globalThis.clearTimeout(timeoutId); |
| ... | @@ -257,6 +299,24 @@ export function resolveSendEnvMode(input: { | ... | @@ -257,6 +299,24 @@ export function resolveSendEnvMode(input: { |
| 257 | return input.isGitRepo ? input.requestedEnvMode : "local"; | 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 | export function cloneComposerImageForRetry( | 320 | export function cloneComposerImageForRetry( |
| 261 | image: ComposerImageAttachment, | 321 | image: ComposerImageAttachment, |
| 262 | ): ComposerImageAttachment { | 322 | ): ComposerImageAttachment { |
| ... | @@ -491,6 +551,7 @@ export async function waitForStartedServerThread( | ... | @@ -491,6 +551,7 @@ export async function waitForStartedServerThread( |
| 491 | export interface LocalDispatchSnapshot { | 551 | export interface LocalDispatchSnapshot { |
| 492 | startedAt: string; | 552 | startedAt: string; |
| 493 | preparingWorktree: boolean; | 553 | preparingWorktree: boolean; |
| 554 | submissionIntent: ComposerSubmissionIntent; | ||
| 494 | latestUserMessageId: ChatMessage["id"] | null; | 555 | latestUserMessageId: ChatMessage["id"] | null; |
| 495 | latestTurnTurnId: TurnId | null; | 556 | latestTurnTurnId: TurnId | null; |
| 496 | latestTurnRequestedAt: string | null; | 557 | latestTurnRequestedAt: string | null; |
| ... | @@ -502,7 +563,10 @@ export interface LocalDispatchSnapshot { | ... | @@ -502,7 +563,10 @@ export interface LocalDispatchSnapshot { |
| 502 | 563 | ||
| 503 | export function createLocalDispatchSnapshot( | 564 | export function createLocalDispatchSnapshot( |
| 504 | activeThread: Thread | undefined, | 565 | activeThread: Thread | undefined, |
| 505 | options?: { preparingWorktree?: boolean }, | 566 | options?: { |
| 567 | preparingWorktree?: boolean; | ||
| 568 | submissionIntent?: ComposerSubmissionIntent; | ||
| 569 | }, | ||
| 506 | ): LocalDispatchSnapshot { | 570 | ): LocalDispatchSnapshot { |
| 507 | const latestTurn = activeThread?.latestTurn ?? null; | 571 | const latestTurn = activeThread?.latestTurn ?? null; |
| 508 | const session = activeThread?.session ?? null; | 572 | const session = activeThread?.session ?? null; |
| ... | @@ -510,6 +574,7 @@ export function createLocalDispatchSnapshot( | ... | @@ -510,6 +574,7 @@ export function createLocalDispatchSnapshot( |
| 510 | return { | 574 | return { |
| 511 | startedAt: new Date().toISOString(), | 575 | startedAt: new Date().toISOString(), |
| 512 | preparingWorktree: Boolean(options?.preparingWorktree), | 576 | preparingWorktree: Boolean(options?.preparingWorktree), |
| 577 | submissionIntent: options?.submissionIntent ?? "foreground", | ||
| 513 | latestUserMessageId: latestUserMessage?.id ?? null, | 578 | latestUserMessageId: latestUserMessage?.id ?? null, |
| 514 | latestTurnTurnId: latestTurn?.turnId ?? null, | 579 | latestTurnTurnId: latestTurn?.turnId ?? null, |
| 515 | latestTurnRequestedAt: latestTurn?.requestedAt ?? null, | 580 | latestTurnRequestedAt: latestTurn?.requestedAt ?? null, |
apps/web/src/components/ChatView.tsx+96-9| ... | @@ -82,6 +82,7 @@ import { readLocalApi } from "../localApi"; | ... | @@ -82,6 +82,7 @@ import { readLocalApi } from "../localApi"; |
| 82 | import { useDiffPanelStore } from "../diffPanelStore"; | 82 | import { useDiffPanelStore } from "../diffPanelStore"; |
| 83 | import { | 83 | import { |
| 84 | collapseExpandedComposerCursor, | 84 | collapseExpandedComposerCursor, |
| 85 | type ComposerSubmissionIntent, | ||
| 85 | parseStandaloneComposerSlashCommand, | 86 | parseStandaloneComposerSlashCommand, |
| 86 | } from "../composer-logic"; | 87 | } from "../composer-logic"; |
| 87 | import { | 88 | import { |
| ... | @@ -210,10 +211,14 @@ import { | ... | @@ -210,10 +211,14 @@ import { |
| 210 | selectProjectGroupingSettings, | 211 | selectProjectGroupingSettings, |
| 211 | } from "../logicalProject"; | 212 | } from "../logicalProject"; |
| 212 | import { buildPhysicalToLogicalProjectKeyMap } from "../sidebarProjectGrouping"; | 213 | import { buildPhysicalToLogicalProjectKeyMap } from "../sidebarProjectGrouping"; |
| 213 | import { buildDraftThreadRouteParams } from "../threadRoutes"; | 214 | import { buildDraftThreadRouteParams, buildThreadRouteParams } from "../threadRoutes"; |
| 214 | import { | 215 | import { |
| 216 | beginBackgroundDraftSubmissionByRef, | ||
| 217 | clearBackgroundDraftSubmissionByRef, | ||
| 215 | type ComposerImageAttachment, | 218 | type ComposerImageAttachment, |
| 216 | type DraftThreadEnvMode, | 219 | type DraftThreadEnvMode, |
| 220 | finalizePromotedDraftThreadByRef, | ||
| 221 | markPromotedDraftThreadByRef, | ||
| 217 | useComposerDraftStore, | 222 | useComposerDraftStore, |
| 218 | type DraftId, | 223 | type DraftId, |
| 219 | } from "../composerDraftStore"; | 224 | } from "../composerDraftStore"; |
| ... | @@ -315,6 +320,7 @@ import { | ... | @@ -315,6 +320,7 @@ import { |
| 315 | scheduleEnvironmentReconnectWarning, | 320 | scheduleEnvironmentReconnectWarning, |
| 316 | hasServerAcknowledgedLocalDispatch, | 321 | hasServerAcknowledgedLocalDispatch, |
| 317 | isBranchMismatchDismissedForSession, | 322 | isBranchMismatchDismissedForSession, |
| 323 | shouldDockDraftHeroForSubmission, | ||
| 318 | shouldShowBranchMismatchBanner, | 324 | shouldShowBranchMismatchBanner, |
| 319 | getStartedThreadModelChangeBlockReason, | 325 | getStartedThreadModelChangeBlockReason, |
| 320 | LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, | 326 | LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, |
| ... | @@ -325,6 +331,8 @@ import { | ... | @@ -325,6 +331,8 @@ import { |
| 325 | deriveLockedProvider, | 331 | deriveLockedProvider, |
| 326 | readFileAsDataUrl, | 332 | readFileAsDataUrl, |
| 327 | reconcileMountedTerminalThreadIds, | 333 | reconcileMountedTerminalThreadIds, |
| 334 | resolveBackgroundDraftWorkspaceOptions, | ||
| 335 | resolveDraftHeroState, | ||
| 328 | resolveThreadMetadataUpdateForNextTurn, | 336 | resolveThreadMetadataUpdateForNextTurn, |
| 329 | resolveSendEnvMode, | 337 | resolveSendEnvMode, |
| 330 | revokeBlobPreviewUrl, | 338 | revokeBlobPreviewUrl, |
| ... | @@ -596,14 +604,16 @@ function useLocalDispatchState(input: { | ... | @@ -596,14 +604,16 @@ function useLocalDispatchState(input: { |
| 596 | ); | 604 | ); |
| 597 | const activeLocalDispatch = serverAcknowledgedLocalDispatch ? null : localDispatch; | 605 | const activeLocalDispatch = serverAcknowledgedLocalDispatch ? null : localDispatch; |
| 598 | const beginLocalDispatch = useCallback( | 606 | const beginLocalDispatch = useCallback( |
| 599 | (options?: { preparingWorktree?: boolean }) => { | 607 | (options?: { preparingWorktree?: boolean; submissionIntent?: ComposerSubmissionIntent }) => { |
| 600 | const preparingWorktree = Boolean(options?.preparingWorktree); | 608 | const preparingWorktree = Boolean(options?.preparingWorktree); |
| 601 | setLocalDispatch((current) => { | 609 | setLocalDispatch((current) => { |
| 602 | const active = serverAcknowledgedLocalDispatch ? null : current; | 610 | const active = serverAcknowledgedLocalDispatch ? null : current; |
| 603 | if (active) { | 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 | ? active | 615 | ? active |
| 606 | : { ...active, preparingWorktree }; | 616 | : { ...active, preparingWorktree, submissionIntent }; |
| 607 | } | 617 | } |
| 608 | return createLocalDispatchSnapshot(input.activeThread, options); | 618 | return createLocalDispatchSnapshot(input.activeThread, options); |
| 609 | }); | 619 | }); |
| ... | @@ -618,6 +628,7 @@ function useLocalDispatchState(input: { | ... | @@ -618,6 +628,7 @@ function useLocalDispatchState(input: { |
| 618 | latestUserMessageAt: latestUserMessage?.createdAt ?? null, | 628 | latestUserMessageAt: latestUserMessage?.createdAt ?? null, |
| 619 | isPreparingWorktree: activeLocalDispatch?.preparingWorktree ?? false, | 629 | isPreparingWorktree: activeLocalDispatch?.preparingWorktree ?? false, |
| 620 | isSendBusy: activeLocalDispatch !== null, | 630 | isSendBusy: activeLocalDispatch !== null, |
| 631 | backgroundSubmissionPending: localDispatch?.submissionIntent === "background", | ||
| 621 | }; | 632 | }; |
| 622 | } | 633 | } |
| 623 | 634 | ||
| ... | @@ -2346,6 +2357,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -2346,6 +2357,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 2346 | latestUserMessageAt, | 2357 | latestUserMessageAt, |
| 2347 | isPreparingWorktree, | 2358 | isPreparingWorktree, |
| 2348 | isSendBusy, | 2359 | isSendBusy, |
| 2360 | backgroundSubmissionPending, | ||
| 2349 | } = useLocalDispatchState({ | 2361 | } = useLocalDispatchState({ |
| 2350 | activeThread, | 2362 | activeThread, |
| 2351 | activeLatestTurn, | 2363 | activeLatestTurn, |
| ... | @@ -2614,8 +2626,13 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -2614,8 +2626,13 @@ function ChatViewContent(props: ChatViewProps) { |
| 2614 | const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState<string | null>(null); | 2626 | const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState<string | null>(null); |
| 2615 | const draftHeroDockRequested = | 2627 | const draftHeroDockRequested = |
| 2616 | activeThreadKey !== null && dockedDraftHeroThreadKey === activeThreadKey; | 2628 | activeThreadKey !== null && dockedDraftHeroThreadKey === activeThreadKey; |
| 2617 | const isDraftHeroState = | 2629 | const isDraftHeroState = resolveDraftHeroState({ |
| 2618 | isLocalDraftThread && timelineEntries.length === 0 && !isWorking && !draftHeroDockRequested; | 2630 | isLocalDraftThread, |
| 2631 | hasTimelineEntries: timelineEntries.length > 0, | ||
| 2632 | isWorking, | ||
| 2633 | draftHeroDockRequested, | ||
| 2634 | backgroundSubmissionPending, | ||
| 2635 | }); | ||
| 2619 | const [ | 2636 | const [ |
| 2620 | attachDraftHeroTransitionGroupRef, | 2637 | attachDraftHeroTransitionGroupRef, |
| 2621 | attachDraftHeroComposerAnchorRef, | 2638 | attachDraftHeroComposerAnchorRef, |
| ... | @@ -5022,6 +5039,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -5022,6 +5039,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 5022 | 5039 | ||
| 5023 | const onSend = async ( | 5040 | const onSend = async ( |
| 5024 | e?: { preventDefault: () => void }, | 5041 | e?: { preventDefault: () => void }, |
| 5042 | submissionIntent: ComposerSubmissionIntent = "foreground", | ||
| 5025 | directAnnotation?: { | 5043 | directAnnotation?: { |
| 5026 | annotation: PreviewAnnotationPayload; | 5044 | annotation: PreviewAnnotationPayload; |
| 5027 | image: ComposerImageAttachment | null; | 5045 | image: ComposerImageAttachment | null; |
| ... | @@ -5230,8 +5248,17 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -5230,8 +5248,17 @@ function ChatViewContent(props: ChatViewProps) { |
| 5230 | return; | 5248 | return; |
| 5231 | } | 5249 | } |
| 5232 | 5250 | ||
| 5251 | const resolvedSubmissionIntent = | ||
| 5252 | submissionIntent === "background" && isLocalDraftThread ? "background" : "foreground"; | ||
| 5233 | sendInFlightRef.current = true; | 5253 | sendInFlightRef.current = true; |
| 5234 | if (isDraftHeroState && activeThreadKey) { | 5254 | if ( |
| 5255 | shouldDockDraftHeroForSubmission({ | ||
| 5256 | isDraftHeroState, | ||
| 5257 | activeThreadKey, | ||
| 5258 | submissionIntent: resolvedSubmissionIntent, | ||
| 5259 | }) && | ||
| 5260 | activeThreadKey | ||
| 5261 | ) { | ||
| 5235 | let resolveDockStarted: (() => void) | undefined; | 5262 | let resolveDockStarted: (() => void) | undefined; |
| 5236 | const dockStarted = new Promise<void>((resolve) => { | 5263 | const dockStarted = new Promise<void>((resolve) => { |
| 5237 | resolveDockStarted = resolve; | 5264 | resolveDockStarted = resolve; |
| ... | @@ -5246,7 +5273,10 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -5246,7 +5273,10 @@ function ChatViewContent(props: ChatViewProps) { |
| 5246 | void dockTransition.catch(() => resolveDockStarted?.()); | 5273 | void dockTransition.catch(() => resolveDockStarted?.()); |
| 5247 | await dockStarted; | 5274 | await dockStarted; |
| 5248 | } | 5275 | } |
| 5249 | beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) }); | 5276 | beginLocalDispatch({ |
| 5277 | preparingWorktree: Boolean(baseBranchForWorktree), | ||
| 5278 | submissionIntent: resolvedSubmissionIntent, | ||
| 5279 | }); | ||
| 5250 | 5280 | ||
| 5251 | const messageIdForSend = newMessageId(); | 5281 | const messageIdForSend = newMessageId(); |
| 5252 | const messageCreatedAt = new Date().toISOString(); | 5282 | const messageCreatedAt = new Date().toISOString(); |
| ... | @@ -5408,6 +5438,13 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -5408,6 +5438,13 @@ function ChatViewContent(props: ChatViewProps) { |
| 5408 | } | 5438 | } |
| 5409 | : undefined; | 5439 | : undefined; |
| 5410 | beginLocalDispatch({ preparingWorktree: false }); | 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 | const startResult = await startThreadTurn({ | 5448 | const startResult = await startThreadTurn({ |
| 5412 | environmentId, | 5449 | environmentId, |
| 5413 | input: { | 5450 | input: { |
| ... | @@ -5427,10 +5464,60 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -5427,10 +5464,60 @@ function ChatViewContent(props: ChatViewProps) { |
| 5427 | }, | 5464 | }, |
| 5428 | }); | 5465 | }); |
| 5429 | if (startResult._tag === "Failure") { | 5466 | if (startResult._tag === "Failure") { |
| 5467 | if (backgroundThreadRef) { | ||
| 5468 | clearBackgroundDraftSubmissionByRef(backgroundThreadRef); | ||
| 5469 | } | ||
| 5430 | failure = startResult; | 5470 | failure = startResult; |
| 5431 | } else { | 5471 | } else { |
| 5432 | turnStartSucceeded = true; | 5472 | turnStartSucceeded = true; |
| 5433 | acknowledgeActiveThreadWoke(); | 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,7 +6307,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 6220 | configuredUrls={configuredPreviewUrls} | 6307 | configuredUrls={configuredPreviewUrls} |
| 6221 | visible | 6308 | visible |
| 6222 | onSendAnnotation={(annotation, image) => { | 6309 | onSendAnnotation={(annotation, image) => { |
| 6223 | void onSend(undefined, { annotation, image }); | 6310 | void onSend(undefined, "foreground", { annotation, image }); |
| 6224 | }} | 6311 | }} |
| 6225 | /> | 6312 | /> |
| 6226 | </Suspense> | 6313 | </Suspense> |
apps/web/src/components/chat/ChatComposer.tsx+16-9| ... | @@ -36,12 +36,13 @@ import { | ... | @@ -36,12 +36,13 @@ import { |
| 36 | import { createPortal } from "react-dom"; | 36 | import { createPortal } from "react-dom"; |
| 37 | import { | 37 | import { |
| 38 | clampCollapsedComposerCursor, | 38 | clampCollapsedComposerCursor, |
| 39 | type ComposerSubmissionIntent, | ||
| 39 | type ComposerTrigger, | 40 | type ComposerTrigger, |
| 40 | collapseExpandedComposerCursor, | 41 | collapseExpandedComposerCursor, |
| 42 | composerSubmissionIntentForEnter, | ||
| 41 | detectComposerTrigger, | 43 | detectComposerTrigger, |
| 42 | expandCollapsedComposerCursor, | 44 | expandCollapsedComposerCursor, |
| 43 | replaceTextRange, | 45 | replaceTextRange, |
| 44 | shouldSubmitComposerOnEnter, | ||
| 45 | } from "../../composer-logic"; | 46 | } from "../../composer-logic"; |
| 46 | import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder"; | 47 | import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder"; |
| 47 | import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic"; | 48 | import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic"; |
| ... | @@ -604,7 +605,7 @@ export interface ChatComposerProps { | ... | @@ -604,7 +605,7 @@ export interface ChatComposerProps { |
| 604 | composerRef: React.RefObject<ChatComposerHandle | null>; | 605 | composerRef: React.RefObject<ChatComposerHandle | null>; |
| 605 | 606 | ||
| 606 | // Callbacks | 607 | // Callbacks |
| 607 | onSend: (e?: { preventDefault: () => void }) => void; | 608 | onSend: (e?: { preventDefault: () => void }, intent?: ComposerSubmissionIntent) => void; |
| 608 | onInterrupt: () => void; | 609 | onInterrupt: () => void; |
| 609 | onImplementPlanInNewThread: () => void; | 610 | onImplementPlanInNewThread: () => void; |
| 610 | onRespondToApproval: ( | 611 | onRespondToApproval: ( |
| ... | @@ -1886,7 +1887,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -1886,7 +1887,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1886 | ]); | 1887 | ]); |
| 1887 | 1888 | ||
| 1888 | const submitComposer = useCallback( | 1889 | const submitComposer = useCallback( |
| 1889 | (event?: { preventDefault: () => void }) => { | 1890 | (event?: { preventDefault: () => void }, intent: ComposerSubmissionIntent = "foreground") => { |
| 1890 | if (noProviderAvailable || isSendDisabled) { | 1891 | if (noProviderAvailable || isSendDisabled) { |
| 1891 | event?.preventDefault(); | 1892 | event?.preventDefault(); |
| 1892 | return; | 1893 | return; |
| ... | @@ -1912,7 +1913,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -1912,7 +1913,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1912 | // ChatView reports its final composed-input preflight through the | 1913 | // ChatView reports its final composed-input preflight through the |
| 1913 | // composer handle before its first asynchronous send step. | 1914 | // composer handle before its first asynchronous send step. |
| 1914 | providerInputRejectedRef.current = false; | 1915 | providerInputRejectedRef.current = false; |
| 1915 | onSend(sendEvent); | 1916 | onSend(sendEvent, intent); |
| 1916 | return !providerInputRejectedRef.current; | 1917 | return !providerInputRejectedRef.current; |
| 1917 | }, | 1918 | }, |
| 1918 | }); | 1919 | }); |
| ... | @@ -1986,11 +1987,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -1986,11 +1987,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1986 | return true; | 1987 | return true; |
| 1987 | } | 1988 | } |
| 1988 | } | 1989 | } |
| 1989 | if ( | 1990 | const submissionIntent = |
| 1990 | key === "Enter" && | 1991 | key === "Enter" |
| 1991 | shouldSubmitComposerOnEnter({ isMobileViewport, shiftKey: event.shiftKey }) | 1992 | ? composerSubmissionIntentForEnter({ |
| 1992 | ) { | 1993 | isMobileViewport, |
| 1993 | submitComposer(); | 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 | return true; | 2001 | return true; |
| 1995 | } | 2002 | } |
| 1996 | return false; | 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,26 +3,69 @@ import { describe, expect, it } from "vite-plus/test"; |
| 3 | import { | 3 | import { |
| 4 | clampCollapsedComposerCursor, | 4 | clampCollapsedComposerCursor, |
| 5 | collapseExpandedComposerCursor, | 5 | collapseExpandedComposerCursor, |
| 6 | composerSubmissionIntentForEnter, | ||
| 6 | detectComposerTrigger, | 7 | detectComposerTrigger, |
| 7 | expandCollapsedComposerCursor, | 8 | expandCollapsedComposerCursor, |
| 8 | isCollapsedCursorAdjacentToInlineToken, | 9 | isCollapsedCursorAdjacentToInlineToken, |
| 9 | parseStandaloneComposerSlashCommand, | 10 | parseStandaloneComposerSlashCommand, |
| 10 | replaceTextRange, | 11 | replaceTextRange, |
| 11 | shouldSubmitComposerOnEnter, | ||
| 12 | } from "./composer-logic"; | 12 | } from "./composer-logic"; |
| 13 | import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; | 13 | import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; |
| 14 | 14 | ||
| 15 | describe("shouldSubmitComposerOnEnter", () => { | 15 | describe("composerSubmissionIntentForEnter", () => { |
| 16 | it("submits plain Enter on desktop", () => { | 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 | it("inserts a newline for plain Enter on mobile", () => { | 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 | it("inserts a newline for Shift+Enter", () => { | 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,6 +3,7 @@ import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; |
| 3 | 3 | ||
| 4 | export type ComposerTriggerKind = "path" | "slash-command" | "skill"; | 4 | export type ComposerTriggerKind = "path" | "slash-command" | "skill"; |
| 5 | export type ComposerSlashCommand = "model" | "plan" | "default"; | 5 | export type ComposerSlashCommand = "model" | "plan" | "default"; |
| 6 | export type ComposerSubmissionIntent = "foreground" | "background"; | ||
| 6 | 7 | ||
| 7 | export interface ComposerTrigger { | 8 | export interface ComposerTrigger { |
| 8 | kind: ComposerTriggerKind; | 9 | kind: ComposerTriggerKind; |
| ... | @@ -11,11 +12,16 @@ export interface ComposerTrigger { | ... | @@ -11,11 +12,16 @@ export interface ComposerTrigger { |
| 11 | rangeEnd: number; | 12 | rangeEnd: number; |
| 12 | } | 13 | } |
| 13 | 14 | ||
| 14 | export function shouldSubmitComposerOnEnter(input: { | 15 | export function composerSubmissionIntentForEnter(input: { |
| 15 | isMobileViewport: boolean; | 16 | isMobileViewport: boolean; |
| 16 | shiftKey: boolean; | 17 | shiftKey: boolean; |
| 17 | }): boolean { | 18 | modifierKey: boolean; |
| 18 | return !input.isMobileViewport && !input.shiftKey; | 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 | const isInlineTokenSegment = ( | 27 | const isInlineTokenSegment = ( |
apps/web/src/composerDraftStore.ts+43| ... | @@ -354,6 +354,7 @@ interface ComposerDraftStoreState { | ... | @@ -354,6 +354,7 @@ interface ComposerDraftStoreState { |
| 354 | draftsByThreadKey: Record<string, ComposerThreadDraftState>; | 354 | draftsByThreadKey: Record<string, ComposerThreadDraftState>; |
| 355 | draftThreadsByThreadKey: Record<string, DraftThreadState>; | 355 | draftThreadsByThreadKey: Record<string, DraftThreadState>; |
| 356 | logicalProjectDraftThreadKeyByLogicalProjectKey: Record<string, string>; | 356 | logicalProjectDraftThreadKeyByLogicalProjectKey: Record<string, string>; |
| 357 | backgroundSubmissionThreadKeys: Record<string, true>; | ||
| 357 | stickyModelSelectionByProvider: Partial<Record<ProviderInstanceId, ModelSelection>>; | 358 | stickyModelSelectionByProvider: Partial<Record<ProviderInstanceId, ModelSelection>>; |
| 358 | stickyActiveProvider: ProviderInstanceId | null; | 359 | stickyActiveProvider: ProviderInstanceId | null; |
| 359 | /** Returns the editable composer content for a draft session or server thread. */ | 360 | /** Returns the editable composer content for a draft session or server thread. */ |
| ... | @@ -2255,6 +2256,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()( | ... | @@ -2255,6 +2256,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 2255 | draftsByThreadKey: {}, | 2256 | draftsByThreadKey: {}, |
| 2256 | draftThreadsByThreadKey: {}, | 2257 | draftThreadsByThreadKey: {}, |
| 2257 | logicalProjectDraftThreadKeyByLogicalProjectKey: {}, | 2258 | logicalProjectDraftThreadKeyByLogicalProjectKey: {}, |
| 2259 | backgroundSubmissionThreadKeys: {}, | ||
| 2258 | stickyModelSelectionByProvider: {}, | 2260 | stickyModelSelectionByProvider: {}, |
| 2259 | stickyActiveProvider: null, | 2261 | stickyActiveProvider: null, |
| 2260 | getComposerDraft: (target) => getComposerDraftState(get(), target), | 2262 | getComposerDraft: (target) => getComposerDraftState(get(), target), |
| ... | @@ -3578,6 +3580,40 @@ const composerDraftStore = create<ComposerDraftStoreState>()( | ... | @@ -3578,6 +3580,40 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 3578 | 3580 | ||
| 3579 | export const useComposerDraftStore = composerDraftStore; | 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 | export function clearComposerDraftsEnvironment(environmentId: EnvironmentId): void { | 3617 | export function clearComposerDraftsEnvironment(environmentId: EnvironmentId): void { |
| 3582 | useComposerDraftStore.setState((state) => { | 3618 | useComposerDraftStore.setState((state) => { |
| 3583 | const removedThreadKeys = new Set<string>(); | 3619 | const removedThreadKeys = new Set<string>(); |
| ... | @@ -3622,11 +3658,17 @@ export function clearComposerDraftsEnvironment(environmentId: EnvironmentId): vo | ... | @@ -3622,11 +3658,17 @@ export function clearComposerDraftsEnvironment(environmentId: EnvironmentId): vo |
| 3622 | return false; | 3658 | return false; |
| 3623 | }), | 3659 | }), |
| 3624 | ) as Record<string, ComposerThreadDraftState>; | 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 | return { | 3667 | return { |
| 3627 | draftsByThreadKey: nextDrafts, | 3668 | draftsByThreadKey: nextDrafts, |
| 3628 | draftThreadsByThreadKey: nextDraftThreads, | 3669 | draftThreadsByThreadKey: nextDraftThreads, |
| 3629 | logicalProjectDraftThreadKeyByLogicalProjectKey: nextLogicalMappings, | 3670 | logicalProjectDraftThreadKeyByLogicalProjectKey: nextLogicalMappings, |
| 3671 | backgroundSubmissionThreadKeys: nextBackgroundSubmissionThreadKeys, | ||
| 3630 | }; | 3672 | }; |
| 3631 | }); | 3673 | }); |
| 3632 | composerDebouncedStorage.flush(); | 3674 | composerDebouncedStorage.flush(); |
| ... | @@ -3756,6 +3798,7 @@ export function finalizePromotedDraftThreadByRef(threadRef: ScopedThreadRef): vo | ... | @@ -3756,6 +3798,7 @@ export function finalizePromotedDraftThreadByRef(threadRef: ScopedThreadRef): vo |
| 3756 | draftStore.finalizePromotedDraftThread(target); | 3798 | draftStore.finalizePromotedDraftThread(target); |
| 3757 | } | 3799 | } |
| 3758 | } | 3800 | } |
| 3801 | clearBackgroundDraftSubmissionByRef(threadRef); | ||
| 3759 | } | 3802 | } |
| 3760 | 3803 | ||
| 3761 | export function finalizePromotedDraftThreadsByRef( | 3804 | export function finalizePromotedDraftThreadsByRef( |
apps/web/src/hooks/useHandleNewThread.ts+6-3| ... | @@ -189,9 +189,12 @@ export function useNewThreadHandler() { | ... | @@ -189,9 +189,12 @@ export function useNewThreadHandler() { |
| 189 | ? scopeThreadRef(storedDraftThread.environmentId, storedDraftThread.threadId) | 189 | ? scopeThreadRef(storedDraftThread.environmentId, storedDraftThread.threadId) |
| 190 | : null; | 190 | : null; |
| 191 | const reusableStoredDraftThread = | 191 | const reusableStoredDraftThread = |
| 192 | storedDraftThreadRef && readThreadShell(storedDraftThreadRef) !== null | 192 | storedDraftThread !== null && |
| 193 | ? null | 193 | storedDraftThread.promotedTo == null && |
| 194 | : storedDraftThread; | 194 | storedDraftThreadRef !== null && |
| 195 | readThreadShell(storedDraftThreadRef) === null | ||
| 196 | ? storedDraftThread | ||
| 197 | : null; | ||
| 195 | if (storedDraftThreadRef && reusableStoredDraftThread === null) { | 198 | if (storedDraftThreadRef && reusableStoredDraftThread === null) { |
| 196 | markPromotedDraftThreadByRef(storedDraftThreadRef); | 199 | markPromotedDraftThreadByRef(storedDraftThreadRef); |
| 197 | } | 200 | } |
apps/web/src/routes/_chat.draft.$draftId.tsx+11-2| ... | @@ -1,10 +1,14 @@ | ... | @@ -1,10 +1,14 @@ |
| 1 | import { createFileRoute, useNavigate } from "@tanstack/react-router"; | 1 | import { createFileRoute, useNavigate } from "@tanstack/react-router"; |
| 2 | import { useEffect } from "react"; | 2 | import { useEffect } from "react"; |
| 3 | import ChatView from "../components/ChatView"; | 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 | import { | 8 | import { |
| 6 | DraftId, | 9 | DraftId, |
| 7 | markPromotedDraftThreadByRef, | 10 | markPromotedDraftThreadByRef, |
| 11 | useBackgroundDraftSubmissionPending, | ||
| 8 | useComposerDraftStore, | 12 | useComposerDraftStore, |
| 9 | } from "../composerDraftStore"; | 13 | } from "../composerDraftStore"; |
| 10 | import { SidebarInset } from "../components/ui/sidebar"; | 14 | import { SidebarInset } from "../components/ui/sidebar"; |
| ... | @@ -28,7 +32,12 @@ function DraftChatThreadRouteView() { | ... | @@ -28,7 +32,12 @@ function DraftChatThreadRouteView() { |
| 28 | const serverThreadRef = draftSession?.promotedTo ?? inferredThreadRef; | 32 | const serverThreadRef = draftSession?.promotedTo ?? inferredThreadRef; |
| 29 | const serverThread = useThread(serverThreadRef); | 33 | const serverThread = useThread(serverThreadRef); |
| 30 | const serverThreadStarted = threadHasStarted(serverThread); | 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 | useEffect(() => { | 42 | useEffect(() => { |
| 34 | if (!inferredThreadRef || draftSession?.promotedTo) { | 43 | if (!inferredThreadRef || draftSession?.promotedTo) { |
docs/user/composer.md+5| ... | @@ -3,3 +3,8 @@ | ... | @@ -3,3 +3,8 @@ |
| 3 | Messages can contain up to 120,000 characters. If a draft is longer, T3 Code keeps it in the | 3 | Messages can contain up to 120,000 characters. If a draft is longer, T3 Code keeps it in the |
| 4 | composer and shows how many characters need to be removed. Shorten the draft or split it into | 4 | composer and shows how many characters need to be removed. Shorten the draft or split it into |
| 5 | multiple messages, then send again in the same thread. | 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,6 +67,10 @@ at. To keep a worktree, use the explicit "new thread in this worktree" action in |
| 67 | toolbar. The only difference between the two commands: with the current sidebar and more than one | 67 | toolbar. The only difference between the two commands: with the current sidebar and more than one |
| 68 | project, `chat.new` opens a project chooser first. | 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 | ## `when` Conditions | 74 | ## `when` Conditions |
| 71 | 75 | ||
| 72 | A `when` expression is evaluated against context keys describing the current UI state. The keys | 76 | A `when` expression is evaluated against context keys describing the current UI state. The keys |