Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions src/renderer/src/workspace/dispatch/dispatchSelectors.test.ts
Original file line number Diff line number Diff line change
@@ -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 })
})
})
72 changes: 72 additions & 0 deletions src/renderer/src/workspace/dispatch/dispatchSelectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
55 changes: 43 additions & 12 deletions src/renderer/src/workspace/hook/actions/pane.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -232,13 +261,17 @@ export function usePaneActions(
const createDetachedDispatchAgent = useCallback(
async (kind: Exclude<SessionKind, 'terminal'>) => {
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) {
Expand Down Expand Up @@ -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()
Expand Down