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 {...@@ -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");
39const threadId = ThreadId.make("thread-1");43const threadId = ThreadId.make("thread-1");
40const now = "2026-03-29T00:00:00.000Z";44const 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
42describe("environment reconnect warning grace", () => {80describe("environment reconnect warning grace", () => {
43 afterEach(() => vi.useRealTimers());81 afterEach(() => vi.useRealTimers());
4482
...@@ -382,6 +420,23 @@ describe("resolveSendEnvMode", () => {...@@ -382,6 +420,23 @@ describe("resolveSendEnvMode", () => {
382 });420 });
383});421});
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
385describe("branchMismatchKey", () => {440describe("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";
23import type { DraftThreadEnvMode } from "../composerDraftStore";23import type { DraftThreadEnvMode } from "../composerDraftStore";
24import type { ComposerSubmissionIntent } from "../composer-logic";
2425
25export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";26export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";
26export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10;27export 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;
2930
30export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String);31export 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
32export function scheduleEnvironmentReconnectWarning(showWarning: () => void): () => void {74export 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}
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
260export function cloneComposerImageForRetry(320export 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(
491export interface LocalDispatchSnapshot {551export 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 {
502563
503export function createLocalDispatchSnapshot(564export 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";
82import { useDiffPanelStore } from "../diffPanelStore";82import { useDiffPanelStore } from "../diffPanelStore";
83import {83import {
84 collapseExpandedComposerCursor,84 collapseExpandedComposerCursor,
85 type ComposerSubmissionIntent,
85 parseStandaloneComposerSlashCommand,86 parseStandaloneComposerSlashCommand,
86} from "../composer-logic";87} from "../composer-logic";
87import {88import {
...@@ -210,10 +211,14 @@ import {...@@ -210,10 +211,14 @@ import {
210 selectProjectGroupingSettings,211 selectProjectGroupingSettings,
211} from "../logicalProject";212} from "../logicalProject";
212import { buildPhysicalToLogicalProjectKeyMap } from "../sidebarProjectGrouping";213import { buildPhysicalToLogicalProjectKeyMap } from "../sidebarProjectGrouping";
213import { buildDraftThreadRouteParams } from "../threadRoutes";214import { buildDraftThreadRouteParams, buildThreadRouteParams } from "../threadRoutes";
214import {215import {
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 === preparingWorktree612 const submissionIntent = options?.submissionIntent ?? active.submissionIntent;
613 return active.preparingWorktree === preparingWorktree &&
614 active.submissionIntent === submissionIntent
605 ? active615 ? 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}
623634
...@@ -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) {
50225039
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 }
52325250
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 });
52505280
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 }
54365523
...@@ -6220,7 +6307,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6220,7 +6307,7 @@ function ChatViewContent(props: ChatViewProps) {
6220 configuredUrls={configuredPreviewUrls}6307 configuredUrls={configuredPreviewUrls}
6221 visible6308 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 {
36import { createPortal } from "react-dom";36import { createPortal } from "react-dom";
37import {37import {
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";
46import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder";47import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder";
47import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic";48import { 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>;
605606
606 // Callbacks607 // 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 ]);
18871888
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 the1913 // 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";
3import {3import {
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";
13import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext";13import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext";
1414
15describe("shouldSubmitComposerOnEnter", () => {15describe("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 });
1926
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 });
2337
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});
2871
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";
33
4export type ComposerTriggerKind = "path" | "slash-command" | "skill";4export type ComposerTriggerKind = "path" | "slash-command" | "skill";
5export type ComposerSlashCommand = "model" | "plan" | "default";5export type ComposerSlashCommand = "model" | "plan" | "default";
6export type ComposerSubmissionIntent = "foreground" | "background";
67
7export interface ComposerTrigger {8export 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}
1314
14export function shouldSubmitComposerOnEnter(input: {15export 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}
2026
21const isInlineTokenSegment = (27const 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>()(
35783580
3579export const useComposerDraftStore = composerDraftStore;3581export 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
3581export function clearComposerDraftsEnvironment(environmentId: EnvironmentId): void {3617export 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>;
36253666
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}
37603803
3761export function finalizePromotedDraftThreadsByRef(3804export 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) !== null192 storedDraftThread !== null &&
193 ? null193 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 @@
1import { createFileRoute, useNavigate } from "@tanstack/react-router";1import { createFileRoute, useNavigate } from "@tanstack/react-router";
2import { useEffect } from "react";2import { useEffect } from "react";
3import ChatView from "../components/ChatView";3import ChatView from "../components/ChatView";
4import { threadHasStarted } from "../components/ChatView.logic";4import {
5 resolveDraftPromotionNavigationTarget,
6 threadHasStarted,
7} from "../components/ChatView.logic";
5import {8import {
6 DraftId,9 DraftId,
7 markPromotedDraftThreadByRef,10 markPromotedDraftThreadByRef,
11 useBackgroundDraftSubmissionPending,
8 useComposerDraftStore,12 useComposerDraftStore,
9} from "../composerDraftStore";13} from "../composerDraftStore";
10import { SidebarInset } from "../components/ui/sidebar";14import { 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 });
3241
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 @@
3Messages can contain up to 120,000 characters. If a draft is longer, T3 Code keeps it in the3Messages can contain up to 120,000 characters. If a draft is longer, T3 Code keeps it in the
4composer and shows how many characters need to be removed. Shorten the draft or split it into4composer and shows how many characters need to be removed. Shorten the draft or split it into
5multiple messages, then send again in the same thread.5multiple 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...@@ -67,6 +67,10 @@ at. To keep a worktree, use the explicit "new thread in this worktree" action in
67toolbar. The only difference between the two commands: with the current sidebar and more than one67toolbar. The only difference between the two commands: with the current sidebar and more than one
68project, `chat.new` opens a project chooser first.68project, `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
70## `when` Conditions74## `when` Conditions
7175
72A `when` expression is evaluated against context keys describing the current UI state. The keys76A `when` expression is evaluated against context keys describing the current UI state. The keys