diff --git a/src/renderer/src/workspace/dispatch/dispatchSelectors.test.ts b/src/renderer/src/workspace/dispatch/dispatchSelectors.test.ts new file mode 100644 index 000000000..a6db2c718 --- /dev/null +++ b/src/renderer/src/workspace/dispatch/dispatchSelectors.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' + +import { resolveDispatchSpawnTarget } from '@renderer/workspace/dispatch/dispatchSelectors' +import type { DispatchModeState, TileNode, WorkspaceState } from '@renderer/workspace/types' + +// Minimal two-project fixture: project A (tabA / a1) and project B (tabB / b1), +// each a single grid agent. Global scope so both tabs show in the dispatch list. +function leaf(sessionId: string): TileNode { + return { type: 'leaf', sessionId } +} + +function makeState(dispatchMode: DispatchModeState | null): WorkspaceState { + return { + tabs: [ + { id: 'tabA', title: 'project-a', root: leaf('a1'), focusedSessionId: 'a1' }, + { id: 'tabB', title: 'project-b', root: leaf('b1'), focusedSessionId: 'b1' }, + ], + activeTabId: 'tabA', + dispatchMode, + sessions: { + a1: { cwd: '/work/project-a', kind: 'claude' }, + b1: { cwd: '/work/project-b', kind: 'claude' }, + }, + detachedSessions: {}, + buried: [], + pinnedSessionIds: [], + } +} + +describe('resolveDispatchSpawnTarget', () => { + it('classic Dispatch: targets the focused session’s own project', () => { + const state = makeState({ scope: 'global', focusedSessionId: 'b1' }) + const target = resolveDispatchSpawnTarget(state) + expect(target).toEqual({ tabId: 'tabB', cwdSessionId: 'b1', laneIndex: null }) + }) + + it('Tiled Dispatch: follows the FOCUSED LANE, not the stale active tab (issue #266)', () => { + // The regression scenario: active tab is A and the classic focus still + // points at A's agent, but the user is commanding lane 1 which shows + // project B. A new agent must land in B, in lane 1 — NOT in active tab A. + const state = makeState({ + scope: 'global', + focusedSessionId: 'a1', + tiled: { + focusedLane: 1, + lanes: [{ selectedSessionId: 'a1' }, { selectedSessionId: 'b1' }], + }, + }) + const target = resolveDispatchSpawnTarget(state) + expect(target).toEqual({ tabId: 'tabB', cwdSessionId: 'b1', laneIndex: 1 }) + }) + + it('Tiled Dispatch: empty focused lane falls back to classic focus but keeps the lane index', () => { + const state = makeState({ + scope: 'global', + focusedSessionId: 'b1', + tiled: { + focusedLane: 1, + lanes: [{ selectedSessionId: 'a1' }, {}], + }, + }) + const target = resolveDispatchSpawnTarget(state) + expect(target).toEqual({ tabId: 'tabB', cwdSessionId: 'b1', laneIndex: 1 }) + }) + + it('no Dispatch mode: targets the active tab', () => { + const target = resolveDispatchSpawnTarget(makeState(null)) + expect(target).toEqual({ tabId: 'tabA', cwdSessionId: null, laneIndex: null }) + }) +}) diff --git a/src/renderer/src/workspace/dispatch/dispatchSelectors.ts b/src/renderer/src/workspace/dispatch/dispatchSelectors.ts index 754f6a99c..b6d3d250f 100644 --- a/src/renderer/src/workspace/dispatch/dispatchSelectors.ts +++ b/src/renderer/src/workspace/dispatch/dispatchSelectors.ts @@ -307,6 +307,78 @@ export function isPinned(state: WorkspaceState, sessionId: SessionId): boolean { return state.pinnedSessionIds.includes(sessionId) } +/** + * Where a NEW Dispatch agent should be created. + * + * - `tabId` — the project tab the agent is filed under + * (becomes detachedSessions[id].projectTabId). + * - `cwdSessionId` — the existing session whose cwd the new agent + * inherits, or null to let the caller fall back to + * the tab's leaves. + * - `laneIndex` — in Tiled Dispatch, the lane the new agent should + * occupy so it appears where the user is looking; + * null in classic Dispatch. + * + * WHY this is a selector instead of inline logic in the spawn actions: + * `createDetachedDispatchAgent` and `splitFocused` both have to answer + * "which project does a new agent belong to?" and they used to read + * cwd from `dispatchMode.focusedSessionId` but the project tab from + * `activeTabId`. Those two fields agree in classic Dispatch (focusing a + * row syncs both via focusDispatchSession) but DIVERGE in Tiled + * Dispatch: lane focus/selection (setTiledFocusedLane / + * setTiledLaneSession) writes only `tiled.focusedLane` and + * `lanes[].selectedSessionId` — never the classic focus fields. The + * result was new agents landing in the stale active tab instead of the + * focused lane's project (issue #266 / #248 regression). Resolving the + * target in one place keeps cwd and projectTab on the SAME project for + * both surfaces. + */ +export type DispatchSpawnTarget = { + tabId: TabId + cwdSessionId: SessionId | null + laneIndex: number | null +} + +export function resolveDispatchSpawnTarget(state: WorkspaceState): DispatchSpawnTarget { + const dm = state.dispatchMode + if (!dm) { + return { tabId: state.activeTabId, cwdSessionId: null, laneIndex: null } + } + + // The visible rows are the scope-correct source of "which tab owns this + // session?" — the same list the user sees and that lane resolution uses. + const rows = buildVisibleDispatchRows(state) + const tabForSession = (id: SessionId | undefined | null): TabId | null => + id ? rows.find(row => row.sessionId === id)?.tabId ?? null : null + + // Tiled Dispatch: the focused lane is the command target. + if (dm.tiled) { + const laneIndex = dm.tiled.focusedLane + const laneSessionId = dm.tiled.lanes[laneIndex]?.selectedSessionId ?? null + const laneTab = tabForSession(laneSessionId) + if (laneTab) { + return { tabId: laneTab, cwdSessionId: laneSessionId, laneIndex } + } + // Focused lane is empty / its agent is gone: there is no project to read + // from the lane itself, so fall back to the classic focus, then the active + // tab — but still place the new agent INTO the focused lane. + const focusTab = tabForSession(dm.focusedSessionId) + return { + tabId: focusTab ?? state.activeTabId, + cwdSessionId: focusTab ? dm.focusedSessionId ?? null : null, + laneIndex, + } + } + + // Classic Dispatch: prefer the focused session's own tab so cwd and + // projectTab stay on the same project even if activeTabId ever drifts. + const focusTab = tabForSession(dm.focusedSessionId) + if (focusTab) { + return { tabId: focusTab, cwdSessionId: dm.focusedSessionId ?? null, laneIndex: null } + } + return { tabId: state.activeTabId, cwdSessionId: null, laneIndex: null } +} + function sessionTitle( meta: WorkspaceState['sessions'][SessionId] | undefined, ): string { diff --git a/src/renderer/src/workspace/hook/actions/pane.ts b/src/renderer/src/workspace/hook/actions/pane.ts index 12a4fbe15..633b49e70 100644 --- a/src/renderer/src/workspace/hook/actions/pane.ts +++ b/src/renderer/src/workspace/hook/actions/pane.ts @@ -28,6 +28,7 @@ import { titleFromCwd } from '@renderer/workspace/layout/helpers' import { buildVisibleDispatchRows, detachedDispatchSessionIdsForTab, + resolveDispatchSpawnTarget, selectVisibleDispatchRow, } from '@renderer/workspace/dispatch/dispatchSelectors' import { commandTargetSessionIdForState } from '@renderer/workspace/hook/selectors/commandTargetSessionId' @@ -55,6 +56,33 @@ import type { SessionActions } from '@renderer/workspace/hook/actions/session' // reviveBuried, killBuried, focusSession, focusSessionInTab, navigate. // ----------------------------------------------------------------------------- +// Update dispatchMode after a new dispatch agent is spawned. In Tiled +// Dispatch the new agent takes over the lane the user is commanding +// (target.laneIndex) so it appears where they were looking; in classic +// Dispatch it becomes the single focus. Setting focusedSessionId in both +// cases keeps classic focus coherent if the user later exits the tiled view. +// The lane index is re-validated here (a stale resolution could outrun a +// concurrent count change), falling back to a plain focus update. +function applyDispatchSpawnFocus( + dispatchMode: DispatchModeState | null, + sessionId: SessionId, + laneIndex: number | null, +): DispatchModeState | null { + if (!dispatchMode) return dispatchMode + const tiled = dispatchMode.tiled + if (laneIndex !== null && tiled && laneIndex >= 0 && laneIndex < tiled.lanes.length) { + const lanes = tiled.lanes.map((lane, i) => + i === laneIndex ? { ...lane, selectedSessionId: sessionId } : lane, + ) + return { + ...dispatchMode, + focusedSessionId: sessionId, + tiled: { ...tiled, lanes, focusedLane: laneIndex }, + } + } + return { ...dispatchMode, focusedSessionId: sessionId } +} + export function usePaneActions( state: { activeTabId: string @@ -117,13 +145,16 @@ export function usePaneActions( ) => { const dispatchSnapshot = refs.stateRef.current if (dispatchSnapshot.dispatchMode && kind !== 'terminal') { - const tab = dispatchSnapshot.tabs.find(t => t.id === dispatchSnapshot.activeTabId) + // Same target resolution as createDetachedDispatchAgent: follow the + // focused lane in Tiled Dispatch so cwd and projectTab agree on the + // project the user is commanding (issue #266 / #248). + const target = resolveDispatchSpawnTarget(dispatchSnapshot) + const tab = dispatchSnapshot.tabs.find(t => t.id === target.tabId) if (!tab) return const leafIds = collectLeaves(tab.root) - const focusedDispatchId = dispatchSnapshot.dispatchMode.focusedSessionId const cwd = - (focusedDispatchId ? dispatchSnapshot.sessions[focusedDispatchId]?.cwd : null) ?? + (target.cwdSessionId ? dispatchSnapshot.sessions[target.cwdSessionId]?.cwd : null) ?? dispatchSnapshot.sessions[tab.focusedSessionId]?.cwd ?? leafIds.map(id => dispatchSnapshot.sessions[id]?.cwd).find(Boolean) if (!cwd) { @@ -172,9 +203,7 @@ export function usePaneActions( detachedAt: Date.now(), }, }, - dispatchMode: prev.dispatchMode - ? { ...prev.dispatchMode, focusedSessionId: sessionId } - : prev.dispatchMode, + dispatchMode: applyDispatchSpawnFocus(prev.dispatchMode, sessionId, target.laneIndex), } }) closeNewAgentPlacement() @@ -232,13 +261,17 @@ export function usePaneActions( const createDetachedDispatchAgent = useCallback( async (kind: Exclude) => { const snapshot = refs.stateRef.current - const tab = snapshot.tabs.find(t => t.id === snapshot.activeTabId) + // Resolve the target project ONCE so cwd and projectTab agree. In Tiled + // Dispatch this follows the focused lane, not the stale active tab — + // reading cwd from focusedSessionId while filing under activeTabId is the + // bug this fixes (issue #266 / #248). See resolveDispatchSpawnTarget. + const target = resolveDispatchSpawnTarget(snapshot) + const tab = snapshot.tabs.find(t => t.id === target.tabId) if (!tab) return const leafIds = collectLeaves(tab.root) - const focusedDispatchId = snapshot.dispatchMode?.focusedSessionId const cwd = - (focusedDispatchId ? snapshot.sessions[focusedDispatchId]?.cwd : null) ?? + (target.cwdSessionId ? snapshot.sessions[target.cwdSessionId]?.cwd : null) ?? snapshot.sessions[tab.focusedSessionId]?.cwd ?? leafIds.map(id => snapshot.sessions[id]?.cwd).find(Boolean) if (!cwd) { @@ -281,9 +314,7 @@ export function usePaneActions( detachedAt: Date.now(), }, }, - dispatchMode: prev.dispatchMode - ? { ...prev.dispatchMode, focusedSessionId: sessionId } - : prev.dispatchMode, + dispatchMode: applyDispatchSpawnFocus(prev.dispatchMode, sessionId, target.laneIndex), } }) closeNewAgentPlacement()