diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2aeca85e6aa2..0aa94b94872c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2748,7 +2748,7 @@ export default function ChatView(props: ChatViewProps) { }, [navigate, setEnvironmentEnabled], ); - const { scratchWorkspaceRootFor, openScratchProject } = useScratchProject(); + const { scratchWorkspaceRootFor, openScratchProject, startScratchThread } = useScratchProject(); const activeProjectIsScratch = activeProject !== null && isScratchProject( @@ -8788,6 +8788,21 @@ export default function ChatView(props: ChatViewProps) { }, ) => { e?.preventDefault(); + const startWithoutProject = submissionIntent === "newWithoutProject"; + const canOpenNextComposer = () => + currentRouteThreadKeyRef.current === routeThreadKey && + !composerDraftHasUserContent( + useComposerDraftStore.getState().getComposerDraft(composerDraftTarget), + ); + if (startWithoutProject && scratchWorkspaceRootFor(environmentId) === null) { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "This environment cannot start a thread without a project", + }), + ); + return; + } // Typed out in full rather than picked from the menu. Attachments or contexts // mean the user is sending a prompt, so those go through as usual. if ( @@ -9209,6 +9224,8 @@ export default function ChatView(props: ChatViewProps) { setComposerDraftThreadContexts(composerDraftTarget, [...records]), resetCursor: (options) => composerRef.current?.resetCursorState(options), }); + } else if (startWithoutProject) { + await startScratchThread(environmentId, canOpenNextComposer); } else if ( submissionIntent === "background" && currentRouteThreadKeyRef.current === routeThreadKey @@ -9679,6 +9696,14 @@ export default function ChatView(props: ChatViewProps) { resetLocalDispatch(); releasedComposer = true; await starts; + if ( + startWithoutProject && + startedCount > 0 && + failedSelections.length === 0 && + canRestoreDraft() + ) { + await startScratchThread(environmentId, canRestoreDraft); + } if (startedCount > 0) { toastManager.add( stackedThreadToast({ @@ -9953,7 +9978,7 @@ export default function ChatView(props: ChatViewProps) { } : undefined; const backgroundThreadRef = - submissionIntent === "background" && isLocalDraftThread + (submissionIntent === "background" || startWithoutProject) && isLocalDraftThread ? scopeThreadRef(environmentId, threadIdForSend) : null; if (backgroundThreadRef) beginBackgroundDraftSubmissionByRef(backgroundThreadRef); @@ -10002,7 +10027,7 @@ export default function ChatView(props: ChatViewProps) { createdAt: messageCreatedAt, }, }); - if (backgroundThreadRef) { + if (backgroundThreadRef && !startWithoutProject) { markPromotedDraftThreadByRef(backgroundThreadRef); try { backgroundDraftOpened = Boolean( @@ -10040,6 +10065,10 @@ export default function ChatView(props: ChatViewProps) { releaseDraftAttachments(composerAttachmentsSnapshot); } acknowledgeActiveThreadWoke(); + if (startWithoutProject) { + if (backgroundThreadRef) markPromotedDraftThreadByRef(backgroundThreadRef); + backgroundDraftOpened = await startScratchThread(environmentId, canOpenNextComposer); + } if (backgroundThreadRef) { if (backgroundDraftOpened || currentRouteThreadKeyRef.current !== routeThreadKey) { finalizePromotedDraftThreadByRef(backgroundThreadRef); @@ -10075,7 +10104,7 @@ export default function ChatView(props: ChatViewProps) { } if (failure !== null) { - if (submissionIntent === "background" && draftId && draftThread) { + if ((submissionIntent === "background" || startWithoutProject) && draftId && draftThread) { restoreFailedBackgroundDraftThread( draftId, draftThread, diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 37d80d33b7df..a136817e25e5 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1403,7 +1403,9 @@ function OpenCommandPaletteDialog(props: { title: "No project", icon: , shortcutCommand: "chat.newWithoutProject" as const, - run: () => startScratchThread(scratchTargetEnvironmentId), + run: async () => { + await startScratchThread(scratchTargetEnvironmentId); + }, }, ...projectItems.slice(noProjectIndex), ]; @@ -1929,7 +1931,9 @@ function OpenCommandPaletteDialog(props: { title: "New thread without a project", icon: , shortcutCommand: "chat.newWithoutProject", - run: () => startScratchThread(scratchTargetEnvironmentId), + run: async () => { + await startScratchThread(scratchTargetEnvironmentId); + }, }); } diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index 2e5f88079084..ee96356885af 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -50,6 +50,16 @@ describe("KeybindingsSettings.logic", () => { }), ); }); + it("finds the editable shortcut for sending and starting without a project", () => { + expect(buildKeybindingRows(DEFAULT_RESOLVED_KEYBINDINGS, "without a project")).toContainEqual( + expect.objectContaining({ + command: "composer.sendAndNewThreadWithoutProject", + key: "mod+alt+shift+enter", + source: "Default", + conflicts: [], + }), + ); + }); it.each(["pu", "pull request", "copy link", "thread id"])( "finds the copy link shortcut with %s", (query) => { diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index fde8f3419c09..95c4981c8bfd 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -362,6 +362,8 @@ export function commandLabel(command: KeybindingCommand): string { if (command === "composer.sendAlternate") return "Composer: Opposite Queue or Steer Action"; if (command === "composer.sendBackground") return "Composer: Start in Background"; if (command === "composer.sendAndNewThread") return "Composer: Send and Start New Thread"; + if (command === "composer.sendAndNewThreadWithoutProject") + return "Composer: Send and Start New Thread Without a Project"; if (command === "thread.steerQueuedMessage") return "Queue: Send First Queued Message as Steer"; if (command === "thread.editQueuedMessage") return "Queue: Edit Last Queued Message"; if (command === "thread.copyReference") return "Pull Request: Copy Link or Thread ID"; diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index df3e54b4ddfc..79abcd1f6f5f 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -77,6 +77,57 @@ describe("composerSubmissionIntentForKey", () => { }; const enter = { key: "Enter", metaKey: false, ctrlKey: false, altKey: false, shiftKey: false }; + it.each(["MacIntel", "Win32", "Linux"])( + "sends and starts without a project from drafts and existing threads on %s", + (platform) => { + for (const isDraftThread of [false, true]) { + for (const isRunning of [false, true]) { + expect( + composerSubmissionIntentForKey({ + ...input, + platform, + isDraftThread, + isRunning, + event: { + ...enter, + metaKey: platform === "MacIntel", + ctrlKey: platform !== "MacIntel", + altKey: true, + shiftKey: true, + }, + }), + ).toBe("newWithoutProject"); + } + } + }, + ); + + it("remaps sending and starting without a project without retaining its default shortcut", () => { + const keybindings = mergeWithDefaultKeybindings( + compileResolvedKeybindingsConfig([ + { + key: "alt+p", + command: "composer.sendAndNewThreadWithoutProject", + when: "composerFocus", + }, + ]), + ); + expect( + composerSubmissionIntentForKey({ + ...input, + keybindings, + event: { ...enter, key: "p", altKey: true }, + }), + ).toBe("newWithoutProject"); + expect( + composerSubmissionIntentForKey({ + ...input, + keybindings, + event: { ...enter, ctrlKey: true, altKey: true, shiftKey: true }, + }), + ).toBeNull(); + }); + it.each([ ["enter", "one line", false, "foreground"], ["enter", "two\nlines", false, "foreground"], diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index c26996f494e5..0bf9651c82e9 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -13,7 +13,11 @@ import { resolveShortcutCommand, type ShortcutEventLike } from "./keybindings"; export type ComposerTriggerKind = "path" | "pull-request" | "slash-command" | "skill"; export type ComposerSlashCommand = "model" | "plan" | "default"; -export type ComposerSubmissionIntent = "foreground" | "background" | "alternate"; +export type ComposerSubmissionIntent = + | "foreground" + | "background" + | "alternate" + | "newWithoutProject"; export interface ComposerTrigger { kind: ComposerTriggerKind; @@ -60,6 +64,7 @@ export function composerSubmissionIntentForKey(input: { if (command === "composer.sendAlternate" && input.isRunning) return "alternate"; if (command === "composer.sendBackground" && input.isDraftThread) return "background"; if (command === "composer.sendAndNewThread" && !input.isDraftThread) return "background"; + if (command === "composer.sendAndNewThreadWithoutProject") return "newWithoutProject"; if (command !== null || event.key !== "Enter" || event.shiftKey || event.altKey) return null; if ( composerRequiresModifier(input.sendShortcut, input.prompt ?? "") && diff --git a/apps/web/src/hooks/useScratchProject.test.ts b/apps/web/src/hooks/useScratchProject.test.ts new file mode 100644 index 000000000000..8a9f59c0532d --- /dev/null +++ b/apps/web/src/hooks/useScratchProject.test.ts @@ -0,0 +1,68 @@ +import { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + openScratch: vi.fn(), + newThread: vi.fn(), + toast: vi.fn(), +})); + +vi.mock("react", () => ({ useCallback: (callback: unknown) => callback })); +vi.mock("~/state/environments", () => ({ useEnvironments: () => ({ environments: [] }) })); +vi.mock("~/state/projects", () => ({ projectEnvironment: { openScratch: {} } })); +vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => state.openScratch })); +vi.mock("./useHandleNewThread", () => ({ useNewThreadHandler: () => state.newThread })); +vi.mock("~/components/ui/toast", () => ({ + stackedThreadToast: (input: unknown) => input, + toastManager: { add: state.toast }, +})); + +import { useScratchProject } from "./useScratchProject"; + +const environmentId = EnvironmentId.make("remote-environment"); +const projectId = ProjectId.make("scratch-project"); +const success = { _tag: "Success", value: { environmentId, id: projectId } }; + +beforeEach(() => { + vi.resetAllMocks(); + state.newThread.mockResolvedValue({ draftId: "fresh-draft", threadId: "fresh-thread" }); +}); + +describe("startScratchThread", () => { + it("opens a scratch composer on the requested environment", async () => { + state.openScratch.mockResolvedValue(success); + expect(await useScratchProject().startScratchThread(environmentId)).toBe(true); + expect(state.newThread).toHaveBeenCalledWith({ environmentId, projectId }); + }); + + it("does not navigate when the user moves on during the scratch RPC", async () => { + let resolveProjectOpen: (result: typeof success) => void = () => undefined; + const pending = new Promise((resolve) => { + resolveProjectOpen = resolve; + }); + state.openScratch.mockReturnValue(pending); + let stillCurrent = true; + const start = useScratchProject().startScratchThread(environmentId, () => stillCurrent); + stillCurrent = false; + resolveProjectOpen(success); + expect(await start).toBe(false); + expect(state.newThread).not.toHaveBeenCalled(); + }); + + it("does not open a project when the sending composer is already stale", async () => { + expect(await useScratchProject().startScratchThread(environmentId, () => false)).toBe(false); + expect(state.openScratch).not.toHaveBeenCalled(); + }); + + it("reports failure to open the composer without claiming it opened", async () => { + state.openScratch.mockResolvedValue(success); + state.newThread.mockRejectedValue(new Error("Disconnected")); + expect(await useScratchProject().startScratchThread(environmentId)).toBe(false); + expect(state.toast).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Could not start without a project", + description: "Disconnected", + }), + ); + }); +}); diff --git a/apps/web/src/hooks/useScratchProject.ts b/apps/web/src/hooks/useScratchProject.ts index 52fdf39cc522..375cc883d927 100644 --- a/apps/web/src/hooks/useScratchProject.ts +++ b/apps/web/src/hooks/useScratchProject.ts @@ -78,13 +78,19 @@ export function useScratchProject() { ); const startScratchThread = useCallback( - async (environmentId: EnvironmentId) => { + async (environmentId: EnvironmentId, shouldStart: () => boolean = () => true) => { + if (!shouldStart()) return false; const project = await openScratchProject(environmentId); - if (project) { - await handleNewThread(scopeProjectRef(project.environmentId, project.id)).catch( - (error: unknown) => reportScratchFailure("Could not start without a project", error), + // A send can finish after the user moves on; opening the scratch project + // also crosses the wire, so check again before changing their composer. + if (project && shouldStart()) { + return Boolean( + await handleNewThread(scopeProjectRef(project.environmentId, project.id)).catch( + (error: unknown) => reportScratchFailure("Could not start without a project", error), + ), ); } + return false; }, [handleNewThread, openScratchProject], ); diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 5523b5aa3c9c..5b63e0571069 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -18,6 +18,11 @@ does the same. Change these shortcuts in **Settings → Keybindings** under or **Composer: Send and Start New Thread**. These bindings take priority over the send shortcut. Click the send button to use the configured follow-up behavior. +Use `mod+Alt+Shift+Enter` to send and then open a new thread without a project +on the same machine. It works in new and existing threads. Customize it under +**Composer: Send and Start New Thread Without a Project**. If sending fails, +the current composer stays open so you can retry. + When an active turn has queued messages, `mod+Shift+Enter` sends the first as a steer. Change it under **Queue: Send First Queued Message as Steer** in Keybindings. diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 1491fbe482bd..6d7c3aede4d1 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -101,6 +101,13 @@ it.effect("parses keybinding rules", () => }); assert.strictEqual(parsedLocal.command, "chat.newLocal"); + const parsedSendWithoutProject = yield* decode(KeybindingRule, { + key: "mod+alt+shift+enter", + command: "composer.sendAndNewThreadWithoutProject", + when: "composerFocus", + }); + assert.strictEqual(parsedSendWithoutProject.command, "composer.sendAndNewThreadWithoutProject"); + const parsedFind = yield* decode(KeybindingRule, { key: "mod+f", command: "chat.find", diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 04c3acab803d..60215ea952a5 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -90,6 +90,7 @@ export const STATIC_KEYBINDING_COMMANDS = [ "composer.sendAlternate", "composer.sendBackground", "composer.sendAndNewThread", + "composer.sendAndNewThreadWithoutProject", "composer.host", "composer.cycleHost", "composer.effort", diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 7620b2432a77..63bf0787712d 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -168,6 +168,11 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ command: "composer.sendAndNewThread", when: "composerFocus && !draftThreadRoute", }, + { + key: "mod+alt+shift+enter", + command: "composer.sendAndNewThreadWithoutProject", + when: "composerFocus", + }, { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" },