Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1048,6 +1048,49 @@ describe("ChatView timeline estimator parity (full app)", () => {
}
});

it("starts a fresh draft thread from the composer /new slash command", async () => {
useComposerDraftStore.getState().setPrompt(THREAD_ID, "/new ");

const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
snapshot: createSnapshotForTargetUser({
targetMessageId: "msg-user-slash-new-test" as MessageId,
targetText: "slash new thread test",
}),
});

try {
const sendButton = await waitForElement(
() => document.querySelector<HTMLButtonElement>('button[aria-label="Send message"]'),
"Unable to find the composer send button.",
);

sendButton.click();

const newThreadPath = await waitForURL(
mounted.router,
(path) => UUID_ROUTE_RE.test(path),
"Route should have changed to a new draft thread UUID after /new.",
);
const newThreadId = newThreadPath.slice(1) as ThreadId;

expect(useComposerDraftStore.getState().projectDraftThreadIdByProjectId[PROJECT_ID]).toBe(
newThreadId,
);
expect(useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]?.prompt ?? "").toBe("");
expect(
wsRequests.some((request) => request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand),
).toBe(false);

await expect
.element(page.getByText("Send a message to start the conversation."))
.toBeInTheDocument();
await expect.element(page.getByTestId("composer-editor")).toBeInTheDocument();
} finally {
await mounted.cleanup();
}
});

it("keeps long proposed plans lightweight until the user expands them", async () => {
const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
Expand Down
113 changes: 78 additions & 35 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
useVirtualizer,
} from "@tanstack/react-virtual";
import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";
import { openOrReuseProjectDraftThread as openProjectDraftThread } from "~/lib/projectDraftThreads";
import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery";

Expand Down Expand Up @@ -783,50 +784,32 @@ export default function ChatView({ threadId }: ChatViewProps) {
setPullRequestDialogState(null);
}, []);

const openOrReuseProjectDraftThread = useCallback(
const openPreparedProjectDraftThread = useCallback(
async (input: { branch: string; worktreePath: string | null; envMode: DraftThreadEnvMode }) => {
if (!activeProject) {
throw new Error("No active project is available for this pull request.");
}
const storedDraftThread = getDraftThreadByProjectId(activeProject.id);
if (storedDraftThread) {
setDraftThreadContext(storedDraftThread.threadId, input);
setProjectDraftThreadId(activeProject.id, storedDraftThread.threadId, input);
if (storedDraftThread.threadId !== threadId) {
await navigate({
await openProjectDraftThread({
projectId: activeProject.id,
currentThreadId: threadId,
options: input,
getDraftThreadByProjectId,
getDraftThread,
setDraftThreadContext,
setProjectDraftThreadId,
clearProjectDraftThreadId,
navigateToThread: (nextThreadId) =>
navigate({
to: "/$threadId",
params: { threadId: storedDraftThread.threadId },
});
}
return;
}

const activeDraftThread = getDraftThread(threadId);
if (!isServerThread && activeDraftThread?.projectId === activeProject.id) {
setDraftThreadContext(threadId, input);
setProjectDraftThreadId(activeProject.id, threadId, input);
return;
}

clearProjectDraftThreadId(activeProject.id);
const nextThreadId = newThreadId();
setProjectDraftThreadId(activeProject.id, nextThreadId, {
createdAt: new Date().toISOString(),
runtimeMode: DEFAULT_RUNTIME_MODE,
interactionMode: DEFAULT_INTERACTION_MODE,
...input,
});
await navigate({
to: "/$threadId",
params: { threadId: nextThreadId },
params: { threadId: nextThreadId },
}),
});
},
[
activeProject,
clearProjectDraftThreadId,
getDraftThread,
getDraftThreadByProjectId,
isServerThread,
navigate,
setDraftThreadContext,
setProjectDraftThreadId,
Expand All @@ -836,15 +819,53 @@ export default function ChatView({ threadId }: ChatViewProps) {

const handlePreparedPullRequestThread = useCallback(
async (input: { branch: string; worktreePath: string | null }) => {
await openOrReuseProjectDraftThread({
await openPreparedProjectDraftThread({
branch: input.branch,
worktreePath: input.worktreePath,
envMode: input.worktreePath ? "worktree" : "local",
});
},
[openOrReuseProjectDraftThread],
[openPreparedProjectDraftThread],
);

const handleNewThreadSlashCommand = useCallback(async () => {
if (!activeProject) {
return;
}

await openProjectDraftThread({
projectId: activeProject.id,
currentThreadId: threadId,
options: {
branch: activeThread?.branch ?? null,
worktreePath: activeThread?.worktreePath ?? null,
envMode: draftThread?.envMode ?? (activeThread?.worktreePath ? "worktree" : "local"),
},
getDraftThreadByProjectId,
getDraftThread,
setDraftThreadContext,
setProjectDraftThreadId,
clearProjectDraftThreadId,
navigateToThread: (nextThreadId) =>
navigate({
to: "/$threadId",
params: { threadId: nextThreadId },
}),
});
}, [
activeProject,
activeThread?.branch,
activeThread?.worktreePath,
clearProjectDraftThreadId,
draftThread?.envMode,
getDraftThread,
getDraftThreadByProjectId,
navigate,
setDraftThreadContext,
setProjectDraftThreadId,
threadId,
]);

useEffect(() => {
if (!activeThread?.id) return;
if (!latestTurnSettled) return;
Expand Down Expand Up @@ -1314,6 +1335,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
label: "/default",
description: "Switch this thread back to normal chat mode",
},
{
id: "slash:new",
type: "slash-command",
command: "new",
label: "/new",
description: "Start a new thread in this project",
},
] satisfies ReadonlyArray<Extract<ComposerCommandItem, { type: "slash-command" }>>;
const query = composerTrigger.query.trim().toLowerCase();
if (!query) {
Expand Down Expand Up @@ -2577,12 +2605,16 @@ export default function ChatView({ threadId }: ChatViewProps) {
const standaloneSlashCommand =
composerImages.length === 0 ? parseStandaloneComposerSlashCommand(trimmed) : null;
if (standaloneSlashCommand) {
await handleInteractionModeChange(standaloneSlashCommand);
promptRef.current = "";
clearComposerDraftContent(activeThread.id);
setComposerHighlightedItemId(null);
setComposerCursor(0);
setComposerTrigger(null);
if (standaloneSlashCommand === "new") {
await handleNewThreadSlashCommand();
} else {
await handleInteractionModeChange(standaloneSlashCommand);
}
return;
}
if (!trimmed && composerImages.length === 0) return;
Expand Down Expand Up @@ -3363,6 +3395,16 @@ export default function ChatView({ threadId }: ChatViewProps) {
}
return;
}
if (item.command === "new") {
const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", {
expectedText: expectedToken,
});
if (applied) {
setComposerHighlightedItemId(null);
void handleNewThreadSlashCommand();
}
return;
}
void handleInteractionModeChange(item.command === "plan" ? "plan" : "default");
const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", {
expectedText: expectedToken,
Expand All @@ -3382,6 +3424,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
},
[
applyPromptReplacement,
handleNewThreadSlashCommand,
handleInteractionModeChange,
onProviderModelSelect,
resolveActiveComposerTrigger,
Expand Down
80 changes: 26 additions & 54 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-
import { restrictToParentElement, restrictToVerticalAxis } from "@dnd-kit/modifiers";
import { CSS } from "@dnd-kit/utilities";
import {
DEFAULT_RUNTIME_MODE,
DEFAULT_MODEL_BY_PROVIDER,
type DesktopUpdateState,
ProjectId,
Expand All @@ -40,12 +39,13 @@ import { useLocation, useNavigate, useParams } from "@tanstack/react-router";
import { useAppSettings } from "../appSettings";
import { isElectron } from "../env";
import { APP_STAGE_LABEL, APP_VERSION } from "../branding";
import { isMacPlatform, newCommandId, newProjectId, newThreadId } from "../lib/utils";
import { isMacPlatform, newCommandId, newProjectId } from "../lib/utils";
import { useStore } from "../store";
import { isChatNewLocalShortcut, isChatNewShortcut, shortcutLabelForCommand } from "../keybindings";
import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic";
import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery";
import { serverConfigQueryOptions } from "../lib/serverReactQuery";
import { openOrReuseProjectDraftThread } from "../lib/projectDraftThreads";
import { readNativeApi } from "../nativeApi";
import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore";
import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
Expand Down Expand Up @@ -407,59 +407,31 @@ export default function Sidebar() {
envMode?: DraftThreadEnvMode;
},
): Promise<void> => {
const hasBranchOption = options?.branch !== undefined;
const hasWorktreePathOption = options?.worktreePath !== undefined;
const hasEnvModeOption = options?.envMode !== undefined;
const storedDraftThread = getDraftThreadByProjectId(projectId);
if (storedDraftThread) {
return (async () => {
if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) {
setDraftThreadContext(storedDraftThread.threadId, {
...(hasBranchOption ? { branch: options?.branch ?? null } : {}),
...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}),
...(hasEnvModeOption ? { envMode: options?.envMode } : {}),
});
}
setProjectDraftThreadId(projectId, storedDraftThread.threadId);
if (routeThreadId === storedDraftThread.threadId) {
return;
}
await navigate({
return openOrReuseProjectDraftThread({
projectId,
currentThreadId: routeThreadId ?? null,
...(options
? {
options: {
...(options.branch !== undefined ? { branch: options.branch } : {}),
...(options.worktreePath !== undefined
? { worktreePath: options.worktreePath }
: {}),
...(options.envMode !== undefined ? { envMode: options.envMode } : {}),
},
}
: {}),
getDraftThreadByProjectId,
getDraftThread,
setDraftThreadContext,
setProjectDraftThreadId,
clearProjectDraftThreadId,
navigateToThread: (threadId) =>
navigate({
to: "/$threadId",
params: { threadId: storedDraftThread.threadId },
});
})();
}
clearProjectDraftThreadId(projectId);

const activeDraftThread = routeThreadId ? getDraftThread(routeThreadId) : null;
if (activeDraftThread && routeThreadId && activeDraftThread.projectId === projectId) {
if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) {
setDraftThreadContext(routeThreadId, {
...(hasBranchOption ? { branch: options?.branch ?? null } : {}),
...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}),
...(hasEnvModeOption ? { envMode: options?.envMode } : {}),
});
}
setProjectDraftThreadId(projectId, routeThreadId);
return Promise.resolve();
}
const threadId = newThreadId();
const createdAt = new Date().toISOString();
return (async () => {
setProjectDraftThreadId(projectId, threadId, {
createdAt,
branch: options?.branch ?? null,
worktreePath: options?.worktreePath ?? null,
envMode: options?.envMode ?? "local",
runtimeMode: DEFAULT_RUNTIME_MODE,
});

await navigate({
to: "/$threadId",
params: { threadId },
});
})();
params: { threadId },
}),
}).then(() => undefined);
},
[
clearProjectDraftThreadId,
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/composer-logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,10 @@ describe("parseStandaloneComposerSlashCommand", () => {
expect(parseStandaloneComposerSlashCommand("/default")).toBe("default");
});

it("parses standalone /new command", () => {
expect(parseStandaloneComposerSlashCommand(" /new ")).toBe("new");
});

it("ignores slash commands with extra message text", () => {
expect(parseStandaloneComposerSlashCommand("/plan explain this")).toBeNull();
});
Expand Down
7 changes: 4 additions & 3 deletions apps/web/src/composer-logic.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { splitPromptIntoComposerSegments } from "./composer-editor-mentions";

export type ComposerTriggerKind = "path" | "slash-command" | "slash-model";
export type ComposerSlashCommand = "model" | "plan" | "default";
export type ComposerSlashCommand = "model" | "plan" | "default" | "new";

export interface ComposerTrigger {
kind: ComposerTriggerKind;
Expand All @@ -10,7 +10,7 @@ export interface ComposerTrigger {
rangeEnd: number;
}

const SLASH_COMMANDS: readonly ComposerSlashCommand[] = ["model", "plan", "default"];
const SLASH_COMMANDS: readonly ComposerSlashCommand[] = ["model", "plan", "default", "new"];

function clampCursor(text: string, cursor: number): number {
if (!Number.isFinite(cursor)) return text.length;
Expand Down Expand Up @@ -165,12 +165,13 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos
export function parseStandaloneComposerSlashCommand(
text: string,
): Exclude<ComposerSlashCommand, "model"> | null {
const match = /^\/(plan|default)\s*$/i.exec(text.trim());
const match = /^\/(plan|default|new)\s*$/i.exec(text.trim());
if (!match) {
return null;
}
const command = match[1]?.toLowerCase();
if (command === "plan") return "plan";
if (command === "new") return "new";
return "default";
}

Expand Down
Loading
Loading