+ {worktrees.map((item, index) => {
const itemDeleteState = getDeleteStateForWorktreeHost(item, deleteStateByWorktreeId)
+ const labelIds = {
+ name: `${targetIdPrefix}-${index}-name`,
+ path: `${targetIdPrefix}-${index}-path`,
+ host: `${targetIdPrefix}-${index}-host`
+ }
+ const showHost = collisionIds.has(item.id)
return (
-
{item.displayName}
-
{item.path}
+
+ {item.displayName}
+
+
+ {item.path}
+
+ {showHost ? (
+
+ {getTargetHostLabel(item, hostLabelById)}
+
+ ) : null}
- {worktree.displayName}
- {worktree.path}
+ if (!worktree) {
+ return null
+ }
+ const labelIds = {
+ name: `${targetIdPrefix}-name`,
+ path: `${targetIdPrefix}-path`,
+ host: `${targetIdPrefix}-host`
+ }
+ const showHost = collisionIds.has(worktree.id)
+ return (
+
+
+ {worktree.displayName}
+
+
+ {worktree.path}
+
+ {showHost ? (
+
+ {getTargetHostLabel(worktree, hostLabelById)}
+
+ ) : null}
- ) : null
+ )
}
diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx
index 77d2443b969..0d81932fb74 100644
--- a/src/renderer/src/components/sidebar/WorktreeList.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeList.tsx
@@ -35,6 +35,7 @@ import { useSidebarWorktreeSelection } from './worktree-list/navigation/use-sele
import { useSidebarWorktreeSortOrder } from './worktree-list/listing/use-sort-order'
import { useVisibleSidebarWorktrees } from './worktree-list/listing/use-visible-worktrees'
import { useWorktreeStatusMutations } from './worktree-list/drag/use-status-mutations'
+import { shouldFiltersHideAllRows } from './sidebar-empty-state-gate'
type WorktreeListProps = {
scrollOffsetRef: React.MutableRefObject
@@ -126,7 +127,9 @@ const WorktreeList = React.memo(function WorktreeList({
workspaceStatuses,
settings,
projectGroups,
- projectGrouping
+ projectGrouping,
+ folderWorkspaces,
+ defaultHostId
})
const visibleScope = useSidebarHostVisibleScope({
filterState,
@@ -233,11 +236,13 @@ const WorktreeList = React.memo(function WorktreeList({
clearFilters
})
- const filtersHideAllRows =
- hasFilters &&
- visibleWorktrees.length === 0 &&
- rowModel.placeholderRepoIds.size === 0 &&
- externalWorktreeCards.importedWorktreesByRepo.size === 0
+ const filtersHideAllRows = shouldFiltersHideAllRows({
+ hasFilters,
+ visibleWorktreeCount: visibleWorktrees.length,
+ visibleFolderWorkspaceCount: visibleScope.visibleFolderWorkspacesForRows.length,
+ placeholderRepoCount: rowModel.placeholderRepoIds.size,
+ importedWorktreeCardCount: externalWorktreeCards.importedWorktreesByRepo.size
+ })
// Why: when active filters hide every row, the Clear Filters empty state must win over Project Group headers.
if (rowModel.rows.length === 0 || filtersHideAllRows) {
return
diff --git a/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts b/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts
index ae8b265a19d..a84b8323d03 100644
--- a/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts
+++ b/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts
@@ -32,6 +32,8 @@ const mocks = vi.hoisted(() => {
}
}),
openModal: vi.fn(),
+ setRightSidebarTab: vi.fn(),
+ setRightSidebarOpen: vi.fn(),
removeWorktree: vi.fn().mockResolvedValue({ ok: true }),
gitStatusByWorktree: {} as Record,
deleteStateByWorktreeId: {} as Record<
@@ -346,6 +348,27 @@ describe('delete worktree flow', () => {
})
})
+ it('opens the diff without re-seeding a shell when View changes is clicked', async () => {
+ mocks.state.settings = { skipDeleteWorktreeConfirm: true }
+ mocks.state.removeWorktree.mockResolvedValueOnce({ ok: false, error: 'changed files' })
+ setWorktrees([{ id: 'wt-1', displayName: 'one' }])
+
+ expect(runWorktreeBatchDelete(['wt-1'])).toBe(true)
+
+ await vi.waitFor(() => expect(showDeleteWorktreeFailureToast).toHaveBeenCalled())
+ const toastOptions = vi.mocked(showDeleteWorktreeFailureToast).mock.calls[0]?.[0]
+ toastOptions?.onViewChanges()
+
+ // Why: the Source Control panel is the surface; seeding a shell would repopulate a
+ // workspace the user is trying to delete and erase its closed-last-terminal tombstone.
+ const { activateAndRevealWorktree } = await import('@/lib/worktree-activation')
+ expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-1', {
+ providesInitialSurface: true
+ })
+ expect(mocks.state.setRightSidebarTab).toHaveBeenCalledWith('source-control')
+ expect(mocks.state.setRightSidebarOpen).toHaveBeenCalledWith(true)
+ })
+
it('does not offer force delete for a locked worktree', async () => {
mocks.state.settings = { skipDeleteWorktreeConfirm: true }
mocks.state.removeWorktree
diff --git a/src/renderer/src/components/sidebar/folder-workspace-host-id.ts b/src/renderer/src/components/sidebar/folder-workspace-host-id.ts
new file mode 100644
index 00000000000..a29a0e9baf5
--- /dev/null
+++ b/src/renderer/src/components/sidebar/folder-workspace-host-id.ts
@@ -0,0 +1,21 @@
+import { toSshExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host'
+import type { FolderWorkspace } from '../../../../shared/folder-workspace-types'
+import type { ProjectGroup } from '../../../../shared/project-group-types'
+
+/**
+ * Which host section a folder workspace's row belongs to.
+ *
+ * Single source of truth: header counting, host bucketing and reveal all resolve
+ * the host through here. Two resolvers that drift is how folder workspaces went
+ * missing from the sidebar in the first place (#15362). Deliberately distinct
+ * from the host *filter* resolver, which also consults executionHostId — this
+ * one must match where the row actually renders.
+ */
+export function getFolderWorkspaceHostId(
+ folderWorkspace: Pick,
+ projectGroup: Pick,
+ defaultHostId: ExecutionHostId
+): ExecutionHostId {
+ const connectionId = folderWorkspace.connectionId ?? projectGroup.connectionId
+ return connectionId ? toSshExecutionHostId(connectionId) : defaultHostId
+}
diff --git a/src/renderer/src/components/sidebar/host-section-folder-workspace-counts.test.ts b/src/renderer/src/components/sidebar/host-section-folder-workspace-counts.test.ts
new file mode 100644
index 00000000000..1d96bd9f7b7
--- /dev/null
+++ b/src/renderer/src/components/sidebar/host-section-folder-workspace-counts.test.ts
@@ -0,0 +1,127 @@
+import { describe, expect, it } from 'vitest'
+import type { ProjectGroup } from '../../../../shared/project-group-types'
+import type { Repo } from '../../../../shared/repo-types'
+import type { Worktree } from '../../../../shared/worktree/types'
+import type { Row } from './worktree-list/grouping/row-types'
+import { addHostSectionRows, type HostSectionOption } from './host-section-rows'
+
+const LOCAL_REPO: Repo = {
+ id: 'local-repo',
+ path: '/local-repo',
+ displayName: 'Local',
+ badgeColor: '#000000',
+ addedAt: 0
+}
+
+const LOCAL_WORKTREE: Worktree = {
+ id: 'local-worktree',
+ repoId: LOCAL_REPO.id,
+ path: '/local-repo/worktree',
+ branch: 'refs/heads/main',
+ head: 'abc123',
+ isBare: false,
+ isMainWorktree: false,
+ linkedIssue: null,
+ linkedPR: null,
+ linkedLinearIssue: null,
+ isArchived: false,
+ comment: '',
+ isUnread: false,
+ isPinned: false,
+ displayName: 'Local worktree',
+ sortOrder: 0,
+ lastActivityAt: 0
+}
+
+const HOSTS: HostSectionOption[] = [
+ { id: 'local', kind: 'local', label: 'Local', detail: 'This computer', health: 'local' },
+ { id: 'ssh:builder', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'available' }
+]
+
+const LOCAL_ROW: Extract = {
+ type: 'item',
+ rowKey: 'local-worktree',
+ sectionKey: 'all',
+ worktree: LOCAL_WORKTREE,
+ repo: LOCAL_REPO,
+ depth: 0,
+ groupDepth: 0,
+ lineageTrail: [],
+ isLastLineageChild: true,
+ lineageChildCount: 0
+}
+
+const PROJECT_GROUP: ProjectGroup = {
+ id: 'group-1',
+ name: 'Remote folder',
+ parentPath: '/srv/project',
+ connectionId: 'builder',
+ parentGroupId: null,
+ createdFrom: 'manual',
+ tabOrder: 0,
+ isCollapsed: false,
+ color: null,
+ createdAt: 1,
+ updatedAt: 1
+}
+
+const FOLDER_ROW: Extract = {
+ type: 'folder-workspace',
+ key: 'folder-workspace:folder-1',
+ folderWorkspace: {
+ id: 'folder-1',
+ projectGroupId: PROJECT_GROUP.id,
+ name: 'Remote folder workspace',
+ folderPath: '/srv/project',
+ connectionId: 'builder',
+ linkedTask: null,
+ comment: '',
+ isArchived: false,
+ isUnread: false,
+ isPinned: false,
+ sortOrder: 0,
+ lastActivityAt: 1,
+ createdAt: 1,
+ updatedAt: 1
+ },
+ projectGroup: PROJECT_GROUP,
+ depth: 0,
+ groupDepth: 0
+}
+
+function hostCounts(rows: readonly Row[]) {
+ return addHostSectionRows({
+ rows,
+ hostOptions: HOSTS,
+ workspaceHostScope: 'all',
+ defaultHostId: 'local'
+ })
+ .filter((row) => row.type === 'host-header')
+ .map(({ hostId, count }) => ({ hostId, count }))
+}
+
+describe('folder workspace host counts', () => {
+ it('counts an expanded folder workspace row', () => {
+ expect(hostCounts([LOCAL_ROW, FOLDER_ROW])).toEqual([
+ { hostId: 'local', count: 1 },
+ { hostId: 'ssh:builder', count: 1 }
+ ])
+ })
+
+ it('counts a collapsed folder-only lane from its header', () => {
+ const collapsedLane: Extract = {
+ type: 'header',
+ key: 'workspace-status:in-progress',
+ label: 'In progress',
+ count: 1,
+ tone: 'text-foreground',
+ hostWorktreeCounts: new Map([['ssh:builder', 1]]),
+ hostWorktreeIds: new Map([['ssh:builder', []]])
+ }
+
+ expect(hostCounts([collapsedLane, LOCAL_ROW])).toEqual([
+ { hostId: 'local', count: 1 },
+ { hostId: 'ssh:builder', count: 1 }
+ ])
+ })
+})
diff --git a/src/renderer/src/components/sidebar/host-section-rows.ts b/src/renderer/src/components/sidebar/host-section-rows.ts
index bbe9100bab1..a9b96eb4d3d 100644
--- a/src/renderer/src/components/sidebar/host-section-rows.ts
+++ b/src/renderer/src/components/sidebar/host-section-rows.ts
@@ -11,10 +11,9 @@ import {
import type { ExecutionHostHealth } from '../../../../shared/execution-host-registry'
import type { RuntimeCompatVerdict } from '../../../../shared/protocol-compat'
import type { SshConnectionStatus } from '../../../../shared/ssh-types'
-import type { FolderWorkspace } from '../../../../shared/folder-workspace-types'
-import type { ProjectGroup } from '../../../../shared/project-group-types'
import type { Repo } from '../../../../shared/repo-types'
import type { Row } from './worktree-list/grouping/row-types'
+import { getFolderWorkspaceHostId } from './folder-workspace-host-id'
export type HostHeaderRow = {
type: 'host-header'
@@ -56,19 +55,6 @@ function getRepoHostId(
return defaultHostId
}
-function getSshHostId(connectionId: string): ExecutionHostId {
- return `ssh:${encodeURIComponent(connectionId)}` as ExecutionHostId
-}
-
-function getFolderWorkspaceHostId(
- folderWorkspace: Pick,
- projectGroup: Pick,
- defaultHostId: ExecutionHostId
-): ExecutionHostId {
- const connectionId = folderWorkspace.connectionId ?? projectGroup.connectionId
- return connectionId ? getSshHostId(connectionId) : defaultHostId
-}
-
function getRowHostId(row: Row, defaultHostId: ExecutionHostId): ExecutionHostId | null {
switch (row.type) {
case 'item':
@@ -95,29 +81,32 @@ function getFallbackHost(hostId: ExecutionHostId): HostSectionOption {
}
}
-function countWorktreeRows(rows: readonly Row[]): number {
+function countWorkspaceRows(rows: readonly Row[]): number {
// Why: a collapsed repo group contributes a header row but no item rows;
// fall back to the header's own count so the host badge doesn't read 0
// while a visibly populated project sits right under it.
let count = 0
const seenWorktreeIds = new Set()
let pendingHeader: Extract | null = null
- let pendingHeaderHadItems = false
+ let pendingHeaderHadWorkspaces = false
const flushHeader = (): void => {
- if (pendingHeader && !pendingHeaderHadItems) {
+ if (pendingHeader && !pendingHeaderHadWorkspaces) {
if (pendingHeader.worktreeIds) {
+ const headerWorktreeIds = new Set(pendingHeader.worktreeIds)
for (const worktreeId of pendingHeader.worktreeIds) {
if (!seenWorktreeIds.has(worktreeId)) {
count += 1
seenWorktreeIds.add(worktreeId)
}
}
+ // Folder workspaces contribute to the header count but have no worktree id.
+ count += Math.max(0, pendingHeader.count - headerWorktreeIds.size)
} else {
count += pendingHeader.count
}
}
pendingHeader = null
- pendingHeaderHadItems = false
+ pendingHeaderHadWorkspaces = false
}
for (const row of rows) {
if (row.type === 'header') {
@@ -130,7 +119,12 @@ function countWorktreeRows(rows: readonly Row[]): number {
count += 1
seenWorktreeIds.add(row.worktree.id)
}
- pendingHeaderHadItems = pendingHeader !== null
+ pendingHeaderHadWorkspaces = pendingHeader !== null
+ continue
+ }
+ if (row.type === 'folder-workspace') {
+ count += 1
+ pendingHeaderHadWorkspaces = pendingHeader !== null
}
}
flushHeader()
@@ -303,7 +297,7 @@ export function addHostSectionRows(args: {
compatibility: host.compatibility,
connectionStatus: host.connectionStatus,
collapsed,
- count: countWorktreeRows(hostRows)
+ count: countWorkspaceRows(hostRows)
})
if (!collapsed) {
result.push(...hostRows)
diff --git a/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts b/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts
index d8872490727..3f844d6f09d 100644
--- a/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts
+++ b/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts
@@ -13,7 +13,12 @@ function viewWorktreeDiff(
worktreeId: string,
executionHostId: WorktreeRemovalTarget['executionHostId']
): void {
- activateAndRevealWorktree(worktreeId, executionHostId ? { executionHostId } : {})
+ // The Source Control panel is the requested surface — don't re-seed a shell in a
+ // workspace the user is trying to delete.
+ activateAndRevealWorktree(worktreeId, {
+ providesInitialSurface: true,
+ ...(executionHostId ? { executionHostId } : {})
+ })
const state = useAppStore.getState()
state.setRightSidebarTab('source-control')
state.setRightSidebarOpen(true)
diff --git a/src/renderer/src/components/sidebar/sidebar-empty-state-gate.test.ts b/src/renderer/src/components/sidebar/sidebar-empty-state-gate.test.ts
new file mode 100644
index 00000000000..bd7504c09ac
--- /dev/null
+++ b/src/renderer/src/components/sidebar/sidebar-empty-state-gate.test.ts
@@ -0,0 +1,60 @@
+import { describe, expect, it } from 'vitest'
+import { shouldFiltersHideAllRows } from './sidebar-empty-state-gate'
+
+const NOTHING_ELSE = {
+ visibleWorktreeCount: 0,
+ placeholderRepoCount: 0,
+ importedWorktreeCardCount: 0
+}
+
+describe('Clear Filters empty state', () => {
+ it('does not replace a folder-only sidebar when a filter is active', () => {
+ // The reported symptom reached by a second path: with any filter active
+ // (host scope counts), a folder-only account saw Clear Filters instead of
+ // its workspaces — under every Group by mode, including Project.
+ expect(
+ shouldFiltersHideAllRows({
+ ...NOTHING_ELSE,
+ hasFilters: true,
+ visibleFolderWorkspaceCount: 1
+ })
+ ).toBe(false)
+ })
+
+ it('still wins when filters hid every row kind', () => {
+ expect(
+ shouldFiltersHideAllRows({
+ ...NOTHING_ELSE,
+ hasFilters: true,
+ visibleFolderWorkspaceCount: 0
+ })
+ ).toBe(true)
+ })
+
+ it('never wins without active filters', () => {
+ expect(
+ shouldFiltersHideAllRows({
+ ...NOTHING_ELSE,
+ hasFilters: false,
+ visibleFolderWorkspaceCount: 0
+ })
+ ).toBe(false)
+ })
+
+ it('defers to worktrees, placeholders and imported cards as before', () => {
+ for (const key of [
+ 'visibleWorktreeCount',
+ 'placeholderRepoCount',
+ 'importedWorktreeCardCount'
+ ] as const) {
+ expect(
+ shouldFiltersHideAllRows({
+ ...NOTHING_ELSE,
+ hasFilters: true,
+ visibleFolderWorkspaceCount: 0,
+ [key]: 1
+ })
+ ).toBe(false)
+ }
+ })
+})
diff --git a/src/renderer/src/components/sidebar/sidebar-empty-state-gate.ts b/src/renderer/src/components/sidebar/sidebar-empty-state-gate.ts
new file mode 100644
index 00000000000..2939cc299a3
--- /dev/null
+++ b/src/renderer/src/components/sidebar/sidebar-empty-state-gate.ts
@@ -0,0 +1,24 @@
+/**
+ * Whether active filters have hidden every sidebar row, so the Clear Filters
+ * empty state should win over any remaining headers.
+ *
+ * Split out of WorktreeList so the row kinds it counts are testable: folder
+ * workspaces were missing from this gate, which meant an account whose only
+ * workspaces were folder workspaces lost them to the empty state as soon as any
+ * filter — including host scope — was active (#15362).
+ */
+export function shouldFiltersHideAllRows(counts: {
+ hasFilters: boolean
+ visibleWorktreeCount: number
+ visibleFolderWorkspaceCount: number
+ placeholderRepoCount: number
+ importedWorktreeCardCount: number
+}): boolean {
+ return (
+ counts.hasFilters &&
+ counts.visibleWorktreeCount === 0 &&
+ counts.visibleFolderWorkspaceCount === 0 &&
+ counts.placeholderRepoCount === 0 &&
+ counts.importedWorktreeCardCount === 0
+ )
+}
diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.folder-workspace-lanes.test.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.folder-workspace-lanes.test.ts
new file mode 100644
index 00000000000..292d6535832
--- /dev/null
+++ b/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.folder-workspace-lanes.test.ts
@@ -0,0 +1,246 @@
+import { describe, expect, it } from 'vitest'
+import { buildRows } from './build-rows'
+import { getFolderWorkspaceLaneKey } from './folder-workspace-lanes'
+import { getPRGroupKey, getPRLaneKey } from './group-keys'
+import type { Row, WorktreeGroupBy } from './row-types'
+import { repo, worktree } from '../../worktree-list-groups-test-fixtures'
+import type { FolderWorkspace } from '../../../../../../shared/folder-workspace-types'
+import type { ProjectGroup } from '../../../../../../shared/project-group-types'
+import type { Repo } from '../../../../../../shared/repo-types'
+import type { ExecutionHostId } from '../../../../../../shared/execution-host'
+
+const GROUP: ProjectGroup = {
+ id: 'group-1',
+ name: 'Multi-repo Group',
+ parentPath: '/tmp/parent',
+ parentGroupId: null,
+ createdFrom: 'folder-scan',
+ tabOrder: 0,
+ isCollapsed: false,
+ color: null,
+ createdAt: 1,
+ updatedAt: 1
+}
+
+function makeFolderWorkspace(overrides: Partial = {}): FolderWorkspace {
+ return {
+ id: 'fw-1',
+ projectGroupId: GROUP.id,
+ name: 'Folder workspace',
+ folderPath: '/tmp/parent',
+ linkedTask: null,
+ comment: '',
+ isArchived: false,
+ isUnread: false,
+ isPinned: false,
+ sortOrder: 1,
+ lastActivityAt: 1,
+ createdAt: 1,
+ updatedAt: 1,
+ workspaceStatus: 'in-progress',
+ ...overrides
+ }
+}
+
+const GROUPED_REPO: Repo = { ...repo, projectGroupId: GROUP.id }
+
+function buildSidebarRows(options: {
+ groupBy: WorktreeGroupBy
+ folderWorkspaces?: readonly FolderWorkspace[]
+ projectGroups?: readonly ProjectGroup[]
+ worktrees?: (typeof worktree)[]
+ collapsedGroups?: Set
+}): Row[] {
+ const worktrees = options.worktrees ?? [worktree]
+ return buildRows(
+ options.groupBy,
+ worktrees,
+ new Map([[GROUPED_REPO.id, GROUPED_REPO]]),
+ null,
+ options.collapsedGroups ?? new Set(),
+ undefined,
+ undefined,
+ 'manual',
+ {},
+ new Map(worktrees.map((entry) => [entry.id, entry])),
+ false,
+ undefined,
+ options.projectGroups ?? [GROUP],
+ new Set(),
+ new Map(),
+ new Map(),
+ [],
+ undefined,
+ options.folderWorkspaces ?? [makeFolderWorkspace()]
+ )
+}
+
+function folderRows(rows: Row[]): Extract[] {
+ return rows.filter(
+ (row): row is Extract => row.type === 'folder-workspace'
+ )
+}
+
+const ALL_GROUP_BY: WorktreeGroupBy[] = ['repo', 'workspace-status', 'pr-status', 'none']
+
+describe('folder workspaces render under every Group by mode', () => {
+ // The three non-repo arms are the acceptance evidence; the repo arm is a
+ // deliberate no-regression guard that also passed before the fix.
+ for (const groupBy of ALL_GROUP_BY) {
+ it(`emits the folder-workspace row when groupBy is ${groupBy}`, () => {
+ expect(folderRows(buildSidebarRows({ groupBy }))).toHaveLength(1)
+ })
+ }
+
+ it('does not duplicate the row under repo grouping', () => {
+ const rows = buildSidebarRows({ groupBy: 'repo' })
+ expect(folderRows(rows).map((row) => row.key)).toEqual(['folder-workspace:fw-1'])
+ })
+})
+
+describe('a folder workspace can be the only member of a lane', () => {
+ it('creates its status lane with no worktrees present', () => {
+ const rows = buildSidebarRows({ groupBy: 'workspace-status', worktrees: [] })
+ expect(folderRows(rows)).toHaveLength(1)
+ const header = rows.find((row) => row.type === 'header')
+ expect(header).toBeDefined()
+ expect(header && 'count' in header ? header.count : null).toBe(1)
+ })
+
+ it('renders in flat mode with no worktrees present', () => {
+ // Pre-fix the whole All section was gated on naturalWorktrees.length > 0,
+ // so a folder-only account saw nothing at all.
+ const rows = buildSidebarRows({ groupBy: 'none', worktrees: [] })
+ expect(folderRows(rows)).toHaveLength(1)
+ })
+})
+
+describe('lane assignment', () => {
+ it('routes to the same PR lane as a worktree with no PR', () => {
+ const laneKey = getFolderWorkspaceLaneKey(
+ { folderWorkspace: makeFolderWorkspace(), projectGroup: GROUP },
+ 'pr-status',
+ []
+ )
+ const noPrWorktreeLane = getPRLaneKey(
+ getPRGroupKey(worktree, new Map([[GROUPED_REPO.id, GROUPED_REPO]]), null)
+ )
+ expect(laneKey).toBe(noPrWorktreeLane)
+ })
+})
+
+describe('ordering', () => {
+ const ordered = [
+ makeFolderWorkspace({ id: 'fw-low', name: 'Low', sortOrder: 1 }),
+ makeFolderWorkspace({ id: 'fw-high', name: 'High', sortOrder: 9 })
+ ]
+
+ // Both emitters are covered: grouped lanes and the separate flat-mode path.
+ for (const groupBy of ['workspace-status', 'none'] as WorktreeGroupBy[]) {
+ it(`orders by manualOrder then sortOrder under ${groupBy}`, () => {
+ const rows = buildSidebarRows({ groupBy, folderWorkspaces: ordered })
+ expect(folderRows(rows).map((row) => row.folderWorkspace.id)).toEqual(['fw-high', 'fw-low'])
+ })
+
+ it(`prefers manualOrder over sortOrder under ${groupBy}`, () => {
+ const rows = buildSidebarRows({
+ groupBy,
+ folderWorkspaces: [
+ makeFolderWorkspace({ id: 'fw-low', name: 'Low', sortOrder: 9, manualOrder: 1 }),
+ makeFolderWorkspace({ id: 'fw-high', name: 'High', sortOrder: 1, manualOrder: 9 })
+ ]
+ })
+ expect(folderRows(rows).map((row) => row.folderWorkspace.id)).toEqual(['fw-high', 'fw-low'])
+ })
+ }
+})
+
+describe('membership is decided once, not per mode', () => {
+ // Negative arm alone is vacuous (zero rows either way pre-fix), so it is
+ // paired with an explicitly non-repo positive arm.
+ it('renders nothing when the owning project group is not visible', () => {
+ const rows = buildSidebarRows({ groupBy: 'workspace-status', projectGroups: [] })
+ expect(folderRows(rows)).toHaveLength(0)
+ })
+
+ it('renders under a non-repo mode when the owning group is visible', () => {
+ const rows = buildSidebarRows({ groupBy: 'workspace-status' })
+ expect(folderRows(rows)).toHaveLength(1)
+ })
+
+ it('keeps archived folder workspaces behaving identically in every mode', () => {
+ const archived = [makeFolderWorkspace({ isArchived: true })]
+ const counts = ALL_GROUP_BY.map(
+ (groupBy) => folderRows(buildSidebarRows({ groupBy, folderWorkspaces: archived })).length
+ )
+ // Parity with today's behaviour: nothing filters folder workspaces by
+ // isArchived, so a mode must not be the thing that hides one.
+ expect(counts).toEqual([1, 1, 1, 1])
+ })
+})
+
+describe('host bookkeeping for lanes containing folder workspaces', () => {
+ const SSH_HOST = 'ssh:target-1' as ExecutionHostId
+
+ it('scopes a collapsed folder-only lane header to its host', () => {
+ const sshGroup: ProjectGroup = { ...GROUP, connectionId: 'target-1' }
+ const rows = buildRows(
+ 'workspace-status',
+ [],
+ new Map(),
+ null,
+ new Set(['workspace-status:in-progress']),
+ undefined,
+ undefined,
+ 'manual',
+ {},
+ new Map(),
+ false,
+ undefined,
+ [sshGroup],
+ new Set(),
+ new Map(),
+ new Map(),
+ [],
+ undefined,
+ [makeFolderWorkspace()]
+ )
+ const header = rows.find((row) => row.type === 'header')
+ expect(header).toBeDefined()
+ const counts = header && 'hostWorktreeCounts' in header ? header.hostWorktreeCounts : undefined
+ // Undefined counts render globally, which is exactly the pre-fix bug.
+ expect(counts).toBeDefined()
+ expect(counts?.get(SSH_HOST)).toBe(1)
+ })
+
+ it('gives a folder-only host an explicit empty id array', () => {
+ const sshGroup: ProjectGroup = { ...GROUP, connectionId: 'target-1' }
+ const rows = buildRows(
+ 'workspace-status',
+ [],
+ new Map(),
+ null,
+ new Set(),
+ undefined,
+ undefined,
+ 'manual',
+ {},
+ new Map(),
+ false,
+ undefined,
+ [sshGroup],
+ new Set(),
+ new Map(),
+ new Map(),
+ [],
+ undefined,
+ [makeFolderWorkspace()]
+ )
+ const header = rows.find((row) => row.type === 'header')
+ const ids = header && 'hostWorktreeIds' in header ? header.hostWorktreeIds : undefined
+ // The key must exist even though folder workspaces contribute no worktree
+ // ids, or the host-section fallback leaks the global id list into it.
+ expect(ids?.has(SSH_HOST)).toBe(true)
+ expect(ids?.get(SSH_HOST)).toEqual([])
+ })
+})
diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.ts
index a5e129a97ad..5d3838f2f62 100644
--- a/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.ts
+++ b/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.ts
@@ -14,8 +14,8 @@ import { ALL_GROUP_KEY, ALL_GROUP_META } from './group-keys'
import { appendOrderedGroups } from './group-sections'
import type { SectionAppendContext } from './group-sections'
import {
- getHostWorktreeCounts,
- getHostWorktreeIds,
+ getLaneHostWorktreeCounts,
+ getLaneHostWorktreeIds,
getMixedWorktreeHostContextLabels
} from './host-labels'
import { buildProjectGroupingIndex } from './project-grouping'
@@ -23,7 +23,15 @@ import type { ProjectGroupingModel } from './project-grouping'
import { appendProjectGroupSections } from './project-group-sections'
import { getPinnedSectionWorktrees } from '../../pinned-section-worktrees'
import { emitPinnedGroup } from './pinned-group-rows'
-import { appendWorktreeRows, buildPendingCreationRow } from './row-builders'
+import {
+ appendWorktreeRows,
+ buildFolderWorkspaceRow,
+ buildPendingCreationRow
+} from './row-builders'
+import {
+ compareFolderWorkspacesForDisplay,
+ getRenderableFolderWorkspaces
+} from './folder-workspace-lanes'
import { getPinnedWorktreeDisplayPolicy } from './row-types'
import type {
ImportedWorktreesCardCandidate,
@@ -65,6 +73,9 @@ export function buildRows(
): Row[] {
const result: Row[] = []
const projectIndex = buildProjectGroupingIndex(projectGrouping)
+ // Membership is decided once, above the groupBy switch: every mode renders the
+ // same set of folder workspaces and only chooses where they land (#15362).
+ const renderableFolderWorkspaces = getRenderableFolderWorkspaces(folderWorkspaces, projectGroups)
const cyclicLineageIds = nestLineage
? getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap)
: new Set()
@@ -124,16 +135,28 @@ export function buildRows(
cyclicLineageIds
)
if (groupBy === 'none') {
- if (naturalWorktrees.length > 0) {
+ // Why folder workspaces gate this too: an account with only folder
+ // workspaces rendered nothing at all in flat mode before (#15362).
+ if (naturalWorktrees.length > 0 || renderableFolderWorkspaces.length > 0) {
result.push({
type: 'header',
key: ALL_GROUP_KEY,
label: ALL_GROUP_META.label,
- count: naturalWorktrees.length,
+ count: naturalWorktrees.length + renderableFolderWorkspaces.length,
tone: ALL_GROUP_META.tone,
icon: ALL_GROUP_META.icon,
- hostWorktreeCounts: getHostWorktreeCounts(naturalWorktrees, repoMap, defaultHostId),
- hostWorktreeIds: getHostWorktreeIds(naturalWorktrees, repoMap, defaultHostId),
+ hostWorktreeCounts: getLaneHostWorktreeCounts(
+ naturalWorktrees,
+ renderableFolderWorkspaces,
+ repoMap,
+ defaultHostId
+ ),
+ hostWorktreeIds: getLaneHostWorktreeIds(
+ naturalWorktrees,
+ renderableFolderWorkspaces,
+ repoMap,
+ defaultHostId
+ ),
worktreeIds: naturalWorktrees.map((worktree) => worktree.id)
})
if (!collapsedGroups.has(ALL_GROUP_KEY)) {
@@ -145,6 +168,11 @@ export function buildRows(
hostContextLabelByWorktreeIdentity: mixedWorktreeHostContextLabels,
cyclicLineageIds
})
+ for (const pair of [...renderableFolderWorkspaces].sort((left, right) =>
+ compareFolderWorkspacesForDisplay(left.folderWorkspace, right.folderWorkspace)
+ )) {
+ result.push(buildFolderWorkspaceRow(pair, 0))
+ }
}
}
return result
@@ -163,7 +191,8 @@ export function buildRows(
newExternalWorktreesInboxByRepo,
pendingByRepo,
repoOrder,
- projectOrderBy
+ projectOrderBy,
+ folderWorkspaces: renderableFolderWorkspaces
})
const sectionContext: SectionAppendContext = {
@@ -196,7 +225,7 @@ export function buildRows(
appendProjectGroupSections(sectionContext, {
orderedGroups,
projectGroups,
- folderWorkspaces,
+ folderWorkspaces: renderableFolderWorkspaces,
projectOrderBy,
repoOrder
})
diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/folder-workspace-lanes.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/folder-workspace-lanes.ts
new file mode 100644
index 00000000000..706c05c5ed6
--- /dev/null
+++ b/src/renderer/src/components/sidebar/worktree-list/grouping/folder-workspace-lanes.ts
@@ -0,0 +1,77 @@
+import type { FolderWorkspace } from '../../../../../../shared/folder-workspace-types'
+import type { ProjectGroup } from '../../../../../../shared/project-group-types'
+import type { WorkspaceStatusDefinition } from '../../../../../../shared/worktree/types'
+import {
+ getWorkspaceStatus,
+ getWorkspaceStatusGroupKey
+} from '../../../../../../shared/workspace-statuses'
+import { ALL_GROUP_KEY, getPRLaneKey } from './group-keys'
+import type { WorktreeGroupBy } from './row-types'
+
+/** A folder workspace paired with the project group that owns it. The pair is
+ * carried through grouping because FolderWorkspaceRow needs a non-optional
+ * ProjectGroup and the section emitters have no owner resolver. */
+export type RenderableFolderWorkspace = {
+ folderWorkspace: FolderWorkspace
+ projectGroup: ProjectGroup
+}
+
+/**
+ * The single place that decides which folder workspaces can render.
+ *
+ * Why one place: membership used to be computed inside the repo-grouping branch,
+ * so every other Group by mode dropped folder workspaces entirely (#15362).
+ * Modes may choose a lane; they may never subtract from this list.
+ */
+export function getRenderableFolderWorkspaces(
+ folderWorkspaces: readonly FolderWorkspace[],
+ projectGroups: readonly ProjectGroup[]
+): RenderableFolderWorkspace[] {
+ const projectGroupsById = new Map(projectGroups.map((group) => [group.id, group]))
+ const renderable: RenderableFolderWorkspace[] = []
+ for (const folderWorkspace of folderWorkspaces) {
+ const projectGroup = projectGroupsById.get(folderWorkspace.projectGroupId)
+ // A group filtered out for host visibility legitimately hides its workspaces.
+ if (!projectGroup?.parentPath) {
+ continue
+ }
+ renderable.push({ folderWorkspace, projectGroup })
+ }
+ return renderable
+}
+
+/**
+ * Which lane a folder workspace belongs to, per Group by mode.
+ *
+ * Deliberately exhaustive with no `default:` so a new WorktreeGroupBy variant is
+ * a compile error here rather than a silent fall-through that hides folder
+ * workspaces again. Total by construction: a folder workspace always gets a lane.
+ */
+export function getFolderWorkspaceLaneKey(
+ pair: RenderableFolderWorkspace,
+ groupBy: Exclude,
+ workspaceStatuses: readonly WorkspaceStatusDefinition[]
+): string {
+ switch (groupBy) {
+ case 'workspace-status':
+ return getWorkspaceStatusGroupKey(getWorkspaceStatus(pair.folderWorkspace, workspaceStatuses))
+ case 'pr-status':
+ // Why in-progress: a folder workspace has no branch or repo, so it can
+ // never resolve a PR. getPRGroupKey returns this same lane for any
+ // worktree without one, so the two stay consistent.
+ return getPRLaneKey('in-progress')
+ case 'none':
+ return ALL_GROUP_KEY
+ }
+}
+
+/** Sidebar display order: user-authored order first, then name. Mirrors the rule
+ * the project-group emitter has always used, so lanes and groups agree. */
+export function compareFolderWorkspacesForDisplay(
+ left: FolderWorkspace,
+ right: FolderWorkspace
+): number {
+ const leftOrder = left.manualOrder ?? left.sortOrder
+ const rightOrder = right.manualOrder ?? right.sortOrder
+ return rightOrder - leftOrder || left.name.localeCompare(right.name)
+}
diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/group-keys.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/group-keys.ts
index 3266033408a..76d0e88ebfc 100644
--- a/src/renderer/src/components/sidebar/worktree-list/grouping/group-keys.ts
+++ b/src/renderer/src/components/sidebar/worktree-list/grouping/group-keys.ts
@@ -21,6 +21,12 @@ export type PRGroupKey = 'done' | 'in-review' | 'in-progress' | 'closed'
export const PR_GROUP_ORDER: PRGroupKey[] = ['done', 'in-review', 'in-progress', 'closed']
+/** Section key for a PR lane. Shared so worktree and folder-workspace bucketing
+ * cannot drift onto different prefixes. */
+export function getPRLaneKey(prGroup: PRGroupKey): string {
+ return `pr:${prGroup}`
+}
+
export const PR_GROUP_META: Record<
PRGroupKey,
{
diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/group-sections.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/group-sections.ts
index 15bd81ace63..e8ba7fe47f2 100644
--- a/src/renderer/src/components/sidebar/worktree-list/grouping/group-sections.ts
+++ b/src/renderer/src/components/sidebar/worktree-list/grouping/group-sections.ts
@@ -8,10 +8,15 @@ import {
} from '../../workspace-status'
import { PROJECT_GROUP_META, PR_GROUP_META } from './group-keys'
import type { PRGroupKey } from './group-keys'
-import { getHostWorktreeCounts, getHostWorktreeIds, getMixedHostContextLabels } from './host-labels'
+import {
+ getLaneHostWorktreeCounts,
+ getLaneHostWorktreeIds,
+ getMixedHostContextLabels
+} from './host-labels'
import type { OrderedGroupEntry, ProjectGroupingIndex } from './project-grouping'
import {
appendWorktreeRows,
+ buildFolderWorkspaceRow,
buildImportedWorktreesCardRow,
buildNewExternalWorktreesInboxRow,
buildPendingCreationRow
@@ -71,6 +76,7 @@ export function appendOrderedGroups(
for (const [key, group] of groupsToAppend) {
const isCollapsed = collapsedGroups.has(key)
const repo = group.repo
+ const folderPairs = group.folderWorkspaces ?? []
const header =
groupBy === 'repo'
? {
@@ -95,11 +101,21 @@ export function appendOrderedGroups(
type: 'header' as const,
key,
label: definition?.label ?? workspaceStatus,
- count: group.items.length,
+ count: group.items.length + folderPairs.length,
tone: meta.tone,
icon: meta.icon,
- hostWorktreeCounts: getHostWorktreeCounts(group.items, repoMap, defaultHostId),
- hostWorktreeIds: getHostWorktreeIds(group.items, repoMap, defaultHostId),
+ hostWorktreeCounts: getLaneHostWorktreeCounts(
+ group.items,
+ folderPairs,
+ repoMap,
+ defaultHostId
+ ),
+ hostWorktreeIds: getLaneHostWorktreeIds(
+ group.items,
+ folderPairs,
+ repoMap,
+ defaultHostId
+ ),
worktreeIds: group.items.map((worktree) => worktree.id)
}
})()
@@ -110,11 +126,21 @@ export function appendOrderedGroups(
type: 'header' as const,
key,
label: meta.label,
- count: group.items.length,
+ count: group.items.length + folderPairs.length,
tone: meta.tone,
icon: meta.icon,
- hostWorktreeCounts: getHostWorktreeCounts(group.items, repoMap, defaultHostId),
- hostWorktreeIds: getHostWorktreeIds(group.items, repoMap, defaultHostId),
+ hostWorktreeCounts: getLaneHostWorktreeCounts(
+ group.items,
+ folderPairs,
+ repoMap,
+ defaultHostId
+ ),
+ hostWorktreeIds: getLaneHostWorktreeIds(
+ group.items,
+ folderPairs,
+ repoMap,
+ defaultHostId
+ ),
worktreeIds: group.items.map((worktree) => worktree.id)
}
})()
@@ -171,6 +197,9 @@ export function appendOrderedGroups(
hostContextLabelByWorktreeIdentity,
cyclicLineageIds
})
+ for (const pair of folderPairs) {
+ result.push(buildFolderWorkspaceRow(pair, projectGroupDepth))
+ }
}
}
}
diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/host-labels.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/host-labels.ts
index 6b077e0a07c..a2949d0f806 100644
--- a/src/renderer/src/components/sidebar/worktree-list/grouping/host-labels.ts
+++ b/src/renderer/src/components/sidebar/worktree-list/grouping/host-labels.ts
@@ -8,6 +8,8 @@ import {
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
import { getWorktreeHostIdentity } from '../../../../../../shared/worktree/host-qualified-identity'
import type { ProjectGroupingIndex, WorktreeGroupEntry } from './project-grouping'
+import { getFolderWorkspaceHostId } from '../../folder-workspace-host-id'
+import type { RenderableFolderWorkspace } from './folder-workspace-lanes'
function getRepoHostLabel(
repoId: string,
@@ -111,3 +113,55 @@ export function getHostWorktreeIds(
}
return idsByHost
}
+
+/**
+ * Host counts for a lane that may contain folder workspaces as well as worktrees.
+ *
+ * Why not getHostWorktreeCounts alone: it returns undefined for an empty
+ * worktree list, and a header with undefined counts is treated as global. A lane
+ * whose only members are folder workspaces would therefore render outside its
+ * host section (#15362).
+ */
+export function getLaneHostWorktreeCounts(
+ worktrees: readonly Worktree[],
+ folderWorkspaces: readonly RenderableFolderWorkspace[],
+ repoMap: Map,
+ defaultHostId: ExecutionHostId
+): Map | undefined {
+ if (worktrees.length === 0 && folderWorkspaces.length === 0) {
+ return undefined
+ }
+ const counts = getHostWorktreeCounts(worktrees, repoMap, defaultHostId) ?? new Map()
+ for (const { folderWorkspace, projectGroup } of folderWorkspaces) {
+ const hostId = getFolderWorkspaceHostId(folderWorkspace, projectGroup, defaultHostId)
+ counts.set(hostId, (counts.get(hostId) ?? 0) + 1)
+ }
+ return counts
+}
+
+/**
+ * Host-scoped worktree ids for a lane that may contain folder workspaces.
+ *
+ * Folder workspaces are not worktrees, so they contribute no ids — but their
+ * host must still get an explicit empty array, or the host-section fallbacks
+ * treat the missing key as "unscoped" and leak the global worktree ids into
+ * that section.
+ */
+export function getLaneHostWorktreeIds(
+ worktrees: readonly Worktree[],
+ folderWorkspaces: readonly RenderableFolderWorkspace[],
+ repoMap: Map,
+ defaultHostId: ExecutionHostId
+): Map | undefined {
+ if (worktrees.length === 0 && folderWorkspaces.length === 0) {
+ return undefined
+ }
+ const idsByHost = getHostWorktreeIds(worktrees, repoMap, defaultHostId) ?? new Map()
+ for (const { folderWorkspace, projectGroup } of folderWorkspaces) {
+ const hostId = getFolderWorkspaceHostId(folderWorkspace, projectGroup, defaultHostId)
+ if (!idsByHost.has(hostId)) {
+ idsByHost.set(hostId, [])
+ }
+ }
+ return idsByHost
+}
diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/project-group-sections.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/project-group-sections.ts
index da42befc634..525829ae282 100644
--- a/src/renderer/src/components/sidebar/worktree-list/grouping/project-group-sections.ts
+++ b/src/renderer/src/components/sidebar/worktree-list/grouping/project-group-sections.ts
@@ -1,4 +1,7 @@
-import type { FolderWorkspace } from '../../../../../../shared/folder-workspace-types'
+import {
+ compareFolderWorkspacesForDisplay,
+ type RenderableFolderWorkspace
+} from './folder-workspace-lanes'
import type { ProjectGroup } from '../../../../../../shared/project-group-types'
import type { ProjectOrderBy } from '../../../../../../shared/ui-chrome-types'
import { getEffectiveProjectGroupManualRank } from '../../../../../../shared/project-groups'
@@ -11,13 +14,14 @@ import {
recentRankForEntry,
withRepoSectionDisplayLabels
} from './section-order'
+import { buildFolderWorkspaceRow } from './row-builders'
export function appendProjectGroupSections(
ctx: SectionAppendContext,
args: {
orderedGroups: OrderedGroupEntry[]
projectGroups: readonly ProjectGroup[]
- folderWorkspaces: readonly FolderWorkspace[]
+ folderWorkspaces: readonly RenderableFolderWorkspace[]
projectOrderBy: ProjectOrderBy
repoOrder: Map | undefined
}
@@ -51,22 +55,19 @@ export function appendProjectGroupSections(
}
const projectGroupsById = new Map(projectGroups.map((group) => [group.id, group]))
- const folderWorkspacesByProjectGroupId = new Map()
- for (const workspace of folderWorkspaces) {
- const group = projectGroupsById.get(workspace.projectGroupId)
- if (!group?.parentPath) {
- continue
- }
- const list = folderWorkspacesByProjectGroupId.get(workspace.projectGroupId) ?? []
- list.push(workspace)
- folderWorkspacesByProjectGroupId.set(workspace.projectGroupId, list)
+ // Membership already decided by getRenderableFolderWorkspaces in buildRows, so
+ // repo grouping no longer owns the filter — it only groups and orders (#15362).
+ const folderWorkspacesByProjectGroupId = new Map()
+ for (const pair of folderWorkspaces) {
+ const groupId = pair.folderWorkspace.projectGroupId
+ const list = folderWorkspacesByProjectGroupId.get(groupId) ?? []
+ list.push(pair)
+ folderWorkspacesByProjectGroupId.set(groupId, list)
}
for (const list of folderWorkspacesByProjectGroupId.values()) {
- list.sort((left, right) => {
- const leftOrder = left.manualOrder ?? left.sortOrder
- const rightOrder = right.manualOrder ?? right.sortOrder
- return rightOrder - leftOrder || left.name.localeCompare(right.name)
- })
+ list.sort((left, right) =>
+ compareFolderWorkspacesForDisplay(left.folderWorkspace, right.folderWorkspace)
+ )
}
const childGroupsByParentId = new Map()
for (const group of projectGroups) {
@@ -107,15 +108,8 @@ export function appendProjectGroupSections(
projectGroupDepth: depth
})
if (!collapsedGroups.has(key)) {
- for (const folderWorkspace of folderWorkspacesByProjectGroupId.get(projectGroup.id) ?? []) {
- result.push({
- type: 'folder-workspace',
- key: `folder-workspace:${folderWorkspace.id}`,
- folderWorkspace,
- projectGroup,
- depth: 0,
- groupDepth: depth + 1
- })
+ for (const pair of folderWorkspacesByProjectGroupId.get(projectGroup.id) ?? []) {
+ result.push(buildFolderWorkspaceRow(pair, depth + 1))
}
appendOrderedGroups(ctx, withRepoSectionDisplayLabels(repoEntries), depth + 1)
for (const childGroup of childGroups) {
diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/project-grouping.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/project-grouping.ts
index 961a0c61d88..3135a73b986 100644
--- a/src/renderer/src/components/sidebar/worktree-list/grouping/project-grouping.ts
+++ b/src/renderer/src/components/sidebar/worktree-list/grouping/project-grouping.ts
@@ -1,6 +1,7 @@
import type { Project, ProjectHostSetup } from '../../../../../../shared/project-types'
import type { Repo } from '../../../../../../shared/repo-types'
import type { Worktree } from '../../../../../../shared/worktree/types'
+import type { RenderableFolderWorkspace } from './folder-workspace-lanes'
import { toSshExecutionHostId } from '../../../../../../shared/execution-host'
import { parseWslUncPath } from '../../../../../../shared/wsl-paths'
import {
@@ -20,6 +21,9 @@ export type WorktreeGroupEntry = {
items: Worktree[]
repo?: Repo
repoIds: Set
+ /** Folder workspaces bucketed into this lane under non-repo grouping. Carries
+ * the owning group because FolderWorkspaceRow requires a non-optional one. */
+ folderWorkspaces?: RenderableFolderWorkspace[]
}
export type ProjectGroupingIndex = {
diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/row-builders.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/row-builders.ts
index 38991fb8d81..5359d216ea5 100644
--- a/src/renderer/src/components/sidebar/worktree-list/grouping/row-builders.ts
+++ b/src/renderer/src/components/sidebar/worktree-list/grouping/row-builders.ts
@@ -5,7 +5,9 @@ import { getWorktreeHostIdentity } from '../../../../../../shared/worktree/host-
import { isValidResolvedWorktreeLineageEdge } from '../../../../../../shared/resolved-worktree-lineage'
import { getProjectedWorktreeLineage } from '../../worktree-lineage-projection'
import { getWorktreeLineageGroupKey } from './group-keys'
+import type { RenderableFolderWorkspace } from './folder-workspace-lanes'
import type {
+ FolderWorkspaceRow,
ImportedWorktreesCardCandidate,
ImportedWorktreesCardRow,
NewExternalWorktreesInboxCandidate,
@@ -240,3 +242,19 @@ export function appendWorktreeRows(
}
}
}
+
+/** The one folder-workspace row constructor, shared by the project-group,
+ * grouped-lane and flat emitters so their rows cannot diverge. */
+export function buildFolderWorkspaceRow(
+ pair: RenderableFolderWorkspace,
+ groupDepth: number
+): FolderWorkspaceRow {
+ return {
+ type: 'folder-workspace',
+ key: `folder-workspace:${pair.folderWorkspace.id}`,
+ folderWorkspace: pair.folderWorkspace,
+ projectGroup: pair.projectGroup,
+ depth: 0,
+ groupDepth
+ }
+}
diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/worktree-grouping.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/worktree-grouping.ts
index 350da045585..b6449acd2a1 100644
--- a/src/renderer/src/components/sidebar/worktree-list/grouping/worktree-grouping.ts
+++ b/src/renderer/src/components/sidebar/worktree-list/grouping/worktree-grouping.ts
@@ -2,8 +2,18 @@ import type { Repo } from '../../../../../../shared/repo-types'
import type { ProjectOrderBy } from '../../../../../../shared/ui-chrome-types'
import type { WorkspaceStatusDefinition, Worktree } from '../../../../../../shared/worktree/types'
import type { AppState } from '../../../../store/types'
-import { getWorkspaceStatus, getWorkspaceStatusGroupKey } from '../../workspace-status'
-import { PR_GROUP_META, PR_GROUP_ORDER, getPRGroupKey } from './group-keys'
+import {
+ getWorkspaceStatus,
+ getWorkspaceStatusFromGroupKey,
+ getWorkspaceStatusGroupKey
+} from '../../workspace-status'
+import {
+ compareFolderWorkspacesForDisplay,
+ getFolderWorkspaceLaneKey,
+ type RenderableFolderWorkspace
+} from './folder-workspace-lanes'
+import { PR_GROUP_META, PR_GROUP_ORDER, getPRGroupKey, getPRLaneKey } from './group-keys'
+import type { PRGroupKey } from './group-keys'
import { addRepoIdToGroup, getProjectGroupingForRepo } from './project-grouping'
import type {
OrderedGroupEntry,
@@ -18,6 +28,22 @@ import type {
} from './row-types'
import { getManualOrderAnchorRepo, sortProjectEntries } from './section-order'
+/** Lane label for a lane a folder workspace opened before any worktree did. */
+function getLaneLabelForKey(
+ key: string,
+ groupBy: Exclude,
+ workspaceStatuses: readonly WorkspaceStatusDefinition[]
+): string {
+ if (groupBy === 'workspace-status') {
+ const status = getWorkspaceStatusFromGroupKey(key, workspaceStatuses)
+ return workspaceStatuses.find((entry) => entry.id === status)?.label ?? status ?? key
+ }
+ if (groupBy === 'pr-status') {
+ return PR_GROUP_META[key.replace(/^pr:/, '') as PRGroupKey].label
+ }
+ return key
+}
+
/** Buckets worktrees into their sections and returns them in render order. */
export function buildOrderedGroups(args: {
groupBy: WorktreeGroupBy
@@ -33,6 +59,7 @@ export function buildOrderedGroups(args: {
pendingByRepo: ReadonlyMap
repoOrder: Map | undefined
projectOrderBy: ProjectOrderBy
+ folderWorkspaces?: readonly RenderableFolderWorkspace[]
}): OrderedGroupEntry[] {
const {
groupBy,
@@ -47,7 +74,8 @@ export function buildOrderedGroups(args: {
newExternalWorktreesInboxByRepo,
pendingByRepo,
repoOrder,
- projectOrderBy
+ projectOrderBy,
+ folderWorkspaces = []
} = args
const grouped = new Map()
@@ -67,7 +95,7 @@ export function buildOrderedGroups(args: {
workspaceStatuses.find((status) => status.id === workspaceStatus)?.label ?? workspaceStatus
} else {
const prGroup = getPRGroupKey(w, repoMap, prCache, settings)
- key = `pr:${prGroup}`
+ key = getPRLaneKey(prGroup)
label = PR_GROUP_META[prGroup].label
}
if (!grouped.has(key)) {
@@ -77,6 +105,30 @@ export function buildOrderedGroups(args: {
group.items.push(w)
addRepoIdToGroup(group, w.repoId)
}
+ // Why: folder workspaces are not worktrees, so they never appear in the loop
+ // above. Bucketing them here — and creating the lane when no worktree opened
+ // one — is what lets a folder workspace be the sole occupant of a lane (#15362).
+ if (groupBy !== 'repo') {
+ for (const pair of folderWorkspaces) {
+ const key = getFolderWorkspaceLaneKey(pair, groupBy, workspaceStatuses)
+ if (!grouped.has(key)) {
+ grouped.set(key, {
+ label: getLaneLabelForKey(key, groupBy, workspaceStatuses),
+ items: [],
+ repo: undefined,
+ repoIds: new Set()
+ })
+ }
+ const group = grouped.get(key)!
+ group.folderWorkspaces ??= []
+ group.folderWorkspaces.push(pair)
+ }
+ for (const group of grouped.values()) {
+ group.folderWorkspaces?.sort((left, right) =>
+ compareFolderWorkspacesForDisplay(left.folderWorkspace, right.folderWorkspace)
+ )
+ }
+ }
if (groupBy === 'repo') {
for (const repoId of placeholderRepoIds) {
const grouping = getProjectGroupingForRepo(repoId, repoMap, projectIndex)
diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-collapsed-groups.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-collapsed-groups.ts
index bc9ef82d046..2c838f00247 100644
--- a/src/renderer/src/components/sidebar/worktree-list/listing/use-collapsed-groups.ts
+++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-collapsed-groups.ts
@@ -8,6 +8,9 @@ import { PINNED_GROUP_KEY, getLineageGroupKey } from '../grouping/group-keys'
import type { PinnedWorktreeDisplayPolicy, WorktreeGroupBy } from '../grouping/row-types'
import type { ProjectGroupingModel } from '../grouping/project-grouping'
import { getGroupKeysForWorktree } from '../grouping/worktree-group-keys'
+import { getFolderWorkspaceRevealGroupKeys } from '../navigation/folder-reveal'
+import type { FolderWorkspace } from '../../../../../../shared/folder-workspace-types'
+import type { ExecutionHostId } from '../../../../../../shared/execution-host'
import { isPinnedSectionWorktree } from '../../pinned-section-worktrees'
import { getWorktreeLineageAncestors } from '../../worktree-lineage-projection'
@@ -26,6 +29,8 @@ export function useEffectiveCollapsedGroups(args: {
settings: AppState['settings']
projectGroups: readonly ProjectGroup[]
projectGrouping: ProjectGroupingModel
+ folderWorkspaces: readonly FolderWorkspace[]
+ defaultHostId: ExecutionHostId
}): Set {
const {
collapsedGroups,
@@ -40,7 +45,9 @@ export function useEffectiveCollapsedGroups(args: {
workspaceStatuses,
settings,
projectGroups,
- projectGrouping
+ projectGrouping,
+ folderWorkspaces,
+ defaultHostId
} = args
return useMemo(() => {
if (!agentSendTargetWorktreeId) {
@@ -48,7 +55,22 @@ export function useEffectiveCollapsedGroups(args: {
}
const targetWorktree = worktreeMap.get(agentSendTargetWorktreeId)
if (!targetWorktree) {
- return collapsedGroups
+ // Why: folder workspaces are absent from worktreeMap, so without this the
+ // agent-send picker could never open the section hiding one (#15362).
+ const folderKeys = getFolderWorkspaceRevealGroupKeys(
+ agentSendTargetWorktreeId,
+ folderWorkspaces,
+ projectGroups,
+ { groupBy, workspaceStatuses, defaultHostId }
+ )
+ if (folderKeys.length === 0) {
+ return collapsedGroups
+ }
+ const nextForFolder = new Set(collapsedGroups)
+ for (const groupKey of folderKeys) {
+ nextForFolder.delete(groupKey)
+ }
+ return nextForFolder
}
const next = new Set(collapsedGroups)
if (
@@ -92,6 +114,8 @@ export function useEffectiveCollapsedGroups(args: {
settings,
workspaceStatuses,
worktreeLineageById,
- worktreeMap
+ worktreeMap,
+ folderWorkspaces,
+ defaultHostId
])
}
diff --git a/src/renderer/src/components/sidebar/worktree-list/navigation/folder-reveal.test.ts b/src/renderer/src/components/sidebar/worktree-list/navigation/folder-reveal.test.ts
index bfe5226f889..fd51f5e0fd9 100644
--- a/src/renderer/src/components/sidebar/worktree-list/navigation/folder-reveal.test.ts
+++ b/src/renderer/src/components/sidebar/worktree-list/navigation/folder-reveal.test.ts
@@ -119,3 +119,39 @@ describe('worktree list folder reveal', () => {
).toEqual([getProjectGroupHeaderKey(root.id), getProjectGroupHeaderKey(child.id)])
})
})
+
+describe('reveal keys under non-repo grouping', () => {
+ const group = makeProjectGroup({ id: 'group-child', connectionId: 'target-1' })
+ const folderWorkspace = makeFolderWorkspace({ workspaceStatus: 'in-progress' })
+ const workspaceKey = folderWorkspaceKey(folderWorkspace.id)
+
+ it('returns the status lane key so a collapsed lane can be expanded', () => {
+ // Pre-fix only project-group headers came back, and those do not exist
+ // under status grouping, so the reveal could never expand the lane.
+ const keys = getFolderWorkspaceRevealGroupKeys(workspaceKey, [folderWorkspace], [group], {
+ groupBy: 'workspace-status',
+ workspaceStatuses: [],
+ defaultHostId: 'local'
+ })
+ expect(keys).toContain('workspace-status:in-progress')
+ })
+
+ it('returns the host key so a collapsed host can be expanded too', () => {
+ const keys = getFolderWorkspaceRevealGroupKeys(workspaceKey, [folderWorkspace], [group], {
+ groupBy: 'workspace-status',
+ workspaceStatuses: [],
+ defaultHostId: 'local'
+ })
+ expect(keys).toContain('host:ssh:target-1')
+ })
+
+ it('still returns project-group keys under repo grouping', () => {
+ const keys = getFolderWorkspaceRevealGroupKeys(workspaceKey, [folderWorkspace], [group], {
+ groupBy: 'repo',
+ workspaceStatuses: [],
+ defaultHostId: 'local'
+ })
+ expect(keys).toContain(getProjectGroupHeaderKey(group.id))
+ expect(keys.some((key) => key.startsWith('workspace-status:'))).toBe(false)
+ })
+})
diff --git a/src/renderer/src/components/sidebar/worktree-list/navigation/folder-reveal.ts b/src/renderer/src/components/sidebar/worktree-list/navigation/folder-reveal.ts
index 7b0ac70b463..fd6766e1c4a 100644
--- a/src/renderer/src/components/sidebar/worktree-list/navigation/folder-reveal.ts
+++ b/src/renderer/src/components/sidebar/worktree-list/navigation/folder-reveal.ts
@@ -1,10 +1,13 @@
import type { FolderWorkspace } from '../../../../../../shared/folder-workspace-types'
import type { ProjectGroup } from '../../../../../../shared/project-group-types'
-import type { Worktree } from '../../../../../../shared/worktree/types'
-import type { ExecutionHostId } from '../../../../../../shared/execution-host'
+import type { WorkspaceStatusDefinition, Worktree } from '../../../../../../shared/worktree/types'
import { folderWorkspaceToWorktree } from '../../../../../../shared/folder-workspace-worktree'
import { parseWorkspaceKey } from '../../../../../../shared/workspace-scope'
import { getProjectGroupHeaderKey } from '../grouping/group-keys'
+import type { ExecutionHostId } from '../../../../../../shared/execution-host'
+import { getFolderWorkspaceLaneKey } from '../grouping/folder-workspace-lanes'
+import type { WorktreeGroupBy } from '../grouping/row-types'
+import { getFolderWorkspaceHostId } from '../../folder-workspace-host-id'
function findFolderWorkspaceByKey(
worktreeId: string,
@@ -57,7 +60,12 @@ export function sidebarWorkspaceStillExists(
export function getFolderWorkspaceRevealGroupKeys(
worktreeId: string,
folderWorkspaces: readonly FolderWorkspace[],
- projectGroups: readonly ProjectGroup[]
+ projectGroups: readonly ProjectGroup[],
+ options?: {
+ groupBy?: WorktreeGroupBy
+ workspaceStatuses?: readonly WorkspaceStatusDefinition[]
+ defaultHostId?: ExecutionHostId
+ }
): string[] {
const folderWorkspace = findFolderWorkspaceByKey(worktreeId, folderWorkspaces)
if (!folderWorkspace) {
@@ -77,5 +85,24 @@ export function getFolderWorkspaceRevealGroupKeys(
keys.unshift(getProjectGroupHeaderKey(group.id))
groupId = group.parentGroupId
}
+
+ // Under non-repo grouping the project-group headers above do not exist, so the
+ // lane and host headers are the ones actually hiding the row (#15362). Lane
+ // keys come from the same function grouping uses, so the two cannot disagree.
+ const owningGroup = groupsById.get(folderWorkspace.projectGroupId)
+ if (options?.groupBy && options.groupBy !== 'repo' && owningGroup) {
+ keys.push(
+ getFolderWorkspaceLaneKey(
+ { folderWorkspace, projectGroup: owningGroup },
+ options.groupBy,
+ options.workspaceStatuses ?? []
+ )
+ )
+ }
+ if (owningGroup && options?.defaultHostId) {
+ keys.push(
+ `host:${getFolderWorkspaceHostId(folderWorkspace, owningGroup, options.defaultHostId)}`
+ )
+ }
return keys
}
diff --git a/src/renderer/src/components/sidebar/worktree-list/navigation/pending-reveal-inputs.ts b/src/renderer/src/components/sidebar/worktree-list/navigation/pending-reveal-inputs.ts
index 7e9b5e77824..a0aa9a51520 100644
--- a/src/renderer/src/components/sidebar/worktree-list/navigation/pending-reveal-inputs.ts
+++ b/src/renderer/src/components/sidebar/worktree-list/navigation/pending-reveal-inputs.ts
@@ -60,7 +60,12 @@ export function expandGroupsForWorktreeReveal(
const folderGroupKeys = getFolderWorkspaceRevealGroupKeys(
worktreeId,
args.folderWorkspaces,
- args.projectGroups
+ args.projectGroups,
+ {
+ groupBy: args.groupBy,
+ workspaceStatuses: args.workspaceStatuses,
+ defaultHostId: args.defaultHostId
+ }
)
if (folderGroupKeys.length > 0) {
for (const groupKey of folderGroupKeys) {
diff --git a/src/renderer/src/components/sidebar/worktree-list/navigation/render-row-lookup.folder-workspace.test.ts b/src/renderer/src/components/sidebar/worktree-list/navigation/render-row-lookup.folder-workspace.test.ts
new file mode 100644
index 00000000000..d56020c488b
--- /dev/null
+++ b/src/renderer/src/components/sidebar/worktree-list/navigation/render-row-lookup.folder-workspace.test.ts
@@ -0,0 +1,82 @@
+import { describe, expect, it } from 'vitest'
+import type { FolderWorkspace } from '../../../../../../shared/folder-workspace-types'
+import type { ProjectGroup } from '../../../../../../shared/project-group-types'
+import { folderWorkspaceKey } from '../../../../../../shared/workspace-scope'
+import type { RenderRow } from '../listing/render-row'
+import { findPreferredRenderRowIndexForWorktreeIdentity } from './render-row-lookup'
+
+const FOLDER_WORKSPACE: FolderWorkspace = {
+ id: 'fw-1',
+ projectGroupId: 'group-1',
+ name: 'Folder workspace',
+ folderPath: '/tmp/parent',
+ linkedTask: null,
+ comment: '',
+ isArchived: false,
+ isUnread: false,
+ isPinned: false,
+ sortOrder: 1,
+ lastActivityAt: 1,
+ createdAt: 1,
+ updatedAt: 1
+}
+
+const PROJECT_GROUP: ProjectGroup = {
+ id: 'group-1',
+ name: 'Group',
+ parentPath: '/tmp/parent',
+ parentGroupId: null,
+ createdFrom: 'folder-scan',
+ tabOrder: 0,
+ isCollapsed: false,
+ color: null,
+ createdAt: 1,
+ updatedAt: 1
+}
+
+describe('host-qualified reveal lookup finds folder workspaces', () => {
+ it('returns the folder row index instead of -1', () => {
+ // Reveal requests for the current workspace carry executionHostId, which
+ // routes here. A walker that only knows item rows returned -1 and the
+ // reveal silently never completed (#15362).
+ const rows: RenderRow[] = [
+ {
+ type: 'folder-workspace',
+ key: `folder-workspace:${FOLDER_WORKSPACE.id}`,
+ folderWorkspace: FOLDER_WORKSPACE,
+ projectGroup: PROJECT_GROUP,
+ depth: 0,
+ groupDepth: 0
+ } as RenderRow
+ ]
+
+ const index = findPreferredRenderRowIndexForWorktreeIdentity(
+ rows,
+ { id: folderWorkspaceKey(FOLDER_WORKSPACE.id), hostId: undefined },
+ 'single-location'
+ )
+
+ expect(index).toBe(0)
+ })
+
+ it('does not match a different folder workspace', () => {
+ const rows: RenderRow[] = [
+ {
+ type: 'folder-workspace',
+ key: 'folder-workspace:other',
+ folderWorkspace: { ...FOLDER_WORKSPACE, id: 'other' },
+ projectGroup: PROJECT_GROUP,
+ depth: 0,
+ groupDepth: 0
+ } as RenderRow
+ ]
+
+ expect(
+ findPreferredRenderRowIndexForWorktreeIdentity(
+ rows,
+ { id: folderWorkspaceKey(FOLDER_WORKSPACE.id), hostId: undefined },
+ 'single-location'
+ )
+ ).toBe(-1)
+ })
+})
diff --git a/src/renderer/src/components/sidebar/worktree-list/navigation/render-row-lookup.ts b/src/renderer/src/components/sidebar/worktree-list/navigation/render-row-lookup.ts
index d8900b958c6..9728ff4634e 100644
--- a/src/renderer/src/components/sidebar/worktree-list/navigation/render-row-lookup.ts
+++ b/src/renderer/src/components/sidebar/worktree-list/navigation/render-row-lookup.ts
@@ -109,6 +109,14 @@ export function findPreferredRenderRowIndexForWorktreeIdentity(
let fallbackIndex = -1
for (let index = 0; index < renderRows.length; index++) {
const row = renderRows[index]
+ // Why: host-qualified reveals are emitted for folder workspaces too, and a
+ // walker that only knows item rows returns -1 so the reveal never lands.
+ if (row.type === 'folder-workspace') {
+ if (folderWorkspaceKey(row.folderWorkspace.id) === worktree.id) {
+ return index
+ }
+ continue
+ }
const itemRows = row.type === 'lineage-group' ? row.rows : row.type === 'item' ? [row] : []
const itemRow = itemRows.find(
(candidate) => getWorktreeHostIdentity(candidate.worktree) === identity
diff --git a/src/renderer/src/components/terminal-pane/pty-connection-test-pane-fixtures.ts b/src/renderer/src/components/terminal-pane/pty-connection-test-pane-fixtures.ts
index 3d720043ad8..acca657bfd7 100644
--- a/src/renderer/src/components/terminal-pane/pty-connection-test-pane-fixtures.ts
+++ b/src/renderer/src/components/terminal-pane/pty-connection-test-pane-fixtures.ts
@@ -11,6 +11,7 @@ export function leafIdForPane(paneId: number): string {
export type ConnectCallbacks = {
onReattachDetermined?: () => void
onConnect?: () => void
+ onStreamRecovered?: () => void
onData?: (
data: string,
meta?: { seq?: number; rawLength?: number; background?: boolean; droppedOutput?: boolean }
diff --git a/src/renderer/src/components/terminal-pane/pty-connection-visible-pane-output-pause-latch.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-visible-pane-output-pause-latch.test.ts
new file mode 100644
index 00000000000..8cf68aa4188
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/pty-connection-visible-pane-output-pause-latch.test.ts
@@ -0,0 +1,243 @@
+/**
+ * STA-4593 incident: typing into a visible remote Codex pane produced no
+ * visible output until the user switched workspaces away and back. The host
+ * DROPS output while a stream is paused yet keeps accepting input, and every
+ * hide→show cycle heals the pane with an unconditional snapshot repaint —
+ * which is why the switch "fixed" it and why nothing ever logged an error.
+ *
+ * Invariant under test: a VISIBLE pane must never be left with a paused output
+ * intent. The pause bit must be re-derived from live visibility whenever the
+ * chance to sync it was missed.
+ *
+ * Causal boundary: syncHiddenRendererPtyDelivery issues setOutputPaused only
+ * when canUseHiddenOutputSnapshot(ptyId) — which requires
+ * transport.getPtyId() === ptyId. During a stream rebind getPtyId() is
+ * transiently null, so a reveal that lands in that window is a silent no-op:
+ * desiredOutputPaused stays true, the transport replays it verbatim onto the
+ * recovered stream, the host drops all output, and the stall watchdog never
+ * arms on a paused stream. Nothing re-derives the bit on onConnect or
+ * onStreamRecovered.
+ *
+ * Rig: the hidden-backlog-snapshot suite's mock-transport harness; fake
+ * visibility flips drive the real connectPanePty binding.
+ */
+import type * as React from 'react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { flushAsyncTicks } from './pty-connection-test-async'
+import {
+ createMockTransport,
+ createPane,
+ createManager,
+ type ConnectCallbacks,
+ type MockTransport
+} from './pty-connection-test-pane-fixtures'
+import { buildPaneConnectionDeps } from './pty-connection-test-deps'
+import { createInitialStoreState } from './pty-connection-test-store-fixtures'
+import type { StoreState } from './pty-connection-test-store-state'
+import {
+ installTerminalTestGlobals,
+ restoreTerminalTestGlobals
+} from './pty-connection-test-environment'
+
+const { scheduleRuntimeGraphSync, shouldSeedCacheTimerOnInitialTitle } = vi.hoisted(() => ({
+ scheduleRuntimeGraphSync: vi.fn(),
+ shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false)
+}))
+
+let mockStoreState: StoreState
+let transportFactoryQueue: MockTransport[] = []
+let storeSubscribers: ((state: StoreState) => void)[] = []
+
+vi.mock('@/runtime/sync-runtime-graph', () => ({
+ scheduleRuntimeGraphSync
+}))
+
+vi.mock('@/store', () => ({
+ useAppStore: {
+ getState: () => mockStoreState,
+ subscribe: (listener: (state: StoreState) => void) => {
+ storeSubscribers.push(listener)
+ return () => {
+ storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener)
+ }
+ }
+ }
+}))
+
+vi.mock('@/lib/agent-status', async (importOriginal) => {
+ const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment')
+ return buildAgentStatusModuleMock(await importOriginal