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" },