authorgravatar for hi@4bs3nt.comjan <hi@4bs3nt.com> 2026-07-27 16:46:09+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-27 16:46:09+02:00
logbdf99c17bf7ae634728481677cf1bca8db65d00e
treeb7ed96368cc3e3d0e173e7be2c95c8baf62106c7
parent108e01746c585d98703df5fe76cb6c213ecb6b8f
signature Signed by PGP key B5690EEEBB952194

feat: allow new thread creation through project breadcrumbs (#4638)


4 files changed, 68 insertions(+), 10 deletions(-)

apps/web/src/components/ChatView.logic.test.ts+28
...@@ -25,6 +25,7 @@ import {...@@ -25,6 +25,7 @@ import {
25 reconcileRetainedMountedThreadIds,25 reconcileRetainedMountedThreadIds,
26 resolveThreadMetadataUpdateForNextTurn,26 resolveThreadMetadataUpdateForNextTurn,
27 resolveSendEnvMode,27 resolveSendEnvMode,
28 startNewThreadForProject,
28 shouldShowBranchMismatchBanner,29 shouldShowBranchMismatchBanner,
29 shouldWriteThreadErrorToCurrentServerThread,30 shouldWriteThreadErrorToCurrentServerThread,
30} from "./ChatView.logic";31} from "./ChatView.logic";
...@@ -445,6 +446,33 @@ describe("shouldWriteThreadErrorToCurrentServerThread", () => {...@@ -445,6 +446,33 @@ describe("shouldWriteThreadErrorToCurrentServerThread", () => {
445 });446 });
446});447});
447448
449describe("startNewThreadForProject", () => {
450 it("starts a thread through the supplied shared handler for the active project", () => {
451 const calls: Array<{ environmentId: EnvironmentId; projectId: ProjectId }> = [];
452 const projectRef = { environmentId, projectId };
453
454 expect(
455 startNewThreadForProject(projectRef, (nextProjectRef) => {
456 calls.push(nextProjectRef);
457 return Promise.resolve();
458 }),
459 ).toBe(true);
460 expect(calls).toEqual([projectRef]);
461 });
462
463 it("does nothing when the active project is unavailable", () => {
464 let called = false;
465
466 expect(
467 startNewThreadForProject(null, () => {
468 called = true;
469 return Promise.resolve();
470 }),
471 ).toBe(false);
472 expect(called).toBe(false);
473 });
474});
475
448describe("hasServerAcknowledgedLocalDispatch", () => {476describe("hasServerAcknowledgedLocalDispatch", () => {
449 it("does not acknowledge unchanged server state", () => {477 it("does not acknowledge unchanged server state", () => {
450 const localDispatch = createLocalDispatchSnapshot(478 const localDispatch = createLocalDispatchSnapshot(
apps/web/src/components/ChatView.logic.ts+11
...@@ -5,6 +5,7 @@ import {...@@ -5,6 +5,7 @@ import {
5 type ModelSelection,5 type ModelSelection,
6 type ProviderDriverKind,6 type ProviderDriverKind,
7 type ServerProvider,7 type ServerProvider,
8 type ScopedProjectRef,
8 type ScopedThreadRef,9 type ScopedThreadRef,
9 type ThreadId,10 type ThreadId,
10 type TurnId,11 type TurnId,
...@@ -27,6 +28,16 @@ export const MAX_HIDDEN_MOUNTED_PREVIEW_THREADS = 3;...@@ -27,6 +28,16 @@ export const MAX_HIDDEN_MOUNTED_PREVIEW_THREADS = 3;
2728
28export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String);29export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String);
2930
31export function startNewThreadForProject(
32 projectRef: ScopedProjectRef | null,
33 handleNewThread: (projectRef: ScopedProjectRef) => Promise<void>,
34): boolean {
35 if (projectRef === null) return false;
36 void handleNewThread(projectRef);
37
38 return true;
39}
40
30export function resolveThreadMetadataUpdateForNextTurn(input: {41export function resolveThreadMetadataUpdateForNextTurn(input: {
31 currentModelSelection: ModelSelection;42 currentModelSelection: ModelSelection;
32 nextModelSelection?: ModelSelection;43 nextModelSelection?: ModelSelection;
apps/web/src/components/ChatView.tsx+7
...@@ -168,6 +168,7 @@ import { getProviderModelCapabilities, resolveSelectableProvider } from "../prov...@@ -168,6 +168,7 @@ import { getProviderModelCapabilities, resolveSelectableProvider } from "../prov
168import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances";168import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances";
169import { useClientSettings, useEnvironmentSettings } from "../hooks/useSettings";169import { useClientSettings, useEnvironmentSettings } from "../hooks/useSettings";
170import { useNowMinute } from "../hooks/useNowMinute";170import { useNowMinute } from "../hooks/useNowMinute";
171import { useNewThreadHandler } from "../hooks/useHandleNewThread";
171import { resolveAppModelSelectionForInstance } from "../modelSelection";172import { resolveAppModelSelectionForInstance } from "../modelSelection";
172import { getTerminalFocusOwner } from "../lib/terminalFocus";173import { getTerminalFocusOwner } from "../lib/terminalFocus";
173import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions";174import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions";
...@@ -271,6 +272,7 @@ import {...@@ -271,6 +272,7 @@ import {
271 resolveSendEnvMode,272 resolveSendEnvMode,
272 revokeBlobPreviewUrl,273 revokeBlobPreviewUrl,
273 revokeUserMessagePreviewUrls,274 revokeUserMessagePreviewUrls,
275 startNewThreadForProject,
274 waitForStartedServerThread,276 waitForStartedServerThread,
275} from "./ChatView.logic";277} from "./ChatView.logic";
276import { useLocalStorage } from "~/hooks/useLocalStorage";278import { useLocalStorage } from "~/hooks/useLocalStorage";
...@@ -1130,6 +1132,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1130,6 +1132,7 @@ function ChatViewContent(props: ChatViewProps) {
1130 forceExpandedMobileComposer = false,1132 forceExpandedMobileComposer = false,
1131 } = props;1133 } = props;
1132 const draftId = routeKind === "draft" ? props.draftId : null;1134 const draftId = routeKind === "draft" ? props.draftId : null;
1135 const handleNewThread = useNewThreadHandler();
1133 const routeThreadRef = useMemo(1136 const routeThreadRef = useMemo(
1134 () => scopeThreadRef(environmentId, threadId),1137 () => scopeThreadRef(environmentId, threadId),
1135 [environmentId, threadId],1138 [environmentId, threadId],
...@@ -1583,6 +1586,9 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1583,6 +1586,9 @@ function ChatViewContent(props: ChatViewProps) {
1583 ? scopeProjectRef(activeThread.environmentId, activeThread.projectId)1586 ? scopeProjectRef(activeThread.environmentId, activeThread.projectId)
1584 : null;1587 : null;
1585 const activeProject = useProject(activeProjectRef);1588 const activeProject = useProject(activeProjectRef);
1589 const handleNewThreadInActiveProject = useCallback(() => {
1590 startNewThreadForProject(activeProjectRef, handleNewThread);
1591 }, [activeProjectRef, handleNewThread]);
1586 const activeEnvironmentShell = useEnvironmentQuery(1592 const activeEnvironmentShell = useEnvironmentQuery(
1587 activeThread ? environmentShell.stateAtom(activeThread.environmentId) : null,1593 activeThread ? environmentShell.stateAtom(activeThread.environmentId) : null,
1588 );1594 );
...@@ -5674,6 +5680,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5674,6 +5680,7 @@ function ChatViewContent(props: ChatViewProps) {
5674 availableEditors={availableEditors}5680 availableEditors={availableEditors}
5675 rightPanelOpen={rightPanelOpen}5681 rightPanelOpen={rightPanelOpen}
5676 gitCwd={gitCwd}5682 gitCwd={gitCwd}
5683 onNewThreadInProject={handleNewThreadInActiveProject}
5677 onRunProjectScript={runProjectScript}5684 onRunProjectScript={runProjectScript}
5678 onAddProjectScript={saveProjectScript}5685 onAddProjectScript={saveProjectScript}
5679 onUpdateProjectScript={updateProjectScript}5686 onUpdateProjectScript={updateProjectScript}
apps/web/src/components/chat/ChatHeader.tsx+22-10
...@@ -34,6 +34,7 @@ interface ChatHeaderProps {...@@ -34,6 +34,7 @@ interface ChatHeaderProps {
34 availableEditors: ReadonlyArray<EditorId>;34 availableEditors: ReadonlyArray<EditorId>;
35 rightPanelOpen: boolean;35 rightPanelOpen: boolean;
36 gitCwd: string | null;36 gitCwd: string | null;
37 onNewThreadInProject: () => void;
37 onRunProjectScript: (script: ProjectScript) => void;38 onRunProjectScript: (script: ProjectScript) => void;
38 onAddProjectScript: (input: NewProjectScriptInput) => Promise<ProjectScriptActionResult>;39 onAddProjectScript: (input: NewProjectScriptInput) => Promise<ProjectScriptActionResult>;
39 onUpdateProjectScript: (40 onUpdateProjectScript: (
...@@ -69,6 +70,7 @@ export const ChatHeader = memo(function ChatHeader({...@@ -69,6 +70,7 @@ export const ChatHeader = memo(function ChatHeader({
69 availableEditors,70 availableEditors,
70 rightPanelOpen,71 rightPanelOpen,
71 gitCwd,72 gitCwd,
73 onNewThreadInProject,
72 onRunProjectScript,74 onRunProjectScript,
73 onAddProjectScript,75 onAddProjectScript,
74 onUpdateProjectScript,76 onUpdateProjectScript,
...@@ -92,16 +94,26 @@ export const ChatHeader = memo(function ChatHeader({...@@ -92,16 +94,26 @@ export const ChatHeader = memo(function ChatHeader({
92 doesn't answer it. */}94 doesn't answer it. */}
93 {activeProjectName ? (95 {activeProjectName ? (
94 <span className="inline-flex shrink-0 items-center gap-2">96 <span className="inline-flex shrink-0 items-center gap-2">
95 <span className="inline-flex min-w-0 items-center gap-1.5">97 <Tooltip>
96 <ProjectFavicon98 <TooltipTrigger
97 environmentId={activeThreadEnvironmentId}99 render={
98 cwd={activeProjectCwd ?? ""}100 <button
99 className="size-3.5"101 type="button"
100 />102 aria-label={`New thread in ${activeProjectName}`}
101 <span className="max-w-40 truncate text-sm font-medium text-muted-foreground">103 onClick={onNewThreadInProject}
102 {activeProjectName}104 className="inline-flex min-w-0 cursor-pointer items-center gap-1.5 rounded-sm text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
103 </span>105 />
104 </span>106 }
107 >
108 <ProjectFavicon
109 environmentId={activeThreadEnvironmentId}
110 cwd={activeProjectCwd ?? ""}
111 className="size-3.5"
112 />
113 <span className="max-w-40 truncate text-sm font-medium">{activeProjectName}</span>
114 </TooltipTrigger>
115 <TooltipPopup side="top">New thread in {activeProjectName}</TooltipPopup>
116 </Tooltip>
105 <span aria-hidden className="text-muted-foreground/40">117 <span aria-hidden className="text-muted-foreground/40">
106 /118 /
107 </span>119 </span>