> = {
+ 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
+}
diff --git a/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-status-lane.tsx b/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-status-lane.tsx
index d4c591509dc..f65d59573c4 100644
--- a/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-status-lane.tsx
+++ b/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-status-lane.tsx
@@ -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 (
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(
- () => ({ offline, exceptions, at, resolveTargets }),
- [at, exceptions, offline, resolveTargets]
+ () => ({ offline, exceptions, unread, lineageStatus, at, resolveTargets }),
+ [at, exceptions, lineageStatus, offline, resolveTargets, unread]
)
return {children}
}
-/** 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. */
diff --git a/src/renderer/src/components/sidebar/workspace-card/template/default-workspace-card.json b/src/renderer/src/components/sidebar/workspace-card/template/default-workspace-card.json
index 10ad42460ba..a3169b451b6 100644
--- a/src/renderer/src/components/sidebar/workspace-card/template/default-workspace-card.json
+++ b/src/renderer/src/components/sidebar/workspace-card/template/default-workspace-card.json
@@ -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" }
},
{
diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-rollup.test.ts b/src/renderer/src/components/sidebar/worktree-card-slot-rollup.test.ts
index 88c5cd8bbf0..3f5dde90c3d 100644
--- a/src/renderer/src/components/sidebar/worktree-card-slot-rollup.test.ts
+++ b/src/renderer/src/components/sidebar/worktree-card-slot-rollup.test.ts
@@ -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) {
@@ -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 })
diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-rollup.ts b/src/renderer/src/components/sidebar/worktree-card-slot-rollup.ts
index 179bd2aa900..f10d2c35f8b 100644
--- a/src/renderer/src/components/sidebar/worktree-card-slot-rollup.ts
+++ b/src/renderer/src/components/sidebar/worktree-card-slot-rollup.ts
@@ -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)
}
diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-targets.ts b/src/renderer/src/components/sidebar/worktree-card-slot-targets.ts
index 0b6272d1a5e..65c8bbcba85 100644
--- a/src/renderer/src/components/sidebar/worktree-card-slot-targets.ts
+++ b/src/renderer/src/components/sidebar/worktree-card-slot-targets.ts
@@ -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()
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) {
diff --git a/src/renderer/src/store/slices/workspace-attention/workspace-attention-contract.ts b/src/renderer/src/store/slices/workspace-attention/workspace-attention-contract.ts
index 357fa9d7923..5881983f51a 100644
--- a/src/renderer/src/store/slices/workspace-attention/workspace-attention-contract.ts
+++ b/src/renderer/src/store/slices/workspace-attention/workspace-attention-contract.ts
@@ -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>[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
+ /** Host-qualified workspaces the user marked unread this session. Renderer-local. */
+ workspacesMarkedUnreadByUser: Partial>
/** Sidebar "Mark as read" (also removes every marker and reads every agent in the workspace) / "Mark as unread". */
setWorkspaceUnreadByUser: (
worktreeId: string,
diff --git a/src/renderer/src/store/slices/workspace-attention/workspace-attention-slice.ts b/src/renderer/src/store/slices/workspace-attention/workspace-attention-slice.ts
index a1e7f8daf29..5ce0f9c687e 100644
--- a/src/renderer/src/store/slices/workspace-attention/workspace-attention-slice.ts
+++ b/src/renderer/src/store/slices/workspace-attention/workspace-attention-slice.ts
@@ -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.
diff --git a/src/renderer/src/store/slices/workspace-attention/workspace-live-markers.ts b/src/renderer/src/store/slices/workspace-attention/workspace-live-markers.ts
index 233015553ec..96dec70ae32 100644
--- a/src/renderer/src/store/slices/workspace-attention/workspace-live-markers.ts
+++ b/src/renderer/src/store/slices/workspace-attention/workspace-live-markers.ts
@@ -46,13 +46,6 @@ export function hasLiveTabMarker(sources: LiveMarkerSources, workspaceId: string
)
}
-export function workspaceMarkerTabIds(
- sources: LiveMarkerSources,
- workspaceId: string
-): Set {
- 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 },
diff --git a/src/renderer/src/store/slices/workspace-attention/workspace-marker-host.ts b/src/renderer/src/store/slices/workspace-attention/workspace-marker-host.ts
new file mode 100644
index 00000000000..2522fdb0d84
--- /dev/null
+++ b/src/renderer/src/store/slices/workspace-attention/workspace-marker-host.ts
@@ -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 {
+ return new Set(
+ workspaceMarkerTabs(state, workspaceId)
+ .filter(
+ (tab) =>
+ executionHostId === undefined ||
+ markerTabHost(state, workspaceId, tab) === executionHostId
+ )
+ .map((tab) => tab.id)
+ )
+}
diff --git a/src/renderer/src/store/slices/workspace-attention/workspace-unread-explicit-read.test.ts b/src/renderer/src/store/slices/workspace-attention/workspace-unread-explicit-read.test.ts
index 695f9fe8f72..3bc7546b087 100644
--- a/src/renderer/src/store/slices/workspace-attention/workspace-unread-explicit-read.test.ts
+++ b/src/renderer/src/store/slices/workspace-attention/workspace-unread-explicit-read.test.ts
@@ -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)
diff --git a/src/renderer/src/web/preload-api/web-worktree-card-style-sync.ts b/src/renderer/src/web/preload-api/web-worktree-card-style-sync.ts
index 19cfbe8407b..88c26b10a95 100644
--- a/src/renderer/src/web/preload-api/web-worktree-card-style-sync.ts
+++ b/src/renderer/src/web/preload-api/web-worktree-card-style-sync.ts
@@ -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 }
: {}),
diff --git a/src/renderer/src/web/web-preload-api-settings.test.ts b/src/renderer/src/web/web-preload-api-settings.test.ts
index 1e8cf443335..7d27abed43b 100644
--- a/src/renderer/src/web/web-preload-api-settings.test.ts
+++ b/src/renderer/src/web/web-preload-api-settings.test.ts
@@ -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> {
+ 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')