diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 5c026c94a138..194418c8309f 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -26,10 +26,14 @@ import { isBranchMismatchDismissedForSession, reconcileMountedTerminalThreadIds, reconcileRetainedMountedThreadIds, + resolveBackgroundDraftWorkspaceOptions, + resolveDraftPromotionNavigationTarget, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, + resolveDraftHeroState, scheduleEnvironmentReconnectWarning, startNewThreadForProject, + shouldDockDraftHeroForSubmission, shouldShowBranchMismatchBanner, shouldWriteThreadErrorToCurrentServerThread, } from "./ChatView.logic"; @@ -39,6 +43,40 @@ const projectId = ProjectId.make("project-1"); const threadId = ThreadId.make("thread-1"); const now = "2026-03-29T00:00:00.000Z"; +describe("draft hero submission transition", () => { + it("does not dock the composer before a background submission", () => { + expect( + shouldDockDraftHeroForSubmission({ + isDraftHeroState: true, + activeThreadKey: "environment-local:thread-1", + submissionIntent: "background", + }), + ).toBe(false); + }); + + it("keeps the composer in the hero layout until navigation after server promotion", () => { + expect( + resolveDraftHeroState({ + isLocalDraftThread: false, + hasTimelineEntries: true, + isWorking: true, + draftHeroDockRequested: false, + backgroundSubmissionPending: true, + }), + ).toBe(true); + }); + + it("does not auto-navigate a background submission after server promotion", () => { + expect( + resolveDraftPromotionNavigationTarget({ + serverThreadRef: { environmentId, threadId }, + serverThreadStarted: true, + backgroundSubmissionPending: true, + }), + ).toBeNull(); + }); +}); + describe("environment reconnect warning grace", () => { afterEach(() => vi.useRealTimers()); @@ -382,6 +420,23 @@ describe("resolveSendEnvMode", () => { }); }); +describe("resolveBackgroundDraftWorkspaceOptions", () => { + it("keeps New worktree selected without reusing the launched worktree", () => { + expect( + resolveBackgroundDraftWorkspaceOptions({ + envMode: "worktree", + branch: "main", + startFromOrigin: true, + }), + ).toEqual({ + envMode: "worktree", + branch: "main", + worktreePath: null, + startFromOrigin: true, + }); + }); +}); + describe("branchMismatchKey", () => { it("builds a key from thread id and both branches", () => { expect(branchMismatchKey("thread-1", { threadBranch: "feat/a", currentBranch: "feat/b" })).toBe( diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 04561b507c3e..1662ef91b92d 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -21,6 +21,7 @@ import { type TerminalContextDraft, } from "../lib/terminalContext"; import type { DraftThreadEnvMode } from "../composerDraftStore"; +import type { ComposerSubmissionIntent } from "../composer-logic"; export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; @@ -29,6 +30,47 @@ export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000; export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); +export function shouldDockDraftHeroForSubmission(input: { + isDraftHeroState: boolean; + activeThreadKey: string | null; + submissionIntent: ComposerSubmissionIntent; +}): boolean { + return ( + input.submissionIntent === "foreground" && + input.isDraftHeroState && + input.activeThreadKey !== null + ); +} + +export function resolveDraftHeroState(input: { + isLocalDraftThread: boolean; + hasTimelineEntries: boolean; + isWorking: boolean; + draftHeroDockRequested: boolean; + backgroundSubmissionPending: boolean; +}): boolean { + if (input.backgroundSubmissionPending) { + return true; + } + return ( + input.isLocalDraftThread && + !input.hasTimelineEntries && + !input.isWorking && + !input.draftHeroDockRequested + ); +} + +export function resolveDraftPromotionNavigationTarget(input: { + serverThreadRef: ScopedThreadRef | null; + serverThreadStarted: boolean; + backgroundSubmissionPending: boolean; +}): ScopedThreadRef | null { + if (input.backgroundSubmissionPending) { + return null; + } + return input.serverThreadStarted ? input.serverThreadRef : null; +} + export function scheduleEnvironmentReconnectWarning(showWarning: () => void): () => void { const timeoutId = globalThis.setTimeout(showWarning, ENVIRONMENT_RECONNECT_WARNING_GRACE_MS); return () => globalThis.clearTimeout(timeoutId); @@ -257,6 +299,24 @@ export function resolveSendEnvMode(input: { return input.isGitRepo ? input.requestedEnvMode : "local"; } +export function resolveBackgroundDraftWorkspaceOptions(input: { + envMode: DraftThreadEnvMode; + branch: string | null; + startFromOrigin: boolean; +}): { + envMode: DraftThreadEnvMode; + branch: string | null; + worktreePath: null; + startFromOrigin: boolean; +} { + return { + envMode: input.envMode, + branch: input.branch, + worktreePath: null, + startFromOrigin: input.envMode === "worktree" && input.startFromOrigin, + }; +} + export function cloneComposerImageForRetry( image: ComposerImageAttachment, ): ComposerImageAttachment { @@ -491,6 +551,7 @@ export async function waitForStartedServerThread( export interface LocalDispatchSnapshot { startedAt: string; preparingWorktree: boolean; + submissionIntent: ComposerSubmissionIntent; latestUserMessageId: ChatMessage["id"] | null; latestTurnTurnId: TurnId | null; latestTurnRequestedAt: string | null; @@ -502,7 +563,10 @@ export interface LocalDispatchSnapshot { export function createLocalDispatchSnapshot( activeThread: Thread | undefined, - options?: { preparingWorktree?: boolean }, + options?: { + preparingWorktree?: boolean; + submissionIntent?: ComposerSubmissionIntent; + }, ): LocalDispatchSnapshot { const latestTurn = activeThread?.latestTurn ?? null; const session = activeThread?.session ?? null; @@ -510,6 +574,7 @@ export function createLocalDispatchSnapshot( return { startedAt: new Date().toISOString(), preparingWorktree: Boolean(options?.preparingWorktree), + submissionIntent: options?.submissionIntent ?? "foreground", latestUserMessageId: latestUserMessage?.id ?? null, latestTurnTurnId: latestTurn?.turnId ?? null, latestTurnRequestedAt: latestTurn?.requestedAt ?? null, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0a8a71e781fb..8dcb6b8ed932 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -82,6 +82,7 @@ import { readLocalApi } from "../localApi"; import { useDiffPanelStore } from "../diffPanelStore"; import { collapseExpandedComposerCursor, + type ComposerSubmissionIntent, parseStandaloneComposerSlashCommand, } from "../composer-logic"; import { @@ -210,10 +211,14 @@ import { selectProjectGroupingSettings, } from "../logicalProject"; import { buildPhysicalToLogicalProjectKeyMap } from "../sidebarProjectGrouping"; -import { buildDraftThreadRouteParams } from "../threadRoutes"; +import { buildDraftThreadRouteParams, buildThreadRouteParams } from "../threadRoutes"; import { + beginBackgroundDraftSubmissionByRef, + clearBackgroundDraftSubmissionByRef, type ComposerImageAttachment, type DraftThreadEnvMode, + finalizePromotedDraftThreadByRef, + markPromotedDraftThreadByRef, useComposerDraftStore, type DraftId, } from "../composerDraftStore"; @@ -315,6 +320,7 @@ import { scheduleEnvironmentReconnectWarning, hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, + shouldDockDraftHeroForSubmission, shouldShowBranchMismatchBanner, getStartedThreadModelChangeBlockReason, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, @@ -325,6 +331,8 @@ import { deriveLockedProvider, readFileAsDataUrl, reconcileMountedTerminalThreadIds, + resolveBackgroundDraftWorkspaceOptions, + resolveDraftHeroState, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, revokeBlobPreviewUrl, @@ -596,14 +604,16 @@ function useLocalDispatchState(input: { ); const activeLocalDispatch = serverAcknowledgedLocalDispatch ? null : localDispatch; const beginLocalDispatch = useCallback( - (options?: { preparingWorktree?: boolean }) => { + (options?: { preparingWorktree?: boolean; submissionIntent?: ComposerSubmissionIntent }) => { const preparingWorktree = Boolean(options?.preparingWorktree); setLocalDispatch((current) => { const active = serverAcknowledgedLocalDispatch ? null : current; if (active) { - return active.preparingWorktree === preparingWorktree + const submissionIntent = options?.submissionIntent ?? active.submissionIntent; + return active.preparingWorktree === preparingWorktree && + active.submissionIntent === submissionIntent ? active - : { ...active, preparingWorktree }; + : { ...active, preparingWorktree, submissionIntent }; } return createLocalDispatchSnapshot(input.activeThread, options); }); @@ -618,6 +628,7 @@ function useLocalDispatchState(input: { latestUserMessageAt: latestUserMessage?.createdAt ?? null, isPreparingWorktree: activeLocalDispatch?.preparingWorktree ?? false, isSendBusy: activeLocalDispatch !== null, + backgroundSubmissionPending: localDispatch?.submissionIntent === "background", }; } @@ -2346,6 +2357,7 @@ function ChatViewContent(props: ChatViewProps) { latestUserMessageAt, isPreparingWorktree, isSendBusy, + backgroundSubmissionPending, } = useLocalDispatchState({ activeThread, activeLatestTurn, @@ -2614,8 +2626,13 @@ function ChatViewContent(props: ChatViewProps) { const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState(null); const draftHeroDockRequested = activeThreadKey !== null && dockedDraftHeroThreadKey === activeThreadKey; - const isDraftHeroState = - isLocalDraftThread && timelineEntries.length === 0 && !isWorking && !draftHeroDockRequested; + const isDraftHeroState = resolveDraftHeroState({ + isLocalDraftThread, + hasTimelineEntries: timelineEntries.length > 0, + isWorking, + draftHeroDockRequested, + backgroundSubmissionPending, + }); const [ attachDraftHeroTransitionGroupRef, attachDraftHeroComposerAnchorRef, @@ -5022,6 +5039,7 @@ function ChatViewContent(props: ChatViewProps) { const onSend = async ( e?: { preventDefault: () => void }, + submissionIntent: ComposerSubmissionIntent = "foreground", directAnnotation?: { annotation: PreviewAnnotationPayload; image: ComposerImageAttachment | null; @@ -5230,8 +5248,17 @@ function ChatViewContent(props: ChatViewProps) { return; } + const resolvedSubmissionIntent = + submissionIntent === "background" && isLocalDraftThread ? "background" : "foreground"; sendInFlightRef.current = true; - if (isDraftHeroState && activeThreadKey) { + if ( + shouldDockDraftHeroForSubmission({ + isDraftHeroState, + activeThreadKey, + submissionIntent: resolvedSubmissionIntent, + }) && + activeThreadKey + ) { let resolveDockStarted: (() => void) | undefined; const dockStarted = new Promise((resolve) => { resolveDockStarted = resolve; @@ -5246,7 +5273,10 @@ function ChatViewContent(props: ChatViewProps) { void dockTransition.catch(() => resolveDockStarted?.()); await dockStarted; } - beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) }); + beginLocalDispatch({ + preparingWorktree: Boolean(baseBranchForWorktree), + submissionIntent: resolvedSubmissionIntent, + }); const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); @@ -5408,6 +5438,13 @@ function ChatViewContent(props: ChatViewProps) { } : undefined; beginLocalDispatch({ preparingWorktree: false }); + const backgroundThreadRef = + resolvedSubmissionIntent === "background" + ? scopeThreadRef(activeThread.environmentId, threadIdForSend) + : null; + if (backgroundThreadRef) { + beginBackgroundDraftSubmissionByRef(backgroundThreadRef); + } const startResult = await startThreadTurn({ environmentId, input: { @@ -5427,10 +5464,60 @@ function ChatViewContent(props: ChatViewProps) { }, }); if (startResult._tag === "Failure") { + if (backgroundThreadRef) { + clearBackgroundDraftSubmissionByRef(backgroundThreadRef); + } failure = startResult; } else { turnStartSucceeded = true; acknowledgeActiveThreadWoke(); + if (backgroundThreadRef) { + markPromotedDraftThreadByRef(backgroundThreadRef); + try { + const nextDraft = await handleNewThread( + scopeProjectRef(activeProject.environmentId, activeProject.id), + resolveBackgroundDraftWorkspaceOptions({ + envMode: sendEnvMode, + branch: activeThreadBranch, + startFromOrigin, + }), + ); + if (nextDraft) { + finalizePromotedDraftThreadByRef(backgroundThreadRef); + toastManager.add( + stackedThreadToast({ + type: "success", + title: "Started in background", + timeout: 5_000, + actionProps: { + children: "Open", + onClick: () => { + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(backgroundThreadRef), + }); + }, + }, + }), + ); + } else { + clearBackgroundDraftSubmissionByRef(backgroundThreadRef); + } + } catch (error) { + clearBackgroundDraftSubmissionByRef(backgroundThreadRef); + resetLocalDispatch(); + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Task started in the background", + description: + error instanceof Error + ? `Could not open a fresh composer: ${error.message}` + : "Could not open a fresh composer.", + }), + ); + } + } } } @@ -6220,7 +6307,7 @@ function ChatViewContent(props: ChatViewProps) { configuredUrls={configuredPreviewUrls} visible onSendAnnotation={(annotation, image) => { - void onSend(undefined, { annotation, image }); + void onSend(undefined, "foreground", { annotation, image }); }} /> diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index a0518bdabef2..f06a9658225f 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -36,12 +36,13 @@ import { import { createPortal } from "react-dom"; import { clampCollapsedComposerCursor, + type ComposerSubmissionIntent, type ComposerTrigger, collapseExpandedComposerCursor, + composerSubmissionIntentForEnter, detectComposerTrigger, expandCollapsedComposerCursor, replaceTextRange, - shouldSubmitComposerOnEnter, } from "../../composer-logic"; import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder"; import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic"; @@ -604,7 +605,7 @@ export interface ChatComposerProps { composerRef: React.RefObject; // Callbacks - onSend: (e?: { preventDefault: () => void }) => void; + onSend: (e?: { preventDefault: () => void }, intent?: ComposerSubmissionIntent) => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; onRespondToApproval: ( @@ -1886,7 +1887,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ]); const submitComposer = useCallback( - (event?: { preventDefault: () => void }) => { + (event?: { preventDefault: () => void }, intent: ComposerSubmissionIntent = "foreground") => { if (noProviderAvailable || isSendDisabled) { event?.preventDefault(); return; @@ -1912,7 +1913,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ChatView reports its final composed-input preflight through the // composer handle before its first asynchronous send step. providerInputRejectedRef.current = false; - onSend(sendEvent); + onSend(sendEvent, intent); return !providerInputRejectedRef.current; }, }); @@ -1986,11 +1987,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return true; } } - if ( - key === "Enter" && - shouldSubmitComposerOnEnter({ isMobileViewport, shiftKey: event.shiftKey }) - ) { - submitComposer(); + const submissionIntent = + key === "Enter" + ? composerSubmissionIntentForEnter({ + isMobileViewport, + shiftKey: event.shiftKey, + modifierKey: event.metaKey || event.ctrlKey, + isDraftThread: routeKind === "draft", + }) + : null; + if (submissionIntent) { + submitComposer(undefined, submissionIntent); return true; } return false; diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index b8ef7443611a..80b74f926454 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -3,26 +3,69 @@ import { describe, expect, it } from "vite-plus/test"; import { clampCollapsedComposerCursor, collapseExpandedComposerCursor, + composerSubmissionIntentForEnter, detectComposerTrigger, expandCollapsedComposerCursor, isCollapsedCursorAdjacentToInlineToken, parseStandaloneComposerSlashCommand, replaceTextRange, - shouldSubmitComposerOnEnter, } from "./composer-logic"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; -describe("shouldSubmitComposerOnEnter", () => { +describe("composerSubmissionIntentForEnter", () => { it("submits plain Enter on desktop", () => { - expect(shouldSubmitComposerOnEnter({ isMobileViewport: false, shiftKey: false })).toBe(true); + expect( + composerSubmissionIntentForEnter({ + isMobileViewport: false, + shiftKey: false, + modifierKey: false, + isDraftThread: true, + }), + ).toBe("foreground"); }); it("inserts a newline for plain Enter on mobile", () => { - expect(shouldSubmitComposerOnEnter({ isMobileViewport: true, shiftKey: false })).toBe(false); + expect( + composerSubmissionIntentForEnter({ + isMobileViewport: true, + shiftKey: false, + modifierKey: false, + isDraftThread: true, + }), + ).toBeNull(); }); it("inserts a newline for Shift+Enter", () => { - expect(shouldSubmitComposerOnEnter({ isMobileViewport: false, shiftKey: true })).toBe(false); + expect( + composerSubmissionIntentForEnter({ + isMobileViewport: false, + shiftKey: true, + modifierKey: false, + isDraftThread: true, + }), + ).toBeNull(); + }); + + it("submits a new thread in the background with Mod+Enter", () => { + expect( + composerSubmissionIntentForEnter({ + isMobileViewport: false, + shiftKey: false, + modifierKey: true, + isDraftThread: true, + }), + ).toBe("background"); + }); + + it("keeps Mod+Enter in the foreground for an active thread", () => { + expect( + composerSubmissionIntentForEnter({ + isMobileViewport: false, + shiftKey: false, + modifierKey: true, + isDraftThread: false, + }), + ).toBe("foreground"); }); }); diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index 2d1d3aed3b1e..239d6c619655 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -3,6 +3,7 @@ import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; export type ComposerTriggerKind = "path" | "slash-command" | "skill"; export type ComposerSlashCommand = "model" | "plan" | "default"; +export type ComposerSubmissionIntent = "foreground" | "background"; export interface ComposerTrigger { kind: ComposerTriggerKind; @@ -11,11 +12,16 @@ export interface ComposerTrigger { rangeEnd: number; } -export function shouldSubmitComposerOnEnter(input: { +export function composerSubmissionIntentForEnter(input: { isMobileViewport: boolean; shiftKey: boolean; -}): boolean { - return !input.isMobileViewport && !input.shiftKey; + modifierKey: boolean; + isDraftThread: boolean; +}): ComposerSubmissionIntent | null { + if (input.isMobileViewport || input.shiftKey) { + return null; + } + return input.modifierKey && input.isDraftThread ? "background" : "foreground"; } const isInlineTokenSegment = ( diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 3fe6681e09ed..f20385ee04f4 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -354,6 +354,7 @@ interface ComposerDraftStoreState { draftsByThreadKey: Record; draftThreadsByThreadKey: Record; logicalProjectDraftThreadKeyByLogicalProjectKey: Record; + backgroundSubmissionThreadKeys: Record; stickyModelSelectionByProvider: Partial>; stickyActiveProvider: ProviderInstanceId | null; /** Returns the editable composer content for a draft session or server thread. */ @@ -2255,6 +2256,7 @@ const composerDraftStore = create()( draftsByThreadKey: {}, draftThreadsByThreadKey: {}, logicalProjectDraftThreadKeyByLogicalProjectKey: {}, + backgroundSubmissionThreadKeys: {}, stickyModelSelectionByProvider: {}, stickyActiveProvider: null, getComposerDraft: (target) => getComposerDraftState(get(), target), @@ -3578,6 +3580,40 @@ const composerDraftStore = create()( export const useComposerDraftStore = composerDraftStore; +export function beginBackgroundDraftSubmissionByRef(threadRef: ScopedThreadRef): void { + const threadKey = scopedThreadKey(threadRef); + useComposerDraftStore.setState((state) => { + if (state.backgroundSubmissionThreadKeys[threadKey]) { + return state; + } + return { + backgroundSubmissionThreadKeys: { + ...state.backgroundSubmissionThreadKeys, + [threadKey]: true, + }, + }; + }); +} + +export function clearBackgroundDraftSubmissionByRef(threadRef: ScopedThreadRef): void { + const threadKey = scopedThreadKey(threadRef); + useComposerDraftStore.setState((state) => { + if (!state.backgroundSubmissionThreadKeys[threadKey]) { + return state; + } + const backgroundSubmissionThreadKeys = { ...state.backgroundSubmissionThreadKeys }; + delete backgroundSubmissionThreadKeys[threadKey]; + return { backgroundSubmissionThreadKeys }; + }); +} + +export function useBackgroundDraftSubmissionPending(threadRef: ScopedThreadRef | null): boolean { + const threadKey = threadRef ? scopedThreadKey(threadRef) : null; + return useComposerDraftStore( + (state) => threadKey !== null && state.backgroundSubmissionThreadKeys[threadKey] === true, + ); +} + export function clearComposerDraftsEnvironment(environmentId: EnvironmentId): void { useComposerDraftStore.setState((state) => { const removedThreadKeys = new Set(); @@ -3622,11 +3658,17 @@ export function clearComposerDraftsEnvironment(environmentId: EnvironmentId): vo return false; }), ) as Record; + const nextBackgroundSubmissionThreadKeys = Object.fromEntries( + Object.entries(state.backgroundSubmissionThreadKeys).filter( + ([threadKey]) => parseScopedThreadKey(threadKey)?.environmentId !== environmentId, + ), + ) as Record; return { draftsByThreadKey: nextDrafts, draftThreadsByThreadKey: nextDraftThreads, logicalProjectDraftThreadKeyByLogicalProjectKey: nextLogicalMappings, + backgroundSubmissionThreadKeys: nextBackgroundSubmissionThreadKeys, }; }); composerDebouncedStorage.flush(); @@ -3756,6 +3798,7 @@ export function finalizePromotedDraftThreadByRef(threadRef: ScopedThreadRef): vo draftStore.finalizePromotedDraftThread(target); } } + clearBackgroundDraftSubmissionByRef(threadRef); } export function finalizePromotedDraftThreadsByRef( diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 64176c0873a7..ed88a2033296 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -189,9 +189,12 @@ export function useNewThreadHandler() { ? scopeThreadRef(storedDraftThread.environmentId, storedDraftThread.threadId) : null; const reusableStoredDraftThread = - storedDraftThreadRef && readThreadShell(storedDraftThreadRef) !== null - ? null - : storedDraftThread; + storedDraftThread !== null && + storedDraftThread.promotedTo == null && + storedDraftThreadRef !== null && + readThreadShell(storedDraftThreadRef) === null + ? storedDraftThread + : null; if (storedDraftThreadRef && reusableStoredDraftThread === null) { markPromotedDraftThreadByRef(storedDraftThreadRef); } diff --git a/apps/web/src/routes/_chat.draft.$draftId.tsx b/apps/web/src/routes/_chat.draft.$draftId.tsx index 74c1b15c0711..d067c6a8da9c 100644 --- a/apps/web/src/routes/_chat.draft.$draftId.tsx +++ b/apps/web/src/routes/_chat.draft.$draftId.tsx @@ -1,10 +1,14 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useEffect } from "react"; import ChatView from "../components/ChatView"; -import { threadHasStarted } from "../components/ChatView.logic"; +import { + resolveDraftPromotionNavigationTarget, + threadHasStarted, +} from "../components/ChatView.logic"; import { DraftId, markPromotedDraftThreadByRef, + useBackgroundDraftSubmissionPending, useComposerDraftStore, } from "../composerDraftStore"; import { SidebarInset } from "../components/ui/sidebar"; @@ -28,7 +32,12 @@ function DraftChatThreadRouteView() { const serverThreadRef = draftSession?.promotedTo ?? inferredThreadRef; const serverThread = useThread(serverThreadRef); const serverThreadStarted = threadHasStarted(serverThread); - const canonicalThreadRef = serverThreadStarted ? serverThreadRef : null; + const backgroundSubmissionPending = useBackgroundDraftSubmissionPending(serverThreadRef); + const canonicalThreadRef = resolveDraftPromotionNavigationTarget({ + serverThreadRef, + serverThreadStarted, + backgroundSubmissionPending, + }); useEffect(() => { if (!inferredThreadRef || draftSession?.promotedTo) { diff --git a/docs/user/composer.md b/docs/user/composer.md index d2e49db247b0..f4dd49e513e7 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -3,3 +3,8 @@ Messages can contain up to 120,000 characters. If a draft is longer, T3 Code keeps it in the composer and shows how many characters need to be removed. Shorten the draft or split it into multiple messages, then send again in the same thread. + +On desktop, press `Cmd+Enter` on macOS or `Ctrl+Enter` on Windows and Linux from a new thread to +start it in the background. T3 Code opens another new thread and shows an **Open** action for the +thread that started. The new thread keeps the selected workspace mode and base branch. If **New +worktree** is selected, each background thread creates its own worktree. diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 8e56a79a287d..0c4ca077f5de 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -67,6 +67,10 @@ at. To keep a worktree, use the explicit "new thread in this worktree" action in toolbar. The only difference between the two commands: with the current sidebar and more than one project, `chat.new` opens a project chooser first. +Background submission from a new thread is the exception. `mod+enter` starts that thread and opens +another new thread with the same workspace mode and base branch. **New worktree** remains selected, +but the new thread does not reuse the worktree created for the thread that just started. + ## `when` Conditions A `when` expression is evaluated against context keys describing the current UI state. The keys