Fix sidebar lineage rollup: unread dot on children, running status on collapsed parent

- A card's unread dot and bold title now include its own visible agent rows'
  unread results, so expanding a parent reveals which child the unread came from.
- A collapsed parent's status lane shows the most urgent descendant state
  (needs you > failed > working) when it beats the parent's own status.
- Host-scoped workspace unread markers and related attention fixes.
This commit is contained in:
Jinjing
2026-11-12 23:46:35 -08:00
parent 6bad9ebd56
commit c20b321d79
17 changed files with 597 additions and 53 deletions
@@ -0,0 +1,83 @@
import { defineFixture } from '../../../../component-fixtures/component-fixture'
import { useAppStore } from '@/store'
import type { Worktree } from '../../../../../../shared/worktree/types'
import { FIXTURE_REPO, makeWorktree } from '../../worktree-card-slot-fixture-seeds'
import WorktreeCard from './WorktreeCard'
const now = Date.now()
const workspace: Worktree = {
...makeWorktree({ id: 'details-row', name: 'deploy-preview', agents: [] }, now),
comment: 'Preview ready for review',
cliProvenance: { kind: 'created-by-cli', createdAt: now },
linkedItems: [
{
provider: 'github',
type: 'issue',
number: 123,
url: 'https://github.com/stablyai/orca/issues/123'
}
]
}
function fixture(detailsRow: boolean, indicators = true) {
return defineFixture({
width: 320,
setup: () =>
useAppStore.setState((state) => ({
repos: [FIXTURE_REPO],
worktreesByRepo: { [FIXTURE_REPO.id]: [workspace] },
settings: state.settings
? {
...state.settings,
experimentalNewWorktreeCardStyle: true,
worktreeCardDetailsRow: detailsRow
}
: null,
worktreeCardProperties: indicators
? ['status', 'branch', 'host', 'issue', 'comment', 'cli', 'ports']
: ['status', 'branch'],
workspacePortScan: {
key: 'details-row',
result: {
platform: 'unknown',
scannedAt: now,
ports: [
{
kind: 'workspace',
id: 'preview',
bindHost: '127.0.0.1',
connectHost: 'localhost',
port: 3000,
protocol: 'http',
owner: {
worktreeId: workspace.id,
repoId: workspace.repoId,
displayName: workspace.displayName,
path: workspace.path,
confidence: 'cwd'
}
}
]
}
}
})),
render: () => (
<WorktreeCard
worktree={workspace}
repo={FIXTURE_REPO}
isActive={false}
hideRepoBadge
hostContextLabel="Build server"
nativeDragEnabled={false}
/>
)
})
}
export const shown = fixture(true)
export const hidden = fixture(false)
export const displayOptionsOff = fixture(false, false)
export const hiddenWithConflict = defineFixture({
...fixture(false),
store: { gitConflictOperationByWorktree: { [workspace.id]: 'cherry-pick' } }
})
@@ -13,6 +13,7 @@ import { FilledBellIcon } from '../identity/worktree-card-glyphs'
import { checksLabel } from '../review/workspace-review-check-label'
import StatusIndicator from './StatusIndicator'
import { useWorktreeActivityStatus } from '../activity/use-worktree-activity-status'
import { laneStatusWithLineage } from './worktree-card-lineage-lane-status'
import { useShallow } from 'zustand/react/shallow'
import { useAppStore } from '@/store/index'
import {
@@ -35,6 +36,8 @@ type WorktreeCardStatusSlotProps = {
showStatus: boolean
showUnreadAction: boolean
isUnread: boolean
/** A collapsed descendant's needs-you, failed or working state; replaces a quieter own status. */
lineageStatus?: WorktreeStatus | null
unreadTooltip: string
onToggleUnread: React.MouseEventHandler<HTMLButtonElement>
onPointerDown: React.PointerEventHandler<HTMLButtonElement>
@@ -126,6 +129,7 @@ export function WorktreeCardStatusSlot({
showStatus,
showUnreadAction,
isUnread,
lineageStatus = null,
unreadTooltip,
onToggleUnread,
onPointerDown,
@@ -140,7 +144,7 @@ export function WorktreeCardStatusSlot({
const t = useTranslate()
const collectionChecks = reviewChecks && reviewChecks.total > 1 ? reviewChecks : undefined
const isReviewStale = reviewStale || Boolean(collectionChecks?.stale)
const status = useWorktreeActivityStatus(worktreeId)
const status = laneStatusWithLineage(useWorktreeActivityStatus(worktreeId), lineageStatus)
// One subscription per card: rows mount by the hundreds, and every listener runs on each store write.
const { isSleeping, vmRuntime, vmWaking } = useAppStore(
useShallow((state) => ({
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { laneStatusWithLineage, lineageLaneStatus } from './worktree-card-lineage-lane-status'
describe('lineageLaneStatus', () => {
it('claims the most urgent loud state and ignores unread', () => {
expect(lineageLaneStatus([{ kind: 'unread', at: 1, manual: false }])).toBeNull()
expect(
lineageLaneStatus([
{ kind: 'unread', at: 1, manual: false },
{ kind: 'working', at: 2 }
])
).toBe('working')
expect(lineageLaneStatus([{ kind: 'working', at: 2, monitoring: true }])).toBe('monitoring')
expect(
lineageLaneStatus([
{ kind: 'needs', count: 1 },
{ kind: 'failed' },
{ kind: 'working', at: 2 }
])
).toBe('permission')
})
})
describe('laneStatusWithLineage', () => {
it('lets a running descendant replace a quiet parent', () => {
expect(laneStatusWithLineage('inactive', 'working')).toBe('working')
expect(laneStatusWithLineage('done', 'permission')).toBe('permission')
expect(laneStatusWithLineage('active', null)).toBe('active')
})
it('keeps the parent when its own state is as urgent or more', () => {
expect(laneStatusWithLineage('permission', 'working')).toBe('permission')
expect(laneStatusWithLineage('working', 'monitoring')).toBe('working')
expect(laneStatusWithLineage('failed', 'failed')).toBe('failed')
})
})
@@ -0,0 +1,43 @@
import type { WorktreeStatus } from '../../../../../../shared/worktree-status-rollup'
import type { SlotException } from '../../worktree-card-slot-signal'
// Only the states loud enough to take over a collapsed parent's lane; unread stays on the bold title.
const LANE_URGENCY: Partial<Record<WorktreeStatus, number>> = {
permission: 0,
failed: 1,
working: 2,
monitoring: 3
}
/** The lane status collapsed descendants would claim: needs you, then failed, then working. */
export function lineageLaneStatus(exceptions: readonly SlotException[]): WorktreeStatus | null {
let best: WorktreeStatus | null = null
for (const exception of exceptions) {
const status: WorktreeStatus | null =
exception.kind === 'needs'
? 'permission'
: exception.kind === 'failed'
? 'failed'
: exception.kind === 'working'
? exception.monitoring
? 'monitoring'
: 'working'
: null
if (status && (best === null || LANE_URGENCY[status]! < LANE_URGENCY[best]!)) {
best = status
}
}
return best
}
/** The parent's own status, unless a collapsed descendant's is more urgent. */
export function laneStatusWithLineage(
own: WorktreeStatus,
lineage: WorktreeStatus | null | undefined
): WorktreeStatus {
if (!lineage) {
return own
}
const ownUrgency = LANE_URGENCY[own] ?? Number.POSITIVE_INFINITY
return LANE_URGENCY[lineage]! < ownUrgency ? lineage : own
}
@@ -4,7 +4,10 @@ import { cn } from '@/lib/class-names'
import { useWslHostOffForRepo } from '../../use-wsl-host-off-review'
import { WorktreeCardStatusSlot } from './WorktreeCardStatusSlot'
import type { WorktreeCardController } from '../controller/use-worktree-card-controller'
import { useWorktreeCardRolledUpUnread } from './worktree-card-title-status'
import {
useWorktreeCardLineageLaneStatus,
useWorktreeCardRolledUpUnread
} from './worktree-card-title-status'
/** The card's leading status lane: activity, review and the unread toggle or dot. */
export function WorktreeCardStatusLane({
@@ -15,8 +18,9 @@ export function WorktreeCardStatusLane({
showUnreadAction: boolean
}): React.JSX.Element {
const reviewStale = useWslHostOffForRepo(card.repo)
// The dot also speaks for folded agents and collapsed child workspaces.
// The dot also speaks for its agents and collapsed child workspaces.
const rolledUpUnread = useWorktreeCardRolledUpUnread()
const lineageStatus = useWorktreeCardLineageLaneStatus()
return (
<div
className={cn(
@@ -31,6 +35,7 @@ export function WorktreeCardStatusLane({
showStatus={card.showStatus}
showUnreadAction={showUnreadAction}
isUnread={card.worktree.isUnread || rolledUpUnread}
lineageStatus={lineageStatus}
unreadTooltip={card.unreadTooltip}
onPointerDown={card.stopQuickActionPointerPropagation}
onToggleUnread={card.handleToggleUnreadQuick}
@@ -4,7 +4,15 @@ import { useShallow } from 'zustand/react/shallow'
import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData'
import { useNow } from '@/hooks/use-now'
import { useAppStore } from '@/store'
import type { HostScopedWorktreeKey } from '../../../../../../shared/worktree/host-qualified-identity'
import type { WorktreeStatus } from '../../../../../../shared/worktree-status-rollup'
import { lineageLaneStatus } from './worktree-card-lineage-lane-status'
import {
composeWorktreeHostIdentity,
type HostScopedWorktreeKey
} from '../../../../../../shared/worktree/host-qualified-identity'
import type { AppState } from '@/store/types'
import type { Worktree } from '../../../../../../shared/worktree/types'
import { worktreeAgentProjectionInputs } from '../activity/worktree-agent-rows-projection'
import { selectAgentRowsUnread } from '../activity/worktree-card-agent-ack-inputs'
import { selectSendTargetControlInputs } from '../activity/worktree-card-send-target-inputs'
import { useWorktreeAgentExpansionState } from '../activity/worktree-card-agents-expansion-state'
@@ -24,6 +32,7 @@ import {
slotTargetsFromAgentLines,
slotTargetsOfKind,
workspaceUnreadTarget,
workspaceUnreadTargetsOutsideAgentRows,
type SlotTarget
} from '../../worktree-card-slot-targets'
import {
@@ -39,6 +48,10 @@ type TitleStatus = {
offline: boolean
/** What the title speaks for: hidden agents, collapsed child workspaces, the workspace's own unread. */
exceptions: readonly SlotException[]
/** Dot and bold: any unread the card holds, including a visible agent line's. */
unread: boolean
/** The lane status collapsed descendants claim (needs you, failed, working); null when quiet. */
lineageStatus: WorktreeStatus | null
at: number | null
resolveTargets: (kind: SlotException['kind']) => SlotTarget[]
}
@@ -51,6 +64,31 @@ function useSlotAgentSignals(agents: DashboardAgentRow[]): SlotAgentSignals {
return useMemo(() => buildSlotAgentSignals(agents, unread), [agents, unread])
}
function createWorkspaceUnreadSelector(worktree: Worktree, visibleAgents: readonly SlotTarget[]) {
const target = workspaceUnreadTarget(
worktree.id,
worktree.lastActivityAt || null,
worktree.hostId
)
let inputs: readonly unknown[] = []
let unread = false
return (state: AppState) => {
const next = [
...worktreeAgentProjectionInputs(state),
state.unreadTerminalTabs,
state.unreadAgentCompletionPanes,
state.workspacesMarkedUnreadByUser
]
if (!next.every((value, index) => value === inputs[index])) {
inputs = next
unread =
worktree.isUnread &&
workspaceUnreadTargetsOutsideAgentRows(state, target, visibleAgents).length > 0
}
return unread
}
}
/**
* Computes the card's rolled-up status once, for the status lane, the title and the title's
* right-hand slot: rows mount by the hundreds, so the three must not each subscribe.
@@ -66,7 +104,8 @@ export function WorktreeCardTitleStatusProvider({
const agents = card.inlineAgentRows
const signals = useSlotAgentSignals(agents)
const offline = card.isRuntimeDisconnected || card.isSshDisconnected
const markedByUser = useAppStore((s) => Boolean(s.workspacesMarkedUnreadByUser[worktree.id]))
const identity = composeWorktreeHostIdentity(worktree.hostId ?? 'local', worktree.id)
const markedByUser = useAppStore((s) => Boolean(s.workspacesMarkedUnreadByUser[identity]))
const pickingSendTarget = useAppStore(
(s) => selectSendTargetControlInputs(s, worktree.id).targetMode !== null
)
@@ -83,9 +122,16 @@ export function WorktreeCardTitleStatusProvider({
const collapsedLineage = useCollapsedLineageSlots(hiddenDescendants)
const lineageLatestAt = useLineageLatestAt(descendants)
const agentUnread = signals.allSignals.some((signal) => signal.kind === 'unread')
// Any unread source counts (a terminal tab, a pane, the user's mark) unless a visible row already shows it.
const workspaceUnread = worktree.isUnread && !(linesShown && agentUnread)
const lastActivityAt = worktree.lastActivityAt > 0 ? worktree.lastActivityAt : null
const visibleAgentTargets = useMemo(
() => (linesShown ? slotTargetsFromAgentLines(worktree.id, signals, worktree.hostId) : []),
[linesShown, signals, worktree.hostId, worktree.id]
)
const selectWorkspaceUnread = useMemo(
() => createWorkspaceUnreadSelector(worktree, visibleAgentTargets),
[visibleAgentTargets, worktree]
)
const workspaceUnread = useAppStore(selectWorkspaceUnread)
const slot = titleRowSlot({
offline,
lines: signals.allSignals,
@@ -99,13 +145,19 @@ export function WorktreeCardTitleStatusProvider({
(kind: SlotException['kind']) => {
const state = useAppStore.getState()
return slotTargetsOfKind(
expandWorkspaceUnreadTargets(state, [
...(linesShown ? [] : slotTargetsFromAgentLines(worktree.id, signals, worktree.hostId)),
...collectLineageSlotTargets(state, hiddenDescendants),
[
...expandWorkspaceUnreadTargets(state, [
...(linesShown ? [] : slotTargetsFromAgentLines(worktree.id, signals, worktree.hostId)),
...collectLineageSlotTargets(state, hiddenDescendants)
]),
...(workspaceUnread
? [workspaceUnreadTarget(worktree.id, lastActivityAt, worktree.hostId)]
? workspaceUnreadTargetsOutsideAgentRows(
state,
workspaceUnreadTarget(worktree.id, lastActivityAt, worktree.hostId),
visibleAgentTargets
)
: [])
]),
],
kind
)
},
@@ -114,6 +166,7 @@ export function WorktreeCardTitleStatusProvider({
lastActivityAt,
linesShown,
signals,
visibleAgentTargets,
workspaceUnread,
worktree.hostId,
worktree.id
@@ -121,17 +174,25 @@ export function WorktreeCardTitleStatusProvider({
)
const exceptions = slot.kind === 'status' ? slot.exceptions : NO_EXCEPTIONS
const at = slot.kind === 'status' ? slot.at : null
// Why: a collapsed parent rolls up its children's unread agent lines, so each child must show
// the same dot even when the line is visible, or expanding hides where the unread came from.
const unread = agentUnread || exceptions.some((exception) => exception.kind === 'unread')
const lineageStatus = lineageLaneStatus(collapsedLineage)
const value = useMemo<TitleStatus>(
() => ({ offline, exceptions, at, resolveTargets }),
[at, exceptions, offline, resolveTargets]
() => ({ offline, exceptions, unread, lineageStatus, at, resolveTargets }),
[at, exceptions, lineageStatus, offline, resolveTargets, unread]
)
return <TitleStatusContext.Provider value={value}>{children}</TitleStatusContext.Provider>
}
/** True while the card speaks for an unread result: its own, a folded agent's or a collapsed child's. */
/** True while the card holds an unread result: its own, any of its agents' or a collapsed child's. */
export function useWorktreeCardRolledUpUnread(): boolean {
const status = useContext(TitleStatusContext)
return status?.exceptions.some((exception) => exception.kind === 'unread') === true
return useContext(TitleStatusContext)?.unread === true
}
/** What collapsed child workspaces claim for the lane, so a running child shows on its parent. */
export function useWorktreeCardLineageLaneStatus(): WorktreeStatus | null {
return useContext(TitleStatusContext)?.lineageStatus ?? null
}
/** The title row's right edge: a pill per loud status the card speaks for, then the time. */
@@ -23,7 +23,7 @@
"ports": "options.show.ports && ports.count > 0",
"indicators": "vars.details || vars.ports",
"metaIndicators": "!vars.compact && vars.metaRow && vars.indicators",
"titleIndicators": "vars.compact && vars.indicators",
"titleIndicators": "!vars.metaRow && vars.indicators",
"hasMetaRow": "vars.metaRow ? vars.metaRepo || vars.hostBadge || vars.folderInMeta || vars.branch || vars.identityInMeta || vars.detachedInMeta || vars.conflict || git.cacheTimerActive || vars.metaIndicators : vars.conflict || git.cacheTimerActive",
"star": "vars.compact && ws.isMain && !vars.folder",
"headerActions": "vars.star || place.deleteQuickAction",
@@ -74,6 +74,11 @@
"when": "ws.isSparse",
"props": { "hasHoverDetails": "=vars.hoverDetails && !ws.isRenaming" }
},
{
"el": "HostBadge",
"when": "vars.newStyle && !vars.metaRow && vars.hostBadge",
"props": { "label": "=vars.hostLabel" }
},
{
"el": "Row",
"when": "vars.titleIndicators",
@@ -130,7 +135,7 @@
{ "el": "MetaRepoBadge", "when": "vars.metaRepo" },
{
"el": "HostBadge",
"when": "vars.hostBadge",
"when": "vars.metaRow && vars.hostBadge",
"props": { "label": "=vars.hostLabel" }
},
{
@@ -19,7 +19,11 @@ import {
selectCollapsedLineageSlots,
selectLineageLatestAt
} from './worktree-card-slot-rollup'
import { slotTargetsOfKind, workspaceUnreadTarget } from './worktree-card-slot-targets'
import {
slotTargetsOfKind,
workspaceUnreadTarget,
workspaceUnreadTargetsOutsideAgentRows
} from './worktree-card-slot-targets'
import { fromLineageSlotRecord } from './worktree-card-slot-summary'
function collapsedSlot(...args: Parameters<typeof selectCollapsedLineageSlots>) {
@@ -61,7 +65,10 @@ beforeEach(() => {
runtimeAgentOrchestrationByPaneKey: {},
paneForegroundAgentByPaneKey: {},
worktreesByRepo: {},
repos: []
repos: [],
unreadTerminalTabs: {},
unreadAgentCompletionPanes: {},
workspacesMarkedUnreadByUser: {}
})
})
afterEach(() => {
@@ -142,6 +149,66 @@ it('expires hidden working evidence on the same freshness tick as agent rows', (
expect(collapsedSlot(useAppStore.getState(), [identity])).toBeNull()
})
it('keeps independent bells and completion panes in title targets alongside a visible unread agent', () => {
useAppStore.setState({
tabsByWorktree: {
child: ['agent', 'shell', 'remote'].map((id) =>
makeTab({
id,
worktreeId: 'child',
ptyId: id === 'remote' ? 'remote:box@@shell' : `pty-${id}`,
title: ''
})
)
},
agentStatusByPaneKey: { [pane('agent')]: entry('agent', { state: 'done' }) },
unreadAgentCompletionPanes: { [pane('agent')]: 'agent-completion' },
unreadTerminalTabs: { shell: 'terminal-bell', remote: 'terminal-bell' }
})
const target = workspaceUnreadTarget('child', 1_950_000, 'local')
const visibleAgents = collectLineageSlotTargets(useAppStore.getState(), [identity])
expect(
workspaceUnreadTargetsOutsideAgentRows(useAppStore.getState(), target, visibleAgents).map(
(target) => target.tabId
)
).toEqual(['shell'])
useAppStore.setState({ unreadTerminalTabs: {} })
expect(
workspaceUnreadTargetsOutsideAgentRows(useAppStore.getState(), target, visibleAgents)
).toEqual([])
const otherPane = 'agent:22222222-2222-4222-8222-222222222222'
useAppStore.setState({
unreadAgentCompletionPanes: {
[pane('agent')]: 'agent-completion',
[otherPane]: 'agent-completion'
}
})
expect(
workspaceUnreadTargetsOutsideAgentRows(useAppStore.getState(), target, visibleAgents).map(
(target) => target.tabId
)
).toEqual(['agent'])
useAppStore.setState({
unreadAgentCompletionPanes: { [pane('agent')]: 'agent-completion' },
workspacesMarkedUnreadByUser: { [identity]: true }
})
expect(
workspaceUnreadTargetsOutsideAgentRows(useAppStore.getState(), target, visibleAgents).map(
(target) => ({ paneKey: target.paneKey, tabId: target.tabId, marked: target.markedByUser })
)
).toEqual([{ paneKey: null, tabId: undefined, marked: true }])
useAppStore.setState({ unreadAgentCompletionPanes: {}, workspacesMarkedUnreadByUser: {} })
expect(
workspaceUnreadTargetsOutsideAgentRows(useAppStore.getState(), target, visibleAgents).map(
(target) => ({ paneKey: target.paneKey, tabId: target.tabId, marked: target.markedByUser })
)
).toEqual([{ paneKey: null, tabId: undefined, marked: false }])
})
it('updates hidden subtree attention after status changes, acknowledgement, and freshness expiry', () => {
const parent = makeWorktree({ id: 'parent', repoId: 'repo1', hostId: 'local', lastActivityAt: 0 })
const child = makeWorktree({ id: 'child', repoId: 'repo1', hostId: 'local', lastActivityAt: 0 })
@@ -6,7 +6,7 @@ import {
fromLineageSlotRecord,
type LineageSlotRecord
} from './worktree-card-slot-summary'
import { useCallback, useMemo } from 'react'
import { useMemo } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { useAppStore } from '@/store'
import type { AppState } from '@/store/types'
@@ -190,8 +190,20 @@ export function selectLineageLatestAt(
return at || null
}
function createLineageLatestAtSelector(source: WorktreeLineageSource) {
let inputs: readonly unknown[] = []
let at: number | null = null
return (state: AppState) => {
const next = worktreeAgentProjectionInputs(state)
if (!next.every((value, index) => value === inputs[index])) {
inputs = next
at = selectLineageLatestAt(state, source)
}
return at
}
}
export function useLineageLatestAt(source: WorktreeLineageSource): number | null {
return useAppStore(
useCallback((state: AppState) => selectLineageLatestAt(state, source), [source])
)
const select = useMemo(() => createLineageLatestAtSelector(source), [source])
return useAppStore(select)
}
@@ -11,6 +11,7 @@ import { activateStructuredAgentSessionTab } from '@/lib/agent-session/structure
import { activateTabAndFocusPane } from '@/lib/workspace/tabs/activate-tab-and-focus-pane'
import { useAppStore } from '@/store'
import type { AppState } from '@/store/types'
import { workspaceMarkerTabIdsForHost } from '@/store/slices/workspace-attention/workspace-marker-host'
import {
paneKeyTabId,
workspaceLiveMarkerTabIds
@@ -31,6 +32,9 @@ export type SlotTarget = SlotLineSignal & {
tabId?: string
/** The user marked the workspace unread; it stands on its own instead of yielding to agents. */
markedByUser?: boolean
/** Completion subjects represented by this tab target; bells remain independent of agent rows. */
unreadPaneKeys?: readonly string[]
terminalBell?: boolean
retained: boolean
agentType: AgentType | null
entry: AgentStatusEntry | null
@@ -92,17 +96,36 @@ export function expandWorkspaceUnreadTargets(
if (target.kind !== 'unread' || target.paneKey !== null || target.tabId !== undefined) {
return [target]
}
const markedByUser = Boolean(state.workspacesMarkedUnreadByUser[target.worktreeId])
const tabTargets = workspaceLiveMarkerTabIds(state, target.worktreeId).map(
(tabId): SlotTarget => ({ ...target, tabId })
const markedByUser = Boolean(
state.workspacesMarkedUnreadByUser[
composeWorktreeHostIdentity(target.executionHostId ?? 'local', target.worktreeId)
]
)
const ownedTabs = workspaceMarkerTabIdsForHost(state, target.worktreeId, target.executionHostId)
const completionKeys = Object.keys(state.unreadAgentCompletionPanes)
const representedTabs = getStructuredAgentSessionTabsByPaneKey(
state.unifiedTabsByWorktree[target.worktreeId]
)
const structuredKeysByTabId = new Map([...representedTabs].map(([key, tab]) => [tab.id, key]))
const tabTargets = workspaceLiveMarkerTabIds(state, target.worktreeId)
.filter((tabId) => ownedTabs.has(tabId))
.map((tabId): SlotTarget => ({
...target,
tabId,
terminalBell: state.unreadTerminalTabs[tabId] === 'terminal-bell',
unreadPaneKeys: completionKeys.filter(
(key) =>
paneKeyTabId(key) === tabId &&
(!structuredKeysByTabId.has(tabId) || structuredKeysByTabId.get(tabId) === key)
)
}))
return tabTargets.length === 0 || markedByUser
? [...tabTargets, { ...target, markedByUser }]
: tabTargets
})
}
function workspaceKey(target: SlotTarget): string {
function workspaceKey(target: SlotTarget) {
return composeWorktreeHostIdentity(target.executionHostId, target.worktreeId)
}
@@ -113,20 +136,27 @@ export function slotTargetsOfKind(
): SlotTarget[] {
const ofKind = targets.filter((target) => target.kind === kind)
const agentTargets = ofKind.filter((target) => target.paneKey !== null)
const withAgent = new Set(agentTargets.map(workspaceKey))
const agentTabs = new Set(
agentTargets.map((target) => `${workspaceKey(target)}\n${paneKeyTabId(target.paneKey!) ?? ''}`)
)
const agentPanes = new Set(
agentTargets.map((target) => `${workspaceKey(target)}\n${target.paneKey}`)
)
const seen = new Set<string>()
return ofKind
.filter((target) => {
if (target.paneKey !== null || target.markedByUser) {
if (target.paneKey !== null || target.markedByUser || target.terminalBell) {
return true
}
if (target.tabId !== undefined && target.unreadPaneKeys?.length) {
return !target.unreadPaneKeys.every((key) =>
agentPanes.has(`${workspaceKey(target)}\n${key}`)
)
}
// A marker tab whose agent already has its own target is that agent's unread, not another one.
return target.tabId !== undefined
? !agentTabs.has(`${workspaceKey(target)}\n${target.tabId}`)
: !withAgent.has(workspaceKey(target))
: true
})
.filter((target) => {
const key = `${workspaceKey(target)}\n${target.paneKey ?? ''}\n${target.tabId ?? ''}`
@@ -139,6 +169,18 @@ export function slotTargetsOfKind(
.sort((a, b) => (b.at ?? 0) - (a.at ?? 0))
}
/** Only the unread sources not already represented by the card's visible agent rows. */
export function workspaceUnreadTargetsOutsideAgentRows(
state: AppState,
target: SlotTarget,
visibleAgents: readonly SlotTarget[]
): SlotTarget[] {
return slotTargetsOfKind(
[...visibleAgents, ...expandWorkspaceUnreadTargets(state, [target])],
'unread'
).filter((candidate) => candidate.paneKey === null)
}
/** Open the target the way its own row would, after unfolding its card's agent list. */
export function openSlotTarget(target: SlotTarget): void {
if (target.tabId !== undefined) {
@@ -2,6 +2,7 @@ import type { StateCreator } from 'zustand'
import type { StoredAgentAttentionUnread } from '@/attention/agent-attention-contract'
import type { AgentAttentionUnreadWrite } from '@/attention/agent-attention-policy'
import type { ExecutionHostId } from '../../../../../shared/execution-host'
import type { HostScopedWorktreeKey } from '../../../../../shared/worktree/host-qualified-identity'
import type { AppState } from '../../types'
export type WorkspaceAttentionSet = Parameters<StateCreator<AppState>>[0]
@@ -38,8 +39,8 @@ export type WorkspaceAttentionSlice = {
screen: OnScreenAttention,
options: { screenArrived: boolean; markersRead?: boolean }
) => void
/** Workspaces the user marked unread this session, so the slot can say so instead of guessing. Renderer-local. */
workspacesMarkedUnreadByUser: Record<string, true>
/** Host-qualified workspaces the user marked unread this session. Renderer-local. */
workspacesMarkedUnreadByUser: Partial<Record<HostScopedWorktreeKey, true>>
/** Sidebar "Mark as read" (also removes every marker and reads every agent in the workspace) / "Mark as unread". */
setWorkspaceUnreadByUser: (
worktreeId: string,
@@ -15,7 +15,13 @@ import {
settleWorkspaceUnreadFlag,
noteWorkspaceRead
} from './workspace-unread-flag'
import { workspaceMarkerTabIds, workspaceMarkerTabs, paneKeyTabId } from './workspace-live-markers'
import { workspaceMarkerTabs, paneKeyTabId } from './workspace-live-markers'
import { workspaceMarkerTabIdsForHost } from './workspace-marker-host'
import {
composeWorktreeHostIdentity,
type HostScopedWorktreeKey
} from '../../../../../shared/worktree/host-qualified-identity'
import { getResolvedExecutionHostIdForWorktree } from '@/lib/workspace/ownership/resolved-worktree-execution-host'
import { getAgentPaneExecutionHost } from '../agent-status/agent-pane-execution-host'
import { resolveAgentStatusWorktreeId } from '../../../../../shared/agent-status/identity/agent-status-worktree-attribution'
import { isTabOnScreen } from '@/hooks/agent-auto-ack-targets'
@@ -74,20 +80,23 @@ export const createWorkspaceAttentionSlice: StateCreator<
return removed
}
const paneAttentionEnabled = (): boolean => get().settings?.experimentalTerminalAttention === true
const setWorkspacesMarkedUnreadByUser = (worktreeIds: readonly string[], marked: boolean): void =>
const setWorkspacesMarkedUnreadByUser = (
identities: readonly HostScopedWorktreeKey[],
marked: boolean
): void =>
set((s) => {
const changed = worktreeIds.filter(
(worktreeId) => Boolean(s.workspacesMarkedUnreadByUser[worktreeId]) !== marked
const changed = identities.filter(
(identity) => Boolean(s.workspacesMarkedUnreadByUser[identity]) !== marked
)
if (changed.length === 0) {
return s
}
const next = { ...s.workspacesMarkedUnreadByUser }
for (const worktreeId of changed) {
for (const identity of changed) {
if (marked) {
next[worktreeId] = true
next[identity] = true
} else {
delete next[worktreeId]
delete next[identity]
}
}
return { workspacesMarkedUnreadByUser: next }
@@ -128,7 +137,15 @@ export const createWorkspaceAttentionSlice: StateCreator<
readOnScreenAttention: ({ workspaceIds, targets }, { screenArrived, markersRead }) => {
// Arriving fulfils "come back to this", even while other tabs still hold markers.
if (screenArrived) {
setWorkspacesMarkedUnreadByUser(workspaceIds, false)
const state = get()
setWorkspacesMarkedUnreadByUser(
workspaceIds.flatMap((id) => {
const host =
onScreenHostId(state, id) ?? getResolvedExecutionHostIdForWorktree(state, id)
return host ? [composeWorktreeHostIdentity(host, id)] : []
}),
false
)
}
// Agent acknowledgement may already have removed this scan's completion markers.
if (
@@ -143,14 +160,18 @@ export const createWorkspaceAttentionSlice: StateCreator<
},
workspacesMarkedUnreadByUser: {},
setWorkspaceUnreadByUser: (worktreeId, isUnread, executionHostId) => {
setWorkspacesMarkedUnreadByUser([worktreeId], isUnread)
setWorkspacesMarkedUnreadByUser(
[composeWorktreeHostIdentity(executionHostId, worktreeId)],
isUnread
)
if (isUnread) {
void get().updateWorktreeMeta(worktreeId, { isUnread }, { executionHostId })
return
}
noteWorkspaceRead(worktreeId)
const removed = removeAndDismiss(workspaceMarkerTabIds(get(), worktreeId))
// Update only the clicked host’s row; markers stay workspace-wide because tab keys omit host.
const removed = removeAndDismiss(
workspaceMarkerTabIdsForHost(get(), worktreeId, executionHostId)
)
void get().updateWorktreeMeta(worktreeId, { isUnread }, { executionHostId })
// Mark read reads the agents too, or their lines keep the dot the user just cleared.
// Journal-positioned chat alerts retire only with this explicit read that bounds them.
@@ -46,13 +46,6 @@ export function hasLiveTabMarker(sources: LiveMarkerSources, workspaceId: string
)
}
export function workspaceMarkerTabIds(
sources: LiveMarkerSources,
workspaceId: string
): Set<string> {
return new Set(workspaceMarkerTabs(sources, workspaceId).map((tab) => tab.id))
}
/** The live tabs holding a tab or completion marker: where the workspace's unread actually is. */
export function workspaceLiveMarkerTabIds(
sources: LiveMarkerSources & { unreadAgentCompletionPanes: MarkerRecord },
@@ -0,0 +1,45 @@
import type { AppState } from '../../types'
import type { ExecutionHostId } from '../../../../../shared/execution-host'
import { structuredAgentSessionPaneKey } from '../../../../../shared/agent-session/view/structured-agent-session-projection'
import { getAgentPaneExecutionHost } from '../agent-status/agent-pane-execution-host'
import { workspaceMarkerTabs, type MarkerTab } from './workspace-live-markers'
function markerTabHost(state: AppState, workspaceId: string, tab: MarkerTab) {
const terminal = state.tabsByWorktree[workspaceId]?.find((candidate) => candidate.id === tab.id)
const layout = state.terminalLayoutsByTabId[tab.id]
const paneKey =
tab.sessionId === null
? `${tab.id}:${layout?.activeLeafId ?? ''}`
: structuredAgentSessionPaneKey(tab.id, tab.sessionId)
const entry = state.agentStatusByPaneKey[paneKey]
const ptyId =
(layout?.activeLeafId ? layout.ptyIdsByLeafId?.[layout.activeLeafId] : undefined) ??
terminal?.ptyId ??
state.ptyIdsByTabId[tab.id]?.[0]
if (tab.sessionId === null && !entry && !ptyId) {
const stampedHost = state.unifiedTabsByWorktree[workspaceId]?.find(
(candidate) => candidate.contentType === 'terminal' && candidate.entityId === tab.id
)?.executionHostId
if (stampedHost) {
return stampedHost
}
}
return getAgentPaneExecutionHost(state, paneKey, entry, ptyId)
}
/** Filter before removing markers or resolving jumps: workspace IDs can name several hosts. */
export function workspaceMarkerTabIdsForHost(
state: AppState,
workspaceId: string,
executionHostId: ExecutionHostId | undefined
): Set<string> {
return new Set(
workspaceMarkerTabs(state, workspaceId)
.filter(
(tab) =>
executionHostId === undefined ||
markerTabHost(state, workspaceId, tab) === executionHostId
)
.map((tab) => tab.id)
)
}
@@ -17,6 +17,11 @@ import { makeTab } from '../../terminals/terminal-tab.test-fixture'
import { makeUnifiedTab } from '../tabs/tab-group.test-fixture'
import { structuredAgentSessionPaneKey } from '../../../../../shared/agent-session/view/structured-agent-session-projection'
import { publicRuntimeEnvironment } from '@/runtime/rpc/testing/paired-runtime-environment-test-fixture'
import { makeWorktree } from '../worktrees/worktree.test-fixture'
import {
expandWorkspaceUnreadTargets,
workspaceUnreadTarget
} from '@/components/sidebar/worktree-card-slot-targets'
vi.mock('sonner', () => ({
toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn(), dismiss: vi.fn() }
@@ -57,6 +62,101 @@ function finishedAgentOffScreen(store: AttentionStore): PaneTarget {
}
describe('explicit workspace and agent reads', () => {
it('keeps another host’s manual unread target when arriving at a same-ID workspace', () => {
const { store } = createAttentionStore({ windowOnScreen: false })
store.setState({
worktreesByRepo: {
repo1: [
makeWorktree({ id: WS_A, repoId: 'repo1', hostId: 'local' }),
makeWorktree({ id: WS_A, repoId: 'repo1', hostId: 'runtime:box' })
]
},
activeWorktreeId: WS_A,
activeWorkspaceExecutionHostId: 'local'
})
store.getState().setWorkspaceUnreadByUser(WS_A, true, 'local')
const local = workspaceUnreadTarget(WS_A, 900, 'local')
const remote = workspaceUnreadTarget(WS_A, 900, 'runtime:box')
expect(
expandWorkspaceUnreadTargets(store.getState(), [local, remote]).map((target) => [
target.executionHostId,
target.markedByUser
])
).toEqual([
['local', true],
['runtime:box', false]
])
store.getState().setWorkspaceUnreadByUser(WS_A, true, 'runtime:box')
store
.getState()
.readOnScreenAttention({ workspaceIds: [WS_A], targets: [] }, { screenArrived: true })
expect(
expandWorkspaceUnreadTargets(store.getState(), [local, remote]).map((target) => [
target.executionHostId,
target.markedByUser
])
).toEqual([
['local', false],
['runtime:box', true]
])
})
it('preserves another host’s tab markers and notifications when marking a same-ID workspace read', () => {
const dismiss = installAttentionMockApi()
const { store } = createAttentionStore({ windowOnScreen: false })
const local = addLiveTerminalTab(store, WS_A, 'local')
const remote = addLiveTerminalTab(store, WS_A, 'remote')
const state = store.getState()
store.setState({
tabsByWorktree: {
...state.tabsByWorktree,
[WS_A]: state.tabsByWorktree[WS_A]!.map((tab) =>
tab.id === 'remote' ? { ...tab, ptyId: null } : tab
)
},
unifiedTabsByWorktree: {
...state.unifiedTabsByWorktree,
[WS_A]: [
...state.unifiedTabsByWorktree[WS_A]!.filter((tab) => tab.entityId !== 'remote'),
makeUnifiedTab({
id: 'unified-remote',
entityId: 'remote',
worktreeId: WS_A,
groupId: 'group',
contentType: 'terminal',
executionHostId: 'runtime:box'
})
]
},
ptyIdsByTabId: { ...state.ptyIdsByTabId, remote: [] },
terminalLayoutsByTabId: {
...state.terminalLayoutsByTabId,
remote: {
...state.terminalLayoutsByTabId.remote!,
ptyIdsByLeafId: {}
}
},
unreadTerminalTabs: { local: 'terminal-bell', remote: 'terminal-bell' },
unreadTerminalPanes: { [local.paneKey]: 'terminal-bell', [remote.paneKey]: 'terminal-bell' },
unreadAgentCompletionPanes: {
[local.paneKey]: 'agent-completion',
[remote.paneKey]: 'agent-completion'
}
})
store.getState().setWorkspaceUnreadByUser(WS_A, false, 'local')
expect(store.getState().unreadTerminalTabs).toEqual({ remote: 'terminal-bell' })
expect(store.getState().unreadTerminalPanes).toEqual({
'remote:11111111-1111-4111-8111-111111111111': 'terminal-bell'
})
expect(store.getState().unreadAgentCompletionPanes).toEqual({
'remote:11111111-1111-4111-8111-111111111111': 'agent-completion'
})
expect(dismiss).toHaveBeenCalledWith([], ['local:11111111-1111-4111-8111-111111111111'])
})
it('Mark read also reads the agents, so their lines lose the dot with the flag', () => {
const { store } = createAttentionStore()
const pane = finishedAgentOffScreen(store)
@@ -17,7 +17,9 @@ export function read(
typeof host.worktreeCardDetailsRow === 'boolean' ? environmentId : null
}
return {
worktreeCardDetailsRow: host.worktreeCardDetailsRow !== false,
...(typeof host.worktreeCardDetailsRow === 'boolean'
? { worktreeCardDetailsRow: host.worktreeCardDetailsRow }
: {}),
...(typeof host.compactWorktreeCards === 'boolean'
? { compactWorktreeCards: host.compactWorktreeCards }
: {}),
@@ -52,6 +52,30 @@ describe('web settings preload API', () => {
})
})
it('retains a local details-row choice when an older paired host omits it', async () => {
vi.doMock('./web-runtime-client', () => ({
WebRuntimeClient: class {
call(): Promise<RuntimeRpcResponse<unknown>> {
return Promise.resolve({
id: 'settings',
ok: true,
result: { settings: {} },
_meta: { runtimeId: 'runtime-1' }
})
}
close(): void {}
}
}))
const globals = installBrowserGlobals('Linux')
writeStoredRuntimeEnvironment(globals.storage)
const { installWebPreloadApi } = await import('./web-preload-api')
installWebPreloadApi()
await globals.window.api.settings.get()
await globals.window.api.settings.set({ worktreeCardDetailsRow: false })
expect((await globals.window.api.settings.get()).worktreeCardDetailsRow).toBe(false)
})
it('arms the OSC 52 notice when ui.get is the read that runs the migration', async () => {
// Seed after module load to model another tab’s storage update; first ui.get must migrate settings before snapshotting UI or it erases the one-shot notice arm.
const globals = installBrowserGlobals('Linux')