diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index d00bd646c1cf..c3d975b63e81 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -89,7 +89,7 @@ import { useRouteResume } from '../session/hooks/use-route-resume' import { useSessionActions } from '../session/hooks/use-session-actions' import { useSessionListActions } from '../session/hooks/use-session-list-actions' import { useSessionStateCache } from '../session/hooks/use-session-state-cache' -import { startWorkspaceSession } from '../session/workspace-session-target' +import { newSessionOpensTab, startWorkspaceSession } from '../session/workspace-session-target' import { useOverlayRouting } from '../shell/hooks/use-overlay-routing' import { useWindowControlsOverlayWidth } from '../shell/hooks/use-window-controls-overlay-width' import { titlebarControlsPosition } from '../shell/titlebar' @@ -484,8 +484,16 @@ export function ContribWiring({ children }: { children: ReactNode }) { // New session anchored to a workspace (sidebar "+" on a project/worktree). // Seeds cwd + branch from the clicked workspace; an explicit worktree path // also drills the sidebar into that project so the new lane is visible. + // Once a chat is loaded the button opens a TAB instead of replacing it — + // see newSessionOpensTab. const startSessionInWorkspace = useCallback( (path: null | string) => { + if (newSessionOpensTab(activeSessionIdRef.current, $selectedStoredSessionId.get())) { + void openNewSessionTile('center', { cwd: path, listed: false }) + + return + } + startWorkspaceSession({ activeSessionIdRef, followActiveSessionCwd, @@ -495,7 +503,7 @@ export function ContribWiring({ children }: { children: ReactNode }) { startFreshSessionDraft }) }, - [activeSessionIdRef, requestGateway, startFreshSessionDraft] + [activeSessionIdRef, openNewSessionTile, requestGateway, startFreshSessionDraft] ) // Composer "branch off into a new worktree": open a fresh session anchored diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 2ef5dfe9aadf..b0202e722a4d 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -476,13 +476,14 @@ export function useSessionActions({ * list (Cursor-style draft tab); it surfaces on the next refresh once the * first message persists a turn. "Open in split" keeps the listed behavior. */ const openNewSessionTile = useCallback( - async (dir: TileDock = 'right', options?: { listed?: boolean }) => { + async (dir: TileDock = 'right', options?: { cwd?: null | string; listed?: boolean }) => { const listed = options?.listed ?? true try { - // Fresh tile → the resolved new-session cwd (project/default), not the - // primary composer's live cwd. - const params = await desktopSessionCreateParams(resolveNewSessionCwd().trim()) + // Fresh tile → the caller's workspace when one was named (the sidebar + // "+" on a project/worktree lane), else the resolved new-session cwd + // (project/default) — never the primary composer's live cwd. + const params = await desktopSessionCreateParams((options?.cwd || resolveNewSessionCwd()).trim()) const created = await requestGateway('session.create', params) const stored = created.stored_session_id diff --git a/apps/desktop/src/app/session/workspace-session-target.test.ts b/apps/desktop/src/app/session/workspace-session-target.test.ts index 3a83f605a321..614227585c05 100644 --- a/apps/desktop/src/app/session/workspace-session-target.test.ts +++ b/apps/desktop/src/app/session/workspace-session-target.test.ts @@ -9,7 +9,7 @@ import { setNewChatWorkspaceTarget } from '@/store/session' -import { startWorkspaceSession } from './workspace-session-target' +import { newSessionOpensTab, startWorkspaceSession } from './workspace-session-target' function deferred() { let resolve!: (value: T) => void @@ -21,6 +21,29 @@ function deferred() { return { promise, resolve } } +/** + * The sidebar "+" is a create affordance, not a discard one: with a chat + * already loaded it must stack a tab (⌘T) rather than replace the surface + * (⌘N), which would throw away a conversation that may still be mid-turn. + */ +describe('newSessionOpensTab', () => { + it('opens a tab once a conversation is on screen', () => { + expect(newSessionOpensTab('runtime-a', 'stored-a')).toBe(true) + }) + + it('opens a tab for a live runtime whose stored id has not landed yet', () => { + expect(newSessionOpensTab('runtime-a', null)).toBe(true) + }) + + it('opens a tab for a selected session still resuming into a runtime', () => { + expect(newSessionOpensTab(null, 'stored-a')).toBe(true) + }) + + it('replaces the empty surface when nothing is open', () => { + expect(newSessionOpensTab(null, null)).toBe(false) + }) +}) + describe('startWorkspaceSession', () => { afterEach(() => { setCurrentBranch('') diff --git a/apps/desktop/src/app/session/workspace-session-target.ts b/apps/desktop/src/app/session/workspace-session-target.ts index da5028502a11..9a87d95c0095 100644 --- a/apps/desktop/src/app/session/workspace-session-target.ts +++ b/apps/desktop/src/app/session/workspace-session-target.ts @@ -17,6 +17,19 @@ interface WorkspaceSessionOptions { startFreshSessionDraft: (options?: { workspaceTarget: string }) => void } +/** + * Should the sidebar "+" open a NEW TAB rather than replace what's on screen? + * + * "+" is a create affordance, never a discard one. Once a conversation is + * loaded, replacing it with a blank draft would throw away a chat that may + * still be mid-turn, so the button stacks a tab beside it (⌘T) instead of + * taking over the surface (⌘N). With nothing open there is no tab worth + * preserving and the cheaper fresh-draft path applies. + */ +export function newSessionOpensTab(activeSessionId: null | string, selectedStoredSessionId: null | string): boolean { + return Boolean(activeSessionId || selectedStoredSessionId) +} + export function startWorkspaceSession({ activeSessionIdRef, followActiveSessionCwd: followCwd = followActiveSessionCwd,