authorgravatar for 118054752+extoci@users.noreply.github.comExotic <118054752+extoci@users.noreply.github.com> 2026-08-22 00:56:44+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-21 17:56:44-04:00
loge0b4f4639037ce539e6feac7eaf11995cf5490f8
tree2916ae64188524ef2ca3e182f49775d2648b3af5
parentc3e37094e04de71accf497c6110c5305223e0090
signature Signed by PGP key B5690EEEBB952194

feat(web): cmd+enter to create thread in background (#7821)


11 files changed, 359 insertions(+), 32 deletions(-)

apps/web/src/components/ChatView.logic.test.ts+55
......@@ -26,10 +26,14 @@ import {
2626 isBranchMismatchDismissedForSession,
2727 reconcileMountedTerminalThreadIds,
2828 reconcileRetainedMountedThreadIds,
29 resolveBackgroundDraftWorkspaceOptions,
30 resolveDraftPromotionNavigationTarget,
2931 resolveThreadMetadataUpdateForNextTurn,
3032 resolveSendEnvMode,
33 resolveDraftHeroState,
3134 scheduleEnvironmentReconnectWarning,
3235 startNewThreadForProject,
36 shouldDockDraftHeroForSubmission,
3337 shouldShowBranchMismatchBanner,
3438 shouldWriteThreadErrorToCurrentServerThread,
3539} from "./ChatView.logic";
......@@ -39,6 +43,40 @@ const projectId = ProjectId.make("project-1");
3943const threadId = ThreadId.make("thread-1");
4044const now = "2026-03-29T00:00:00.000Z";
4145
46describe("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
4280describe("environment reconnect warning grace", () => {
4381 afterEach(() => vi.useRealTimers());
4482
......@@ -382,6 +420,23 @@ describe("resolveSendEnvMode", () => {
382420 });
383421});
384422
423describe("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
385440describe("branchMismatchKey", () => {
386441 it("builds a key from thread id and both branches", () => {
387442 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 {
2121 type TerminalContextDraft,
2222} from "../lib/terminalContext";
2323import type { DraftThreadEnvMode } from "../composerDraftStore";
24import type { ComposerSubmissionIntent } from "../composer-logic";
2425
2526export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";
2627export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10;
......@@ -29,6 +30,47 @@ export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000;
2930
3031export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String);
3132
33export 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
45export 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
63export 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
3274export function scheduleEnvironmentReconnectWarning(showWarning: () => void): () => void {
3375 const timeoutId = globalThis.setTimeout(showWarning, ENVIRONMENT_RECONNECT_WARNING_GRACE_MS);
3476 return () => globalThis.clearTimeout(timeoutId);
......@@ -257,6 +299,24 @@ export function resolveSendEnvMode(input: {
257299 return input.isGitRepo ? input.requestedEnvMode : "local";
258300}
259301
302export 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
260320export function cloneComposerImageForRetry(
261321 image: ComposerImageAttachment,
262322): ComposerImageAttachment {
......@@ -491,6 +551,7 @@ export async function waitForStartedServerThread(
491551export interface LocalDispatchSnapshot {
492552 startedAt: string;
493553 preparingWorktree: boolean;
554 submissionIntent: ComposerSubmissionIntent;
494555 latestUserMessageId: ChatMessage["id"] | null;
495556 latestTurnTurnId: TurnId | null;
496557 latestTurnRequestedAt: string | null;
......@@ -502,7 +563,10 @@ export interface LocalDispatchSnapshot {
502563
503564export function createLocalDispatchSnapshot(
504565 activeThread: Thread | undefined,
505 options?: { preparingWorktree?: boolean },
566 options?: {
567 preparingWorktree?: boolean;
568 submissionIntent?: ComposerSubmissionIntent;
569 },
506570): LocalDispatchSnapshot {
507571 const latestTurn = activeThread?.latestTurn ?? null;
508572 const session = activeThread?.session ?? null;
......@@ -510,6 +574,7 @@ export function createLocalDispatchSnapshot(
510574 return {
511575 startedAt: new Date().toISOString(),
512576 preparingWorktree: Boolean(options?.preparingWorktree),
577 submissionIntent: options?.submissionIntent ?? "foreground",
513578 latestUserMessageId: latestUserMessage?.id ?? null,
514579 latestTurnTurnId: latestTurn?.turnId ?? null,
515580 latestTurnRequestedAt: latestTurn?.requestedAt ?? null,
apps/web/src/components/ChatView.tsx+96-9
......@@ -82,6 +82,7 @@ import { readLocalApi } from "../localApi";
8282import { useDiffPanelStore } from "../diffPanelStore";
8383import {
8484 collapseExpandedComposerCursor,
85 type ComposerSubmissionIntent,
8586 parseStandaloneComposerSlashCommand,
8687} from "../composer-logic";
8788import {
......@@ -210,10 +211,14 @@ import {
210211 selectProjectGroupingSettings,
211212} from "../logicalProject";
212213import { buildPhysicalToLogicalProjectKeyMap } from "../sidebarProjectGrouping";
213import { buildDraftThreadRouteParams } from "../threadRoutes";
214import { buildDraftThreadRouteParams, buildThreadRouteParams } from "../threadRoutes";
214215import {
216 beginBackgroundDraftSubmissionByRef,
217 clearBackgroundDraftSubmissionByRef,
215218 type ComposerImageAttachment,
216219 type DraftThreadEnvMode,
220 finalizePromotedDraftThreadByRef,
221 markPromotedDraftThreadByRef,
217222 useComposerDraftStore,
218223 type DraftId,
219224} from "../composerDraftStore";
......@@ -315,6 +320,7 @@ import {
315320 scheduleEnvironmentReconnectWarning,
316321 hasServerAcknowledgedLocalDispatch,
317322 isBranchMismatchDismissedForSession,
323 shouldDockDraftHeroForSubmission,
318324 shouldShowBranchMismatchBanner,
319325 getStartedThreadModelChangeBlockReason,
320326 LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,
......@@ -325,6 +331,8 @@ import {
325331 deriveLockedProvider,
326332 readFileAsDataUrl,
327333 reconcileMountedTerminalThreadIds,
334 resolveBackgroundDraftWorkspaceOptions,
335 resolveDraftHeroState,
328336 resolveThreadMetadataUpdateForNextTurn,
329337 resolveSendEnvMode,
330338 revokeBlobPreviewUrl,
......@@ -596,14 +604,16 @@ function useLocalDispatchState(input: {
596604 );
597605 const activeLocalDispatch = serverAcknowledgedLocalDispatch ? null : localDispatch;
598606 const beginLocalDispatch = useCallback(
599 (options?: { preparingWorktree?: boolean }) => {
607 (options?: { preparingWorktree?: boolean; submissionIntent?: ComposerSubmissionIntent }) => {
600608 const preparingWorktree = Boolean(options?.preparingWorktree);
601609 setLocalDispatch((current) => {
602610 const active = serverAcknowledgedLocalDispatch ? null : current;
603611 if (active) {
604 return active.preparingWorktree === preparingWorktree
612 const submissionIntent = options?.submissionIntent ?? active.submissionIntent;
613 return active.preparingWorktree === preparingWorktree &&
614 active.submissionIntent === submissionIntent
605615 ? active
606 : { ...active, preparingWorktree };
616 : { ...active, preparingWorktree, submissionIntent };
607617 }
608618 return createLocalDispatchSnapshot(input.activeThread, options);
609619 });
......@@ -618,6 +628,7 @@ function useLocalDispatchState(input: {
618628 latestUserMessageAt: latestUserMessage?.createdAt ?? null,
619629 isPreparingWorktree: activeLocalDispatch?.preparingWorktree ?? false,
620630 isSendBusy: activeLocalDispatch !== null,
631 backgroundSubmissionPending: localDispatch?.submissionIntent === "background",
621632 };
622633}
623634
......@@ -2346,6 +2357,7 @@ function ChatViewContent(props: ChatViewProps) {
23462357 latestUserMessageAt,
23472358 isPreparingWorktree,
23482359 isSendBusy,
2360 backgroundSubmissionPending,
23492361 } = useLocalDispatchState({
23502362 activeThread,
23512363 activeLatestTurn,
......@@ -2614,8 +2626,13 @@ function ChatViewContent(props: ChatViewProps) {
26142626 const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState<string | null>(null);
26152627 const draftHeroDockRequested =
26162628 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 });
26192636 const [
26202637 attachDraftHeroTransitionGroupRef,
26212638 attachDraftHeroComposerAnchorRef,
......@@ -5022,6 +5039,7 @@ function ChatViewContent(props: ChatViewProps) {
50225039
50235040 const onSend = async (
50245041 e?: { preventDefault: () => void },
5042 submissionIntent: ComposerSubmissionIntent = "foreground",
50255043 directAnnotation?: {
50265044 annotation: PreviewAnnotationPayload;
50275045 image: ComposerImageAttachment | null;
......@@ -5230,8 +5248,17 @@ function ChatViewContent(props: ChatViewProps) {
52305248 return;
52315249 }
52325250
5251 const resolvedSubmissionIntent =
5252 submissionIntent === "background" && isLocalDraftThread ? "background" : "foreground";
52335253 sendInFlightRef.current = true;
5234 if (isDraftHeroState && activeThreadKey) {
5254 if (
5255 shouldDockDraftHeroForSubmission({
5256 isDraftHeroState,
5257 activeThreadKey,
5258 submissionIntent: resolvedSubmissionIntent,
5259 }) &&
5260 activeThreadKey
5261 ) {
52355262 let resolveDockStarted: (() => void) | undefined;
52365263 const dockStarted = new Promise<void>((resolve) => {
52375264 resolveDockStarted = resolve;
......@@ -5246,7 +5273,10 @@ function ChatViewContent(props: ChatViewProps) {
52465273 void dockTransition.catch(() => resolveDockStarted?.());
52475274 await dockStarted;
52485275 }
5249 beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) });
5276 beginLocalDispatch({
5277 preparingWorktree: Boolean(baseBranchForWorktree),
5278 submissionIntent: resolvedSubmissionIntent,
5279 });
52505280
52515281 const messageIdForSend = newMessageId();
52525282 const messageCreatedAt = new Date().toISOString();
......@@ -5408,6 +5438,13 @@ function ChatViewContent(props: ChatViewProps) {
54085438 }
54095439 : undefined;
54105440 beginLocalDispatch({ preparingWorktree: false });
5441 const backgroundThreadRef =
5442 resolvedSubmissionIntent === "background"
5443 ? scopeThreadRef(activeThread.environmentId, threadIdForSend)
5444 : null;
5445 if (backgroundThreadRef) {
5446 beginBackgroundDraftSubmissionByRef(backgroundThreadRef);
5447 }
54115448 const startResult = await startThreadTurn({
54125449 environmentId,
54135450 input: {
......@@ -5427,10 +5464,60 @@ function ChatViewContent(props: ChatViewProps) {
54275464 },
54285465 });
54295466 if (startResult._tag === "Failure") {
5467 if (backgroundThreadRef) {
5468 clearBackgroundDraftSubmissionByRef(backgroundThreadRef);
5469 }
54305470 failure = startResult;
54315471 } else {
54325472 turnStartSucceeded = true;
54335473 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 }
54345521 }
54355522 }
54365523
......@@ -6220,7 +6307,7 @@ function ChatViewContent(props: ChatViewProps) {
62206307 configuredUrls={configuredPreviewUrls}
62216308 visible
62226309 onSendAnnotation={(annotation, image) => {
6223 void onSend(undefined, { annotation, image });
6310 void onSend(undefined, "foreground", { annotation, image });
62246311 }}
62256312 />
62266313 </Suspense>
apps/web/src/components/chat/ChatComposer.tsx+16-9
......@@ -36,12 +36,13 @@ import {
3636import { createPortal } from "react-dom";
3737import {
3838 clampCollapsedComposerCursor,
39 type ComposerSubmissionIntent,
3940 type ComposerTrigger,
4041 collapseExpandedComposerCursor,
42 composerSubmissionIntentForEnter,
4143 detectComposerTrigger,
4244 expandCollapsedComposerCursor,
4345 replaceTextRange,
44 shouldSubmitComposerOnEnter,
4546} from "../../composer-logic";
4647import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder";
4748import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic";
......@@ -604,7 +605,7 @@ export interface ChatComposerProps {
604605 composerRef: React.RefObject<ChatComposerHandle | null>;
605606
606607 // Callbacks
607 onSend: (e?: { preventDefault: () => void }) => void;
608 onSend: (e?: { preventDefault: () => void }, intent?: ComposerSubmissionIntent) => void;
608609 onInterrupt: () => void;
609610 onImplementPlanInNewThread: () => void;
610611 onRespondToApproval: (
......@@ -1886,7 +1887,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
18861887 ]);
18871888
18881889 const submitComposer = useCallback(
1889 (event?: { preventDefault: () => void }) => {
1890 (event?: { preventDefault: () => void }, intent: ComposerSubmissionIntent = "foreground") => {
18901891 if (noProviderAvailable || isSendDisabled) {
18911892 event?.preventDefault();
18921893 return;
......@@ -1912,7 +1913,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
19121913 // ChatView reports its final composed-input preflight through the
19131914 // composer handle before its first asynchronous send step.
19141915 providerInputRejectedRef.current = false;
1915 onSend(sendEvent);
1916 onSend(sendEvent, intent);
19161917 return !providerInputRejectedRef.current;
19171918 },
19181919 });
......@@ -1986,11 +1987,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
19861987 return true;
19871988 }
19881989 }
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);
19942001 return true;
19952002 }
19962003 return false;
apps/web/src/composer-logic.test.ts+48-5
......@@ -3,26 +3,69 @@ import { describe, expect, it } from "vite-plus/test";
33import {
44 clampCollapsedComposerCursor,
55 collapseExpandedComposerCursor,
6 composerSubmissionIntentForEnter,
67 detectComposerTrigger,
78 expandCollapsedComposerCursor,
89 isCollapsedCursorAdjacentToInlineToken,
910 parseStandaloneComposerSlashCommand,
1011 replaceTextRange,
11 shouldSubmitComposerOnEnter,
1212} from "./composer-logic";
1313import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext";
1414
15describe("shouldSubmitComposerOnEnter", () => {
15describe("composerSubmissionIntentForEnter", () => {
1616 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");
1825 });
1926
2027 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();
2236 });
2337
2438 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");
2669 });
2770});
2871
apps/web/src/composer-logic.ts+9-3
......@@ -3,6 +3,7 @@ import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext";
33
44export type ComposerTriggerKind = "path" | "slash-command" | "skill";
55export type ComposerSlashCommand = "model" | "plan" | "default";
6export type ComposerSubmissionIntent = "foreground" | "background";
67
78export interface ComposerTrigger {
89 kind: ComposerTriggerKind;
......@@ -11,11 +12,16 @@ export interface ComposerTrigger {
1112 rangeEnd: number;
1213}
1314
14export function shouldSubmitComposerOnEnter(input: {
15export function composerSubmissionIntentForEnter(input: {
1516 isMobileViewport: boolean;
1617 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";
1925}
2026
2127const isInlineTokenSegment = (
apps/web/src/composerDraftStore.ts+43
......@@ -354,6 +354,7 @@ interface ComposerDraftStoreState {
354354 draftsByThreadKey: Record<string, ComposerThreadDraftState>;
355355 draftThreadsByThreadKey: Record<string, DraftThreadState>;
356356 logicalProjectDraftThreadKeyByLogicalProjectKey: Record<string, string>;
357 backgroundSubmissionThreadKeys: Record<string, true>;
357358 stickyModelSelectionByProvider: Partial<Record<ProviderInstanceId, ModelSelection>>;
358359 stickyActiveProvider: ProviderInstanceId | null;
359360 /** Returns the editable composer content for a draft session or server thread. */
......@@ -2255,6 +2256,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
22552256 draftsByThreadKey: {},
22562257 draftThreadsByThreadKey: {},
22572258 logicalProjectDraftThreadKeyByLogicalProjectKey: {},
2259 backgroundSubmissionThreadKeys: {},
22582260 stickyModelSelectionByProvider: {},
22592261 stickyActiveProvider: null,
22602262 getComposerDraft: (target) => getComposerDraftState(get(), target),
......@@ -3578,6 +3580,40 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
35783580
35793581export const useComposerDraftStore = composerDraftStore;
35803582
3583export 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
3598export 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
3610export 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
35813617export function clearComposerDraftsEnvironment(environmentId: EnvironmentId): void {
35823618 useComposerDraftStore.setState((state) => {
35833619 const removedThreadKeys = new Set<string>();
......@@ -3622,11 +3658,17 @@ export function clearComposerDraftsEnvironment(environmentId: EnvironmentId): vo
36223658 return false;
36233659 }),
36243660 ) 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>;
36253666
36263667 return {
36273668 draftsByThreadKey: nextDrafts,
36283669 draftThreadsByThreadKey: nextDraftThreads,
36293670 logicalProjectDraftThreadKeyByLogicalProjectKey: nextLogicalMappings,
3671 backgroundSubmissionThreadKeys: nextBackgroundSubmissionThreadKeys,
36303672 };
36313673 });
36323674 composerDebouncedStorage.flush();
......@@ -3756,6 +3798,7 @@ export function finalizePromotedDraftThreadByRef(threadRef: ScopedThreadRef): vo
37563798 draftStore.finalizePromotedDraftThread(target);
37573799 }
37583800 }
3801 clearBackgroundDraftSubmissionByRef(threadRef);
37593802}
37603803
37613804export function finalizePromotedDraftThreadsByRef(
apps/web/src/hooks/useHandleNewThread.ts+6-3
......@@ -189,9 +189,12 @@ export function useNewThreadHandler() {
189189 ? scopeThreadRef(storedDraftThread.environmentId, storedDraftThread.threadId)
190190 : null;
191191 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;
195198 if (storedDraftThreadRef && reusableStoredDraftThread === null) {
196199 markPromotedDraftThreadByRef(storedDraftThreadRef);
197200 }
apps/web/src/routes/_chat.draft.$draftId.tsx+11-2
......@@ -1,10 +1,14 @@
11import { createFileRoute, useNavigate } from "@tanstack/react-router";
22import { useEffect } from "react";
33import ChatView from "../components/ChatView";
4import { threadHasStarted } from "../components/ChatView.logic";
4import {
5 resolveDraftPromotionNavigationTarget,
6 threadHasStarted,
7} from "../components/ChatView.logic";
58import {
69 DraftId,
710 markPromotedDraftThreadByRef,
11 useBackgroundDraftSubmissionPending,
812 useComposerDraftStore,
913} from "../composerDraftStore";
1014import { SidebarInset } from "../components/ui/sidebar";
......@@ -28,7 +32,12 @@ function DraftChatThreadRouteView() {
2832 const serverThreadRef = draftSession?.promotedTo ?? inferredThreadRef;
2933 const serverThread = useThread(serverThreadRef);
3034 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 });
3241
3342 useEffect(() => {
3443 if (!inferredThreadRef || draftSession?.promotedTo) {
docs/user/composer.md+5
......@@ -3,3 +3,8 @@
33Messages can contain up to 120,000 characters. If a draft is longer, T3 Code keeps it in the
44composer and shows how many characters need to be removed. Shorten the draft or split it into
55multiple messages, then send again in the same thread.
6
7On desktop, press `Cmd+Enter` on macOS or `Ctrl+Enter` on Windows and Linux from a new thread to
8start it in the background. T3 Code opens another new thread and shows an **Open** action for the
9thread that started. The new thread keeps the selected workspace mode and base branch. If **New
10worktree** 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
6767toolbar. The only difference between the two commands: with the current sidebar and more than one
6868project, `chat.new` opens a project chooser first.
6969
70Background submission from a new thread is the exception. `mod+enter` starts that thread and opens
71another new thread with the same workspace mode and base branch. **New worktree** remains selected,
72but the new thread does not reuse the worktree created for the thread that just started.
73
7074## `when` Conditions
7175
7276A `when` expression is evaluated against context keys describing the current UI state. The keys