From 8afbc4015e3d511c956cf876cb9592fe110a6f49 Mon Sep 17 00:00:00 2001 From: jan Date: Mon, 27 Jul 2026 15:15:17 +0200 Subject: [PATCH] feat: allow new thread creation through project breadcrumbs --- .../web/src/components/ChatView.logic.test.ts | 28 ++++++++++++++++ apps/web/src/components/ChatView.logic.ts | 11 +++++++ apps/web/src/components/ChatView.tsx | 7 ++++ apps/web/src/components/chat/ChatHeader.tsx | 32 +++++++++++++------ 4 files changed, 68 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 57c12959ffb..1b2e537c35d 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -25,6 +25,7 @@ import { reconcileRetainedMountedThreadIds, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, + startNewThreadForProject, shouldShowBranchMismatchBanner, shouldWriteThreadErrorToCurrentServerThread, } from "./ChatView.logic"; @@ -445,6 +446,33 @@ describe("shouldWriteThreadErrorToCurrentServerThread", () => { }); }); +describe("startNewThreadForProject", () => { + it("starts a thread through the supplied shared handler for the active project", () => { + const calls: Array<{ environmentId: EnvironmentId; projectId: ProjectId }> = []; + const projectRef = { environmentId, projectId }; + + expect( + startNewThreadForProject(projectRef, (nextProjectRef) => { + calls.push(nextProjectRef); + return Promise.resolve(); + }), + ).toBe(true); + expect(calls).toEqual([projectRef]); + }); + + it("does nothing when the active project is unavailable", () => { + let called = false; + + expect( + startNewThreadForProject(null, () => { + called = true; + return Promise.resolve(); + }), + ).toBe(false); + expect(called).toBe(false); + }); +}); + describe("hasServerAcknowledgedLocalDispatch", () => { it("does not acknowledge unchanged server state", () => { const localDispatch = createLocalDispatchSnapshot( diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 466c9b24c87..4fb007b85b5 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -5,6 +5,7 @@ import { type ModelSelection, type ProviderDriverKind, type ServerProvider, + type ScopedProjectRef, type ScopedThreadRef, type ThreadId, type TurnId, @@ -27,6 +28,16 @@ export const MAX_HIDDEN_MOUNTED_PREVIEW_THREADS = 3; export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); +export function startNewThreadForProject( + projectRef: ScopedProjectRef | null, + handleNewThread: (projectRef: ScopedProjectRef) => Promise, +): boolean { + if (projectRef === null) return false; + void handleNewThread(projectRef); + + return true; +} + export function resolveThreadMetadataUpdateForNextTurn(input: { currentModelSelection: ModelSelection; nextModelSelection?: ModelSelection; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 918e2c92bb9..73fb98bc97b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -168,6 +168,7 @@ import { getProviderModelCapabilities, resolveSelectableProvider } from "../prov import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; import { useClientSettings, useEnvironmentSettings } from "../hooks/useSettings"; import { useNowMinute } from "../hooks/useNowMinute"; +import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; import { getTerminalFocusOwner } from "../lib/terminalFocus"; import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; @@ -271,6 +272,7 @@ import { resolveSendEnvMode, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, + startNewThreadForProject, waitForStartedServerThread, } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; @@ -1130,6 +1132,7 @@ function ChatViewContent(props: ChatViewProps) { forceExpandedMobileComposer = false, } = props; const draftId = routeKind === "draft" ? props.draftId : null; + const handleNewThread = useNewThreadHandler(); const routeThreadRef = useMemo( () => scopeThreadRef(environmentId, threadId), [environmentId, threadId], @@ -1583,6 +1586,9 @@ function ChatViewContent(props: ChatViewProps) { ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) : null; const activeProject = useProject(activeProjectRef); + const handleNewThreadInActiveProject = useCallback(() => { + startNewThreadForProject(activeProjectRef, handleNewThread); + }, [activeProjectRef, handleNewThread]); const activeEnvironmentShell = useEnvironmentQuery( activeThread ? environmentShell.stateAtom(activeThread.environmentId) : null, ); @@ -5674,6 +5680,7 @@ function ChatViewContent(props: ChatViewProps) { availableEditors={availableEditors} rightPanelOpen={rightPanelOpen} gitCwd={gitCwd} + onNewThreadInProject={handleNewThreadInActiveProject} onRunProjectScript={runProjectScript} onAddProjectScript={saveProjectScript} onUpdateProjectScript={updateProjectScript} diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 3a7d57859a8..0adeed6ffa6 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -34,6 +34,7 @@ interface ChatHeaderProps { availableEditors: ReadonlyArray; rightPanelOpen: boolean; gitCwd: string | null; + onNewThreadInProject: () => void; onRunProjectScript: (script: ProjectScript) => void; onAddProjectScript: (input: NewProjectScriptInput) => Promise; onUpdateProjectScript: ( @@ -69,6 +70,7 @@ export const ChatHeader = memo(function ChatHeader({ availableEditors, rightPanelOpen, gitCwd, + onNewThreadInProject, onRunProjectScript, onAddProjectScript, onUpdateProjectScript, @@ -92,16 +94,26 @@ export const ChatHeader = memo(function ChatHeader({ doesn't answer it. */} {activeProjectName ? ( - - - - {activeProjectName} - - + + + } + > + + {activeProjectName} + + New thread in {activeProjectName} + /