fix(sidebar): keep structured chats visible across workspace surfaces

This commit is contained in:
Brennan Benson
2026-09-21 16:05:23 -07:00
parent 6274bb62bd
commit d6c908bfbf
7 changed files with 96 additions and 7 deletions
@@ -3,6 +3,7 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { cleanup, renderHook } from '@testing-library/react'
import { useAppStore } from '@/store'
import type { Tab } from '../../../../shared/tab-types'
import { getWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity'
import { makeRepo, makeWorktree } from '../worktree-jump-palette-test-fixtures'
import { useVisibleWorkspaceKanbanWorktreeIds } from './use-visible-workspace-kanban-worktree-ids'
@@ -38,4 +39,36 @@ describe('useVisibleWorkspaceKanbanWorktreeIds', () => {
expect(result.current).toEqual(new Set([getWorktreeHostIdentity(local)]))
})
it('keeps a structured-chat workspace visible when sleeping workspaces are hidden', () => {
const worktree = makeWorktree('chat', 'Chat workspace')
const repo = makeRepo()
const structuredTab: Tab = {
id: 'chat-tab',
entityId: 'chat-session',
groupId: 'chat-group',
worktreeId: worktree.id,
contentType: 'agent-session',
agentSessionAgent: 'codex',
label: 'Chat',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 0
}
useAppStore.setState({
worktreesByRepo: { [repo.id]: [worktree] },
unifiedTabsByWorktree: { [worktree.id]: [structuredTab] },
showSleepingWorkspaces: false
})
const { result } = renderHook(() =>
useVisibleWorkspaceKanbanWorktreeIds({
allWorktrees: [worktree],
repoMap: new Map([[repo.id, repo]])
})
)
expect(result.current).toEqual(new Set([getWorktreeHostIdentity(worktree)]))
})
})
@@ -11,6 +11,10 @@ import {
EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT,
getPairedDeviceIdsByEnvironment
} from './workspace-creator-visibility'
import {
EMPTY_STRUCTURED_CHAT_WORKTREE_IDS,
getWorktreeIdsWithStructuredChat
} from './visible-worktree-activity-inputs'
import { getWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity'
type UseVisibleWorkspaceKanbanWorktreeIdsParams = {
@@ -51,6 +55,11 @@ export function useVisibleWorkspaceKanbanWorktreeIds({
const browserTabsByWorktree = useAppStore((s) =>
!showSleepingWorkspaces ? s.browserTabsByWorktree : null
)
const worktreeIdsWithStructuredChat = useAppStore((s) =>
showSleepingWorkspaces
? EMPTY_STRUCTURED_CHAT_WORKTREE_IDS
: getWorktreeIdsWithStructuredChat(s.unifiedTabsByWorktree)
)
const agentStatusEpoch = useAppStore((s) => (!showSleepingWorkspaces ? s.agentStatusEpoch : 0))
// Why: skip the clock entirely when the epoch is the opt-out sentinel, so a
// sleeping-workspaces board cannot evict the sample the live boards share.
@@ -82,6 +91,7 @@ export function useVisibleWorkspaceKanbanWorktreeIds({
ptyIdsByTabId,
browserTabsByWorktree,
worktreeIdsWithLiveAgent,
worktreeIdsWithStructuredChat,
hideDefaultBranchWorkspace,
hideAutomationGeneratedWorkspaces,
hideCliCreatedWorkspaces,
@@ -121,6 +131,7 @@ export function useVisibleWorkspaceKanbanWorktreeIds({
showSleepingWorkspaces,
tabsByWorktree,
worktreeIdsWithLiveAgent,
worktreeIdsWithStructuredChat,
worktreesByRepo
])
}
@@ -36,7 +36,7 @@ export function getVisibleWorktreeBrowserActivityTabs(
return browserProjection.project(browserTabsByWorktree)
}
const EMPTY_WORKTREE_IDS: ReadonlySet<string> = new Set()
export const EMPTY_STRUCTURED_CHAT_WORKTREE_IDS: ReadonlySet<string> = new Set()
const structuredChatWorktreeIds = new WeakMap<Record<string, Tab[]>, ReadonlySet<string>>()
/**
@@ -51,7 +51,7 @@ export function getWorktreeIdsWithStructuredChat(
unifiedTabsByWorktree: Record<string, Tab[]> | null | undefined
): ReadonlySet<string> {
if (!unifiedTabsByWorktree) {
return EMPTY_WORKTREE_IDS
return EMPTY_STRUCTURED_CHAT_WORKTREE_IDS
}
// Keyed on the snapshot, like the tab projection it reads: zustand re-runs every mounted card's
// selector on each store write, and this is a whole-store scan.
@@ -24,7 +24,10 @@ import {
import type { Worktree } from '../../../../shared/worktree/types'
import { buildWorktreeComparator, sortWorktreesSmart } from './smart-sort'
import { isInactiveWorkspace } from '@/lib/worktree-activity-state'
export { getWorktreeIdsWithStructuredChat } from './visible-worktree-activity-inputs'
export {
EMPTY_STRUCTURED_CHAT_WORKTREE_IDS,
getWorktreeIdsWithStructuredChat
} from './visible-worktree-activity-inputs'
// Runtime edge only one way: the builder imports VisibleWorktreeOptions as a type, which erases.
import { buildVisibleWorktreeOptionsFromState } from './visible-worktree-options-from-state'
import { useAppStore } from '@/store'
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, renderHook } from '@testing-library/react'
import { act, cleanup, renderHook } from '@testing-library/react'
import { useAppStore } from '@/store'
import { LOCAL_EXECUTION_HOST_ID } from '../../../../../../shared/execution-host'
import { getWorktreeHostIdentity } from '../../../../../../shared/worktree/host-qualified-identity'
@@ -159,4 +159,39 @@ describe('useVisibleSidebarWorktrees', () => {
rerender(Object.assign({}, withSettings(nextSettings), { defaultHostId: 'runtime:other' }))
expect(computeVisibleWorktreesCalls.count).toBe(callsAfterFirstRender + 1)
})
it('does not rescan visible worktrees for chat-tab writes when sleeping workspaces are shown', () => {
const repo = makeRepo()
const worktree = makeWorktree('alpha', 'Alpha workspace')
useAppStore.setState({ worktreesByRepo: { [repo.id]: [worktree] } })
computeVisibleWorktreesCalls.count = 0
renderHook(() =>
useVisibleSidebarWorktrees({
filterState: {
showSleepingWorkspaces: true,
filterRepoIds: [],
hideDefaultBranchWorkspace: false,
hideAutomationGeneratedWorkspaces: false,
hideCliCreatedWorkspaces: false,
hideDetachedHeadWorkspaces: false,
hideWorkspacesFromOtherDevices: false,
alwaysShowDefaultBranchWorkspace: true,
visibleWorkspaceHostIds: null,
workspaceHostScope: 'all'
},
sortBy: 'recent',
sortedIds: [worktree.id],
repoMap: new Map([[repo.id, repo]]),
worktreeLineageById: {},
defaultHostId: LOCAL_EXECUTION_HOST_ID,
agentSendTargetWorktreeId: null
})
)
const callsAfterFirstRender = computeVisibleWorktreesCalls.count
act(() => useAppStore.setState({ unifiedTabsByWorktree: { [worktree.id]: [] } }))
expect(computeVisibleWorktreesCalls.count).toBe(callsAfterFirstRender)
})
})
@@ -11,6 +11,7 @@ import {
getPairedDeviceIdsByEnvironment
} from '../../workspace-creator-visibility'
import {
EMPTY_STRUCTURED_CHAT_WORKTREE_IDS,
getVisibleWorktreeBrowserActivityTabs,
getVisibleWorktreeTerminalActivityTabs,
getWorktreeIdsWithStructuredChat
@@ -72,7 +73,9 @@ export function useVisibleSidebarWorktrees(args: {
!showSleepingWorkspaces ? getVisibleWorktreeBrowserActivityTabs(s.browserTabsByWorktree) : null
)
const worktreeIdsWithStructuredChat = useAppStore((s) =>
getWorktreeIdsWithStructuredChat(s.unifiedTabsByWorktree)
showSleepingWorkspaces
? EMPTY_STRUCTURED_CHAT_WORKTREE_IDS
: getWorktreeIdsWithStructuredChat(s.unifiedTabsByWorktree)
)
const recomputedVisibleWorktrees = useMemo(() => {
@@ -4,6 +4,7 @@ import {
isCliCreatedWorkspace,
isDetachedHeadWorkspace,
isSleepingSweepExemptWorkspace,
EMPTY_STRUCTURED_CHAT_WORKTREE_IDS,
getWorktreeIdsWithStructuredChat
} from '@/components/sidebar/visible-worktrees'
import { isDefaultBranchWorkspace } from '@/components/sidebar/default-branch-workspace'
@@ -96,6 +97,9 @@ export function useWorktreeJumpPaletteWorktrees({
: EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT,
[hideWorkspacesFromOtherDevices, runtimeEnvironments, runtimeStatusByEnvironmentId]
)
const worktreeIdsWithStructuredChat = showSleepingWorkspaces
? EMPTY_STRUCTURED_CHAT_WORKTREE_IDS
: getWorktreeIdsWithStructuredChat(unifiedTabsByWorktree)
const emptyQueryVisibleWorktrees = useMemo(
() =>
allWorktrees.filter((worktree) => {
@@ -132,7 +136,7 @@ export function useWorktreeJumpPaletteWorktrees({
ptyIdsByTabId,
browserTabsByWorktree,
worktreeIdsWithLiveAgent,
getWorktreeIdsWithStructuredChat(unifiedTabsByWorktree)
worktreeIdsWithStructuredChat
)
) {
return false
@@ -154,7 +158,7 @@ export function useWorktreeJumpPaletteWorktrees({
showSleepingWorkspaces,
tabsByWorktree,
worktreeIdsWithLiveAgent,
unifiedTabsByWorktree
worktreeIdsWithStructuredChat
]
)
const { visibleWorktreesForState, switchableWorktreesForRows } = useMemo(