From 24f75f2c3a40e86292503dcaaa614abee563dfcc Mon Sep 17 00:00:00 2001 From: BinBandit Date: Wed, 11 Mar 2026 21:18:07 +1100 Subject: [PATCH] feat(web): add /new composer thread command --- apps/web/src/components/ChatView.browser.tsx | 43 ++++++ apps/web/src/components/ChatView.tsx | 113 +++++++++++----- apps/web/src/components/Sidebar.tsx | 80 ++++------- apps/web/src/composer-logic.test.ts | 4 + apps/web/src/composer-logic.ts | 7 +- apps/web/src/lib/projectDraftThreads.test.ts | 135 +++++++++++++++++++ apps/web/src/lib/projectDraftThreads.ts | 107 +++++++++++++++ 7 files changed, 397 insertions(+), 92 deletions(-) create mode 100644 apps/web/src/lib/projectDraftThreads.test.ts create mode 100644 apps/web/src/lib/projectDraftThreads.ts diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index d8b74c8cc..e35d851ee 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -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('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, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3c8a0a152..5465e455c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -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"; @@ -783,42 +784,25 @@ 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 }, + }), }); }, [ @@ -826,7 +810,6 @@ export default function ChatView({ threadId }: ChatViewProps) { clearProjectDraftThreadId, getDraftThread, getDraftThreadByProjectId, - isServerThread, navigate, setDraftThreadContext, setProjectDraftThreadId, @@ -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; @@ -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>; const query = composerTrigger.query.trim().toLowerCase(); if (!query) { @@ -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; @@ -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, @@ -3382,6 +3424,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [ applyPromptReplacement, + handleNewThreadSlashCommand, handleInteractionModeChange, onProviderModelSelect, resolveActiveComposerTrigger, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 8b68c3b80..9128b25d9 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -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, @@ -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"; @@ -407,59 +407,31 @@ export default function Sidebar() { envMode?: DraftThreadEnvMode; }, ): Promise => { - 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, diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index 7e6805c96..19f1b09de 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -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(); }); diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index 70b3567c3..9a83840a6 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -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; @@ -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; @@ -165,12 +165,13 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos export function parseStandaloneComposerSlashCommand( text: string, ): Exclude | 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"; } diff --git a/apps/web/src/lib/projectDraftThreads.test.ts b/apps/web/src/lib/projectDraftThreads.test.ts new file mode 100644 index 000000000..f02ca4409 --- /dev/null +++ b/apps/web/src/lib/projectDraftThreads.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { ProjectId, ThreadId } from "@t3tools/contracts"; + +import { + openOrReuseProjectDraftThread, + type ProjectDraftThreadRecord, +} from "./projectDraftThreads"; + +const PROJECT_ID = "project-1" as ProjectId; +const CURRENT_THREAD_ID = "thread-current" as ThreadId; +const STORED_THREAD_ID = "thread-stored" as ThreadId; +const CREATED_THREAD_ID = "thread-created" as ThreadId; + +describe("openOrReuseProjectDraftThread", () => { + it("reuses the stored project draft thread and navigates to it", async () => { + const setDraftThreadContext = vi.fn(); + const setProjectDraftThreadId = vi.fn(); + const clearProjectDraftThreadId = vi.fn(); + const navigateToThread = vi.fn(async () => {}); + const storedDraftThread: ProjectDraftThreadRecord = { + threadId: STORED_THREAD_ID, + projectId: PROJECT_ID, + createdAt: "2026-03-11T10:00:00.000Z", + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + envMode: "local", + }; + + const result = await openOrReuseProjectDraftThread({ + projectId: PROJECT_ID, + currentThreadId: CURRENT_THREAD_ID, + options: { + branch: "feature/new-thread", + envMode: "worktree", + }, + getDraftThreadByProjectId: () => storedDraftThread, + getDraftThread: () => null, + setDraftThreadContext, + setProjectDraftThreadId, + clearProjectDraftThreadId, + navigateToThread, + }); + + expect(result).toBe(STORED_THREAD_ID); + expect(setDraftThreadContext).toHaveBeenCalledWith(STORED_THREAD_ID, { + branch: "feature/new-thread", + envMode: "worktree", + }); + expect(setProjectDraftThreadId).toHaveBeenCalledWith(PROJECT_ID, STORED_THREAD_ID); + expect(clearProjectDraftThreadId).not.toHaveBeenCalled(); + expect(navigateToThread).toHaveBeenCalledWith(STORED_THREAD_ID); + }); + + it("reuses the current draft thread for the same project without navigating", async () => { + const setDraftThreadContext = vi.fn(); + const setProjectDraftThreadId = vi.fn(); + const clearProjectDraftThreadId = vi.fn(); + const navigateToThread = vi.fn(async () => {}); + + const result = await openOrReuseProjectDraftThread({ + projectId: PROJECT_ID, + currentThreadId: CURRENT_THREAD_ID, + options: { + branch: null, + worktreePath: "/repo/worktrees/feature", + envMode: "worktree", + }, + getDraftThreadByProjectId: () => null, + getDraftThread: () => ({ + projectId: PROJECT_ID, + createdAt: "2026-03-11T10:00:00.000Z", + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + envMode: "local", + }), + setDraftThreadContext, + setProjectDraftThreadId, + clearProjectDraftThreadId, + navigateToThread, + }); + + expect(result).toBe(CURRENT_THREAD_ID); + expect(clearProjectDraftThreadId).toHaveBeenCalledWith(PROJECT_ID); + expect(setDraftThreadContext).toHaveBeenCalledWith(CURRENT_THREAD_ID, { + branch: null, + worktreePath: "/repo/worktrees/feature", + envMode: "worktree", + }); + expect(setProjectDraftThreadId).toHaveBeenCalledWith(PROJECT_ID, CURRENT_THREAD_ID); + expect(navigateToThread).not.toHaveBeenCalled(); + }); + + it("creates and navigates to a fresh draft thread when none exists", async () => { + const setDraftThreadContext = vi.fn(); + const setProjectDraftThreadId = vi.fn(); + const clearProjectDraftThreadId = vi.fn(); + const navigateToThread = vi.fn(async () => {}); + + const result = await openOrReuseProjectDraftThread({ + projectId: PROJECT_ID, + currentThreadId: CURRENT_THREAD_ID, + options: { + branch: "main", + worktreePath: null, + envMode: "local", + }, + getDraftThreadByProjectId: () => null, + getDraftThread: () => null, + setDraftThreadContext, + setProjectDraftThreadId, + clearProjectDraftThreadId, + navigateToThread, + createThreadId: () => CREATED_THREAD_ID, + now: () => "2026-03-11T11:00:00.000Z", + }); + + expect(result).toBe(CREATED_THREAD_ID); + expect(clearProjectDraftThreadId).toHaveBeenCalledWith(PROJECT_ID); + expect(setDraftThreadContext).not.toHaveBeenCalled(); + expect(setProjectDraftThreadId).toHaveBeenCalledWith(PROJECT_ID, CREATED_THREAD_ID, { + createdAt: "2026-03-11T11:00:00.000Z", + branch: "main", + worktreePath: null, + envMode: "local", + runtimeMode: "full-access", + interactionMode: "default", + }); + expect(navigateToThread).toHaveBeenCalledWith(CREATED_THREAD_ID); + }); +}); diff --git a/apps/web/src/lib/projectDraftThreads.ts b/apps/web/src/lib/projectDraftThreads.ts new file mode 100644 index 000000000..8f4177223 --- /dev/null +++ b/apps/web/src/lib/projectDraftThreads.ts @@ -0,0 +1,107 @@ +import type { ProjectId, ProviderInteractionMode, RuntimeMode, ThreadId } from "@t3tools/contracts"; + +import type { DraftThreadEnvMode, DraftThreadState } from "~/composerDraftStore"; +import { newThreadId } from "~/lib/utils"; +import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE } from "~/types"; + +export interface ProjectDraftThreadRecord extends DraftThreadState { + threadId: ThreadId; +} + +export interface ProjectDraftThreadOptions { + branch?: string | null; + worktreePath?: string | null; + envMode?: DraftThreadEnvMode; + runtimeMode?: RuntimeMode; + interactionMode?: ProviderInteractionMode; + createdAt?: string; +} + +interface OpenOrReuseProjectDraftThreadInput { + projectId: ProjectId; + currentThreadId: ThreadId | null; + options?: ProjectDraftThreadOptions; + getDraftThreadByProjectId: (projectId: ProjectId) => ProjectDraftThreadRecord | null; + getDraftThread: (threadId: ThreadId) => DraftThreadState | null; + setDraftThreadContext: (threadId: ThreadId, options: ProjectDraftThreadOptions) => void; + setProjectDraftThreadId: ( + projectId: ProjectId, + threadId: ThreadId, + options?: ProjectDraftThreadOptions, + ) => void; + clearProjectDraftThreadId: (projectId: ProjectId) => void; + navigateToThread: (threadId: ThreadId) => Promise; + createThreadId?: () => ThreadId; + now?: () => string; +} + +function hasDraftThreadOptions(options: ProjectDraftThreadOptions | undefined): boolean { + return ( + options?.branch !== undefined || + options?.worktreePath !== undefined || + options?.envMode !== undefined || + options?.runtimeMode !== undefined || + options?.interactionMode !== undefined || + options?.createdAt !== undefined + ); +} + +function buildDraftThreadContextUpdate( + options: ProjectDraftThreadOptions | undefined, +): ProjectDraftThreadOptions | null { + if (!hasDraftThreadOptions(options)) { + return null; + } + + return { + ...(options?.branch !== undefined ? { branch: options.branch ?? null } : {}), + ...(options?.worktreePath !== undefined ? { worktreePath: options.worktreePath ?? null } : {}), + ...(options?.envMode !== undefined ? { envMode: options.envMode } : {}), + ...(options?.runtimeMode !== undefined ? { runtimeMode: options.runtimeMode } : {}), + ...(options?.interactionMode !== undefined ? { interactionMode: options.interactionMode } : {}), + ...(options?.createdAt !== undefined ? { createdAt: options.createdAt } : {}), + }; +} + +export async function openOrReuseProjectDraftThread( + input: OpenOrReuseProjectDraftThreadInput, +): Promise { + const update = buildDraftThreadContextUpdate(input.options); + const storedDraftThread = input.getDraftThreadByProjectId(input.projectId); + + if (storedDraftThread) { + if (update) { + input.setDraftThreadContext(storedDraftThread.threadId, update); + } + input.setProjectDraftThreadId(input.projectId, storedDraftThread.threadId); + if (input.currentThreadId !== storedDraftThread.threadId) { + await input.navigateToThread(storedDraftThread.threadId); + } + return storedDraftThread.threadId; + } + + input.clearProjectDraftThreadId(input.projectId); + + if (input.currentThreadId) { + const activeDraftThread = input.getDraftThread(input.currentThreadId); + if (activeDraftThread?.projectId === input.projectId) { + if (update) { + input.setDraftThreadContext(input.currentThreadId, update); + } + input.setProjectDraftThreadId(input.projectId, input.currentThreadId); + return input.currentThreadId; + } + } + + const threadId = (input.createThreadId ?? newThreadId)(); + input.setProjectDraftThreadId(input.projectId, threadId, { + createdAt: input.options?.createdAt ?? (input.now ?? (() => new Date().toISOString()))(), + branch: input.options?.branch ?? null, + worktreePath: input.options?.worktreePath ?? null, + envMode: input.options?.envMode ?? "local", + runtimeMode: input.options?.runtimeMode ?? DEFAULT_RUNTIME_MODE, + interactionMode: input.options?.interactionMode ?? DEFAULT_INTERACTION_MODE, + }); + await input.navigateToThread(threadId); + return threadId; +}