diff --git a/src/renderer/src/components/sidebar/WorktreeCardSlotContent.tsx b/src/renderer/src/components/sidebar/WorktreeCardSlotContent.tsx
index 5005f6ee9e2..b4b025387a3 100644
--- a/src/renderer/src/components/sidebar/WorktreeCardSlotContent.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeCardSlotContent.tsx
@@ -8,7 +8,6 @@ import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { canConnectSshStatus, isConnectingSshStatus } from '@/ssh/ssh-connection-recoverability'
import { useAppStore } from '@/store'
-import { workspaceHasLiveMarker } from '@/store/slices/workspace-attention/workspace-live-markers'
import type { WorktreeCardController } from './workspace-card/controller/use-worktree-card-controller'
import { useWorktreeAgentRows } from './workspace-card/activity/useWorktreeAgentRows'
import { selectAgentRowsUnread } from './workspace-card/activity/worktree-card-agent-ack-inputs'
@@ -35,6 +34,7 @@ import { WorktreeCardSlot, WorktreeCardTitlePills, WorktreeCardTitleTime } from
import { WorktreeCardLeadingIcon, type LeadingIconBadge } from './worktree-card-leading-icon'
import { WorktreeCardPrRollup } from './worktree-card-pr-rollup'
import {
+ expandWorkspaceUnreadTargets,
slotTargetsFromAgentLines,
slotTargetsOfKind,
workspaceUnreadTarget
@@ -131,7 +131,7 @@ export function WorktreeCardSlotContent({
const host = getWorktreeCardHost(card)
const sshDown = host?.kind === 'ssh' && host.down
const offline = isRuntimeDisconnected || sshDown
- const hasLiveMarker = useAppStore((s) => workspaceHasLiveMarker(s, worktree.id))
+ const markedByUser = useAppStore((s) => Boolean(s.workspacesMarkedUnreadByUser[worktree.id]))
// Picking a send target turns the agent lines into the picker, so they can't stay folded.
const pickingSendTarget = useAppStore(
(s) => selectSendTargetControlInputs(s, worktree.id).targetMode !== null
@@ -149,7 +149,7 @@ export function WorktreeCardSlotContent({
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 line already shows it.
const workspaceUnread =
- worktree.isUnread && !(showLines && agentUnread) ? { manual: !hasLiveMarker } : null
+ worktree.isUnread && !(showLines && agentUnread) ? { manual: markedByUser } : null
// The icon badge and pills speak for what's hidden; a visible agent line shows its own state.
const titleSlot = titleRowSlot({
offline,
@@ -169,17 +169,19 @@ export function WorktreeCardSlotContent({
const worktreeLastActivityAt = worktree.lastActivityAt > 0 ? worktree.lastActivityAt : null
const workspaceUnreadShown = workspaceUnread !== null
const resolveTitleTargets = useCallback(
- (kind: SlotException['kind']) =>
- slotTargetsOfKind(
- [
+ (kind: SlotException['kind']) => {
+ const state = useAppStore.getState()
+ return slotTargetsOfKind(
+ expandWorkspaceUnreadTargets(state, [
...(showLines ? [] : slotTargetsFromAgentLines(worktree.id, signals, worktree.hostId)),
- ...(lineageHidden ? collectLineageSlotTargets(useAppStore.getState(), descendants) : []),
+ ...(lineageHidden ? collectLineageSlotTargets(state, descendants) : []),
...(workspaceUnreadShown
? [workspaceUnreadTarget(worktree.id, worktreeLastActivityAt, worktree.hostId)]
: [])
- ],
+ ]),
kind
- ),
+ )
+ },
[
descendants,
lineageHidden,
diff --git a/src/renderer/src/components/sidebar/workspace-actions/context-menu/WorktreeContextMenuView.tsx b/src/renderer/src/components/sidebar/workspace-actions/context-menu/WorktreeContextMenuView.tsx
index f7cae17c19c..8dac8ceebf1 100644
--- a/src/renderer/src/components/sidebar/workspace-actions/context-menu/WorktreeContextMenuView.tsx
+++ b/src/renderer/src/components/sidebar/workspace-actions/context-menu/WorktreeContextMenuView.tsx
@@ -51,6 +51,7 @@ export default function WorktreeContextMenuView({ model }: { model: WorktreeCont
handleSleepSubtreeWithVm,
handleTogglePin,
handleToggleRead,
+ showsUnread,
hasAnyContextLineage,
isDeleting,
isMultiContext,
@@ -164,8 +165,8 @@ export default function WorktreeContextMenuView({ model }: { model: WorktreeCont
)}
{!isMultiContext && (
- {worktree.isUnread ? : }
- {worktree.isUnread
+ {showsUnread ? : }
+ {showsUnread
? t('auto.components.sidebar.WorktreeContextMenu.8dacff1fe0', 'Mark Read')
: t('auto.components.sidebar.WorktreeContextMenu.f50603c6b2', 'Mark Unread')}
diff --git a/src/renderer/src/components/sidebar/workspace-actions/context-menu/use-worktree-context-menu-commands.test.tsx b/src/renderer/src/components/sidebar/workspace-actions/context-menu/use-worktree-context-menu-commands.test.tsx
index 62ea544516e..25582fa4d72 100644
--- a/src/renderer/src/components/sidebar/workspace-actions/context-menu/use-worktree-context-menu-commands.test.tsx
+++ b/src/renderer/src/components/sidebar/workspace-actions/context-menu/use-worktree-context-menu-commands.test.tsx
@@ -55,6 +55,7 @@ function runNewChildWorktree(args: {
setCreateGroupDialogOpen: () => {},
setWorkspaceUnreadByUser: state.setWorkspaceUnreadByUser,
setWorktreesPinnedAndReveal: state.setWorktreesPinnedAndReveal,
+ showsUnread: false,
sleepableWorktrees: [],
subtreeSleepableWorktrees: [],
updateWorktreeMeta: state.updateWorktreeMeta,
diff --git a/src/renderer/src/components/sidebar/workspace-actions/context-menu/use-worktree-context-menu-commands.ts b/src/renderer/src/components/sidebar/workspace-actions/context-menu/use-worktree-context-menu-commands.ts
index 288728d5541..c75efe0a5e0 100644
--- a/src/renderer/src/components/sidebar/workspace-actions/context-menu/use-worktree-context-menu-commands.ts
+++ b/src/renderer/src/components/sidebar/workspace-actions/context-menu/use-worktree-context-menu-commands.ts
@@ -53,6 +53,7 @@ export function useWorktreeContextMenuCommands(args: {
setWorktreesPinnedAndReveal: ReturnType<
typeof useAppStore.getState
>['setWorktreesPinnedAndReveal']
+ showsUnread: boolean
sleepableWorktrees: readonly Worktree[]
subtreeSleepableWorktrees: readonly Worktree[]
updateWorktreeMeta: ReturnType['updateWorktreeMeta']
@@ -74,7 +75,7 @@ export function useWorktreeContextMenuCommands(args: {
const handleToggleRead = useCallback(() => {
args.setWorkspaceUnreadByUser(
args.worktree.id,
- !args.worktree.isUnread,
+ !args.showsUnread,
args.worktree.hostId ?? 'local'
)
}, [args])
diff --git a/src/renderer/src/components/sidebar/workspace-actions/context-menu/use-worktree-context-menu-model.tsx b/src/renderer/src/components/sidebar/workspace-actions/context-menu/use-worktree-context-menu-model.tsx
index bc9c4977a02..295a41316ea 100644
--- a/src/renderer/src/components/sidebar/workspace-actions/context-menu/use-worktree-context-menu-model.tsx
+++ b/src/renderer/src/components/sidebar/workspace-actions/context-menu/use-worktree-context-menu-model.tsx
@@ -27,6 +27,7 @@ import {
shouldRemoveProjectFromContextMenu
} from './worktree-context-menu-policy'
import { useWorktreeContextMenuCommands } from './use-worktree-context-menu-commands'
+import { selectWorkspaceShowsUnread } from '../../worktree-card-slot-rollup'
import {
useWorktreeParentPickerTransition,
type PendingParentPicker
@@ -112,6 +113,10 @@ export function useWorktreeContextMenuModel({
const unifiedTabsByWorktree = useAppStore((s) =>
selectMenuScopedMap(menuOpen, s.unifiedTabsByWorktree, EMPTY_UNIFIED_TABS_BY_WORKTREE)
)
+ // The menu offers what the card shows: unread when any agent result or the flag is unread.
+ const showsUnread = useAppStore((s) =>
+ menuOpen ? selectWorkspaceShowsUnread(s, worktree) : worktree.isUnread
+ )
const agentStatusByPaneKey = useAppStore((s) =>
selectMenuScopedMap(menuOpen, s.agentStatusByPaneKey, EMPTY_AGENT_STATUS_BY_PANE_KEY)
)
@@ -288,6 +293,7 @@ export function useWorktreeContextMenuModel({
setMenuOpenState,
setWorkspaceUnreadByUser,
setWorktreesPinnedAndReveal,
+ showsUnread,
sleepableWorktrees,
subtreeSleepableWorktrees,
statusesForWorktree,
@@ -331,6 +337,7 @@ export function useWorktreeContextMenuModel({
return {
...commands,
+ showsUnread,
activeContextWorktrees,
batchDeleteWorktrees,
browserTabsByWorktree,
diff --git a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-agent-rows-projection.ts b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-agent-rows-projection.ts
index ef433c13d1d..6a08dc80bca 100644
--- a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-agent-rows-projection.ts
+++ b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-agent-rows-projection.ts
@@ -1,5 +1,5 @@
import { structuredAgentSessionOwnerInputs } from '@/runtime/structured-agent-session-owner'
-import { selectWorktreesByRepo } from '@/store/workspace-record-selectors'
+import { selectWorktreesByRepo } from '@/store/workspaces/catalog/workspace-record-fields'
import { getStructuredAgentSessionTabsByPaneKey } from '@/components/native-chat/session/status/structured-agent-session-tabs'
import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData'
import { applyAgentRowLineage } from '@/components/dashboard/agent-row-lineage'
diff --git a/src/renderer/src/components/sidebar/worktree-card-leading-tab-order.ts b/src/renderer/src/components/sidebar/worktree-card-leading-tab-order.ts
index e501bd008cb..1b9a6a2eebf 100644
--- a/src/renderer/src/components/sidebar/worktree-card-leading-tab-order.ts
+++ b/src/renderer/src/components/sidebar/worktree-card-leading-tab-order.ts
@@ -1,5 +1,5 @@
import type { AppState } from '@/store/types'
-import { findWorktreeById } from '@/store/slices/worktree-helpers'
+import { getIndexedWorktreesById } from '@/store/slices/worktrees/listing/worktree-catalog-index'
import type { Tab } from '../../../../shared/tab-types'
import type { AgentStatusEntry } from '../../../../shared/agent-status/contracts/agent-status-types'
import { isAgentStatusFresh } from '../../../../shared/agent-status/contracts/agent-status-freshness'
@@ -54,7 +54,7 @@ export function orderWorktreeLeadingTabs(state: AppState, worktreeId: string): T
const smart = state.sortBy === 'smart'
const terminalTabs = state.tabsByWorktree[worktreeId]
const worktreeLastActivityAt = smart
- ? (findWorktreeById(state.worktreesByRepo, worktreeId)?.lastActivityAt ?? 0)
+ ? (getIndexedWorktreesById(state.worktreesByRepo, worktreeId)[0]?.lastActivityAt ?? 0)
: 0
const paneSources: TabPaneInputSources = {
entriesByTabId: byTabId,
diff --git a/src/renderer/src/components/sidebar/worktree-card-pr-rollup.tsx b/src/renderer/src/components/sidebar/worktree-card-pr-rollup.tsx
index 768662d0f56..96052dc92b8 100644
--- a/src/renderer/src/components/sidebar/worktree-card-pr-rollup.tsx
+++ b/src/renderer/src/components/sidebar/worktree-card-pr-rollup.tsx
@@ -2,8 +2,8 @@ import React, { useMemo, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { useAppStore } from '@/store'
import type { AppState } from '@/store/types'
-import { selectWorktreesByRepo } from '@/store/workspace-record-selectors'
-import { getIndexedRepoMap } from '@/store/worktree-repo-index'
+import { selectWorktreesByRepo } from '@/store/workspaces/catalog/workspace-record-fields'
+import { getIndexedRepoMap } from '@/store/repos/repository-catalog-index'
import { createHostScopedRepoOf } from '../../../../shared/sidebar-list/sidebar-feed-index'
import {
isLineageRange,
diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-agent-line-row.tsx b/src/renderer/src/components/sidebar/worktree-card-slot-agent-line-row.tsx
index e091979d721..621389127f0 100644
--- a/src/renderer/src/components/sidebar/worktree-card-slot-agent-line-row.tsx
+++ b/src/renderer/src/components/sidebar/worktree-card-slot-agent-line-row.tsx
@@ -158,6 +158,29 @@ export const SlotAgentLineRow = React.memo(function SlotAgentLineRow({
) : (
)
+ // A Needs you / Failed pill must stay readable on hover, so dismiss slides in beside it instead.
+ const pillSlot = !offline && (displaySignal.kind === 'needs' || displaySignal.kind === 'failed')
+ const dismissButton = onDismiss ? (
+
+ ) : null
const slot = sendTarget ? (
sendTarget.status === 'eligible' ? (
@@ -167,24 +190,16 @@ export const SlotAgentLineRow = React.memo(function SlotAgentLineRow({
) : sendTarget.status === 'sending' ? (
) : null
- ) : onDismiss ? (
+ ) : dismissButton && pillSlot ? (
+
+ {lineSlot}
+ {dismissButton}
+
+ ) : dismissButton ? (
// Time and dismiss share the slot; hover or keyboard focus swaps in the dismiss control.
// Button comes first so the time can follow it as a `peer` without :has(); z-10 keeps it on top.
-
+ {dismissButton}
{lineSlot}
diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-invalidation.ts b/src/renderer/src/components/sidebar/worktree-card-slot-invalidation.ts
index 007b5d34e09..a06e5f0814a 100644
--- a/src/renderer/src/components/sidebar/worktree-card-slot-invalidation.ts
+++ b/src/renderer/src/components/sidebar/worktree-card-slot-invalidation.ts
@@ -1,7 +1,7 @@
import type { AppState } from '@/store/types'
import { isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence'
import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status/contracts/agent-status-freshness'
-import { getStructuredAgentSessionTabsByPaneKey } from '@/components/native-chat/structured-agent-session-tabs'
+import { getStructuredAgentSessionTabsByPaneKey } from '@/components/native-chat/session/status/structured-agent-session-tabs'
import { parsePaneKey } from '../../../../shared/workspace-layout/stable-pane-id'
import { getTabIdToWorktreeId } from './workspace-card/activity/worktree-agent-row-selectors'
import {
diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-jump.tsx b/src/renderer/src/components/sidebar/worktree-card-slot-jump.tsx
index 2e582b6efb3..3a30f0ed721 100644
--- a/src/renderer/src/components/sidebar/worktree-card-slot-jump.tsx
+++ b/src/renderer/src/components/sidebar/worktree-card-slot-jump.tsx
@@ -50,11 +50,26 @@ export function SlotKindGlyph({
}
}
+function tabTitle(worktreeId: string, tabId: string): string | null {
+ const state = useAppStore.getState()
+ const terminal = state.tabsByWorktree[worktreeId]?.find((tab) => tab.id === tabId)
+ const unified = state.unifiedTabsByWorktree[worktreeId]?.find((tab) => tab.id === tabId)
+ return terminal?.customTitle || terminal?.title || unified?.label || null
+}
+
function targetText(target: SlotTarget): string {
- if (!target.entry) {
- return translate('sidebar.cardSlot.workspaceMarkedUnread', 'Workspace marked unread')
+ if (target.entry) {
+ return getAgentRowPrimaryText(target.entry) || formatAgentTypeLabel(target.agentType)
}
- return getAgentRowPrimaryText(target.entry) || formatAgentTypeLabel(target.agentType)
+ if (target.tabId !== undefined) {
+ return (
+ tabTitle(target.worktreeId, target.tabId) ??
+ translate('sidebar.cardSlot.unreadTab', 'Unread tab')
+ )
+ }
+ return target.markedByUser
+ ? translate('sidebar.cardSlot.markedUnread', 'Marked unread by you')
+ : translate('sidebar.cardSlot.workspaceUnread', 'Unread in this workspace')
}
function SlotTargetMenuItem({
@@ -154,7 +169,7 @@ export function SlotJumpTrigger({
{label}
{menu.sources.map((target) => (
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 9af540c5645..8b705321a63 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
@@ -3,7 +3,8 @@ import { buildWorktreeLineageRanges } from '../../../../shared/sidebar-list/work
// @vitest-environment happy-dom
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
-import { makeTab, makeWorktree } from '@/store/slices/store-test-helpers'
+import { makeTab } from '@/store/terminals/terminal-tab.test-fixture'
+import { makeWorktree } from '@/store/slices/worktrees/worktree.test-fixture'
import { makePaneKey } from '../../../../shared/workspace-layout/stable-pane-id'
import type { AgentStatusEntry } from '../../../../shared/agent-status/contracts/agent-status-types'
import { composeWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity'
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 5b778bfb0c8..4c35bac105e 100644
--- a/src/renderer/src/components/sidebar/worktree-card-slot-rollup.ts
+++ b/src/renderer/src/components/sidebar/worktree-card-slot-rollup.ts
@@ -1,6 +1,6 @@
import { createLineageSlotReader, type WorkspaceSlot } from './worktree-card-slot-range'
import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData'
-import { selectWorktreesByRepo } from '@/store/workspace-record-selectors'
+import { selectWorktreesByRepo } from '@/store/workspaces/catalog/workspace-record-fields'
import {
toLineageSlotRecord,
fromLineageSlotRecord,
@@ -10,7 +10,7 @@ import { useCallback, useMemo } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { useAppStore } from '@/store'
import type { AppState } from '@/store/types'
-import { getIndexedWorktreesById } from '@/store/worktree-repo-index'
+import { getIndexedWorktreesById } from '@/store/slices/worktrees/listing/worktree-catalog-index'
import type { Worktree } from '../../../../shared/worktree/types'
import {
getWorktreeHostIdentity,
@@ -36,6 +36,7 @@ import {
type SlotSignal
} from './worktree-card-slot-signal'
import {
+ expandWorkspaceUnreadTargets,
slotTargetsFromAgentLines,
slotTargetsOfKind,
workspaceUnreadTarget,
@@ -126,6 +127,16 @@ function ownSlot(
const { slotsForTree, descendantSlot } = createLineageSlotReader(ownSlot)
+/** What the card shows as unread: its flag, or an agent result the user hasn't read. */
+export function selectWorkspaceShowsUnread(state: AppState, worktree: Worktree): boolean {
+ return (
+ worktree.isUnread ||
+ ownSlot(state, getWorktreeHostIdentity(worktree), worktree).targets.some(
+ (target) => target.kind === 'unread'
+ )
+ )
+}
+
export function collectLineageSlotTargets(
state: AppState,
source: WorktreeLineageSource
@@ -263,16 +274,19 @@ export function collectLineageChildTargetsOfKind(
source?: WorktreeLineageSource
): SlotTarget[] {
return slotTargetsOfKind(
- children.flatMap((child) => {
- if (childSlot(state, child, source)?.kind !== kind) {
- return []
- }
- const range = childRange(source, child)
- return [
- ...ownSlot(state, child).targets,
- ...(range ? collectLineageSlotTargets(state, range) : [])
- ]
- }),
+ expandWorkspaceUnreadTargets(
+ state,
+ children.flatMap((child) => {
+ if (childSlot(state, child, source)?.kind !== kind) {
+ return []
+ }
+ const range = childRange(source, child)
+ return [
+ ...ownSlot(state, child).targets,
+ ...(range ? collectLineageSlotTargets(state, range) : [])
+ ]
+ })
+ ),
kind
)
}
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 620111005a5..5027eec9a3c 100644
--- a/src/renderer/src/components/sidebar/worktree-card-slot-targets.ts
+++ b/src/renderer/src/components/sidebar/worktree-card-slot-targets.ts
@@ -7,6 +7,14 @@ import type {
} from '../../../../shared/agent-status/contracts/agent-status-types'
import { parsePaneKey } from '../../../../shared/workspace-layout/stable-pane-id'
import { activateAndRevealWorkspace } from '@/lib/workspace/activation/worktree-activation'
+import { activateStructuredAgentSessionTab } from '@/lib/agent-session/structured-agent-session-tab-activation'
+import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane'
+import { useAppStore } from '@/store'
+import type { AppState } from '@/store/types'
+import {
+ paneKeyTabId,
+ workspaceLiveMarkerTabIds
+} from '@/store/slices/workspace-attention/workspace-live-markers'
import { activateWorktreeAgentPane } from './workspace-card/activity/use-worktree-agent-activation'
import { activateRetainedAgentRow } from './workspace-card/activity/activate-retained-agent-row'
import { expandWorktreeAgentList } from './workspace-card/activity/worktree-card-agents-expansion-state'
@@ -19,6 +27,10 @@ export type SlotTarget = SlotLineSignal & {
executionHostId?: ExecutionHostId
/** Null for a workspace marked unread with no agent behind it. */
paneKey: string | null
+ /** A tab holding the workspace's unread marker; opens that tab rather than the workspace. */
+ tabId?: string
+ /** The user marked the workspace unread; it stands on its own instead of yielding to agents. */
+ markedByUser?: boolean
retained: boolean
agentType: AgentType | null
entry: AgentStatusEntry | null
@@ -67,26 +79,57 @@ export function workspaceUnreadTarget(
}
}
-/** Targets for the one kind a slot shows, newest first; a workspace mark yields to its own agents. */
+/**
+ * Resolves each workspace-level unread into the tabs that hold it, read at press time so the
+ * jump lands where the unread is. Your own mark stays a workspace target; a flag with no tab
+ * behind it (restart, a peer) stays one too.
+ */
+export function expandWorkspaceUnreadTargets(
+ state: AppState,
+ targets: readonly SlotTarget[]
+): SlotTarget[] {
+ return targets.flatMap((target) => {
+ 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 })
+ )
+ return tabTargets.length === 0 || markedByUser
+ ? [...tabTargets, { ...target, markedByUser }]
+ : tabTargets
+ })
+}
+
+function workspaceKey(target: SlotTarget): string {
+ return composeWorktreeHostIdentity(target.executionHostId, target.worktreeId)
+}
+
+/** Targets for the one kind a slot shows, newest first; workspace-level unread yields to the agents that explain it. */
export function slotTargetsOfKind(
targets: readonly SlotTarget[],
kind: SlotLineKind
): SlotTarget[] {
const ofKind = targets.filter((target) => target.kind === kind)
- const withAgent = new Set(
- ofKind
- .filter((target) => target.paneKey !== null)
- .map((target) => composeWorktreeHostIdentity(target.executionHostId, target.worktreeId))
+ 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 seen = new Set()
return ofKind
- .filter(
- (target) =>
- target.paneKey !== null ||
- !withAgent.has(composeWorktreeHostIdentity(target.executionHostId, target.worktreeId))
- )
.filter((target) => {
- const key = `${composeWorktreeHostIdentity(target.executionHostId, target.worktreeId)}\n${target.paneKey ?? ''}`
+ if (target.paneKey !== null || target.markedByUser) {
+ return true
+ }
+ // 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))
+ })
+ .filter((target) => {
+ const key = `${workspaceKey(target)}\n${target.paneKey ?? ''}\n${target.tabId ?? ''}`
if (seen.has(key)) {
return false
}
@@ -102,6 +145,10 @@ export function openSlotTarget(target: SlotTarget): void {
composeWorktreeHostIdentity(target.executionHostId, target.worktreeId),
{ open: true, closedOver: null }
)
+ if (target.tabId !== undefined) {
+ activateUnreadTab(target.worktreeId, target.tabId, target.executionHostId)
+ return
+ }
const parsed = target.paneKey ? parsePaneKey(target.paneKey) : null
if (!target.paneKey || !parsed) {
activateAndRevealWorkspace(target.worktreeId, { executionHostId: target.executionHostId })
@@ -125,6 +172,28 @@ export function openSlotTarget(target: SlotTarget): void {
}
}
+/** Open the tab holding a marker the way Activity opens a thread's tab; arriving reads it. */
+function activateUnreadTab(
+ worktreeId: string,
+ tabId: string,
+ executionHostId: ExecutionHostId | undefined
+): void {
+ if (activateAndRevealWorkspace(worktreeId, { executionHostId }) === false) {
+ return
+ }
+ if (activateStructuredAgentSessionTab({ worktreeId, tabId })) {
+ return
+ }
+ const state = useAppStore.getState()
+ if ((state.tabsByWorktree[worktreeId] ?? []).some((tab) => tab.id === tabId)) {
+ state.setActiveTabType('terminal', worktreeId)
+ activateTabAndFocusPane(tabId, null, {
+ flashFocusedPane: true,
+ scrollToBottomIfOutputSinceLastView: true
+ })
+ }
+}
+
/** The line kind a slot points at, or null when the slot is a time, quiet, or offline. */
export function slotTargetKind(signal: SlotSignal): SlotLineKind | null {
switch (signal.kind) {
diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/worktree-card-disclosure-rows.test.ts b/src/renderer/src/components/sidebar/worktree-list/listing/worktree-card-disclosure-rows.test.ts
index 21594525544..1bd42cea488 100644
--- a/src/renderer/src/components/sidebar/worktree-list/listing/worktree-card-disclosure-rows.test.ts
+++ b/src/renderer/src/components/sidebar/worktree-list/listing/worktree-card-disclosure-rows.test.ts
@@ -7,8 +7,9 @@ import type { ProjectGroup } from '../../../../../../shared/project-group-types'
import type { HostSectionRow } from '../../../../../../shared/sidebar-list/host-section-rows'
// @vitest-environment happy-dom
import { expect, it } from 'vitest'
-import { makeWorktree, TEST_REPO } from '@/store/slices/store-test-helpers'
-import { makeLineage } from '@/store/slices/worktrees-slice-test-fixtures'
+import { TEST_REPO } from '@/store/repos/repo.test-fixture'
+import { makeWorktree } from '@/store/slices/worktrees/worktree.test-fixture'
+import { makeLineage } from '@/store/slices/worktrees/metadata/workspace-lineage.test-fixture'
import { buildSidebarRowPlan } from '../grouping/build-rows.test-fixture'
import { buildWorktreeLineageRanges } from '../../../../../../shared/sidebar-list/worktree-lineage-range'
import { composeWorktreeHostIdentity } from '../../../../../../shared/worktree/host-qualified-identity'
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 7396daf1039..ae63fe8d0c7 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -277,7 +277,8 @@
"childFailed": "{{count}} failed",
"childUnread": "{{count}} unread",
"childWorking": "{{count}} working",
- "workspaceMarkedUnread": "Workspace marked unread",
+ "workspaceUnread": "Unread in this workspace",
+ "unreadTab": "Unread tab",
"prOpen": "{{count}} open",
"prDraft": "{{count}} draft",
"prMerged": "{{count}} merged",
diff --git a/src/renderer/src/store/slices/ui/ui-slice-activity-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-activity-actions.ts
index 26a943f954c..a415bb52a3d 100644
--- a/src/renderer/src/store/slices/ui/ui-slice-activity-actions.ts
+++ b/src/renderer/src/store/slices/ui/ui-slice-activity-actions.ts
@@ -1,5 +1,7 @@
import type { StoredAgentAttentionUnread } from '@/attention/agent-attention-contract'
import { emitAgentSubjectReads } from '@/attention/agent-subject-read-actions'
+import type { AppState } from '@/store/types'
+import { paneKeyTabId } from '@/store/slices/workspace-attention/workspace-live-markers'
import type { UISlice, UISliceGet, UISliceSet } from './ui-slice-contract'
import {
collectAcknowledgedAgentNotificationId,
@@ -19,11 +21,34 @@ type ActivityActions = Pick<
| 'clearManuallyUnreadTurns'
>
-export function createUiActivityActions(set: UISliceSet, _get: UISliceGet): ActivityActions {
+/** Workspaces holding these panes: terminal tabs, structured chat tabs, or the agent's own record. */
+function workspaceIdsOfPaneKeys(s: AppState, paneKeys: readonly string[]): Set {
+ const ids = new Set()
+ for (const paneKey of paneKeys) {
+ const tabId = paneKeyTabId(paneKey)
+ for (const worktreeId of [
+ resolvePaneKeyWorktreeIdFromTabs(s, paneKey),
+ s.agentStatusByPaneKey?.[paneKey]?.worktreeId,
+ s.retainedAgentsByPaneKey?.[paneKey]?.worktreeId,
+ ...Object.entries(s.unifiedTabsByWorktree ?? {})
+ .filter(([, tabs]) => tabs.some((tab) => tab.id === tabId))
+ .map(([worktreeId]) => worktreeId)
+ ]) {
+ if (worktreeId) {
+ ids.add(worktreeId)
+ }
+ }
+ }
+ return ids
+}
+
+export function createUiActivityActions(set: UISliceSet, get: UISliceGet): ActivityActions {
return {
acknowledgedAgentsByPaneKey: {},
acknowledgeAgents: (paneKeys, reads, intent) => {
emitAgentSubjectReads(paneKeys, reads, intent)
+ const readWorkspaceIds =
+ intent === 'explicit' ? workspaceIdsOfPaneKeys(get(), paneKeys) : null
const notificationIdsToDismiss = new Set()
set((s) => {
if (paneKeys.length === 0) {
@@ -91,6 +116,10 @@ export function createUiActivityActions(set: UISliceSet, _get: UISliceGet): Acti
...(nextManual ? { manuallyUnreadTurnsByPaneKey: nextManual } : {})
}
})
+ // Explicit reads skip the on-screen scan that would otherwise lower the workspace flag.
+ if (readWorkspaceIds && readWorkspaceIds.size > 0) {
+ get().settleWorkspacesAfterExplicitRead(readWorkspaceIds)
+ }
// Dismiss main’s announced subjects; rebuilt IDs cover restart-empty notification records.
if (paneKeys.length > 0 && typeof window !== 'undefined') {
void window.api?.notifications?.dismiss?.([...notificationIdsToDismiss], paneKeys)
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 90dc2950c68..c124cb945aa 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
@@ -35,10 +35,14 @@ export type WorkspaceAttentionSlice = {
markAgentAttentionUnread: (write: AgentAttentionUnreadWrite) => void
/** Read all markers of tabs shown in a focused, present window. screenArrived includes tab/workspace/host/focus changes and settles flags even without removed markers. */
readOnScreenAttention: (screen: OnScreenAttention, options: { screenArrived: boolean }) => void
- /** Sidebar "Mark as read" (also removes every marker in the workspace) / "Mark as unread". */
+ /** Workspaces the user marked unread this session, so the slot can say so instead of guessing. Renderer-local. */
+ workspacesMarkedUnreadByUser: Record
+ /** Sidebar "Mark as read" (also removes every marker and reads every agent in the workspace) / "Mark as unread". */
setWorkspaceUnreadByUser: (
worktreeId: string,
isUnread: boolean,
executionHostId: ExecutionHostId
) => void
+ /** An explicit read elsewhere (Activity, dashboard, a jump) lowers flags left with nothing unread behind them. */
+ settleWorkspacesAfterExplicitRead: (worktreeIds: Iterable) => void
}
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 b58c4a28504..2d7de6d0d16 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
@@ -1,6 +1,5 @@
import type { StateCreator } from 'zustand'
import type { AgentAttentionUnreadReason } from '@/attention/agent-attention-contract'
-import { emitAgentSubjectReads } from '@/attention/agent-subject-read-actions'
import type { AppState } from '../../types'
import type { WorkspaceAttentionSlice } from './workspace-attention-contract'
import {
@@ -73,6 +72,24 @@ export const createWorkspaceAttentionSlice: StateCreator<
return removed
}
const paneAttentionEnabled = (): boolean => get().settings?.experimentalTerminalAttention === true
+ const setWorkspacesMarkedUnreadByUser = (worktreeIds: readonly string[], marked: boolean): void =>
+ set((s) => {
+ const changed = worktreeIds.filter(
+ (worktreeId) => Boolean(s.workspacesMarkedUnreadByUser[worktreeId]) !== marked
+ )
+ if (changed.length === 0) {
+ return s
+ }
+ const next = { ...s.workspacesMarkedUnreadByUser }
+ for (const worktreeId of changed) {
+ if (marked) {
+ next[worktreeId] = true
+ } else {
+ delete next[worktreeId]
+ }
+ }
+ return { workspacesMarkedUnreadByUser: next }
+ })
return {
unreadTerminalTabs: {},
@@ -107,6 +124,10 @@ export const createWorkspaceAttentionSlice: StateCreator<
}
},
readOnScreenAttention: ({ workspaceIds, targets }, { screenArrived }) => {
+ // Arriving fulfils "come back to this", even while other tabs still hold markers.
+ if (screenArrived) {
+ setWorkspacesMarkedUnreadByUser(workspaceIds, false)
+ }
// Read marker-free flags on screen arrival (manual unread/restart/peer), not on every scan.
if (removeAndDismiss(targets.map((target) => target.tabId)) !== null || screenArrived) {
for (const workspaceId of workspaceIds) {
@@ -114,17 +135,52 @@ export const createWorkspaceAttentionSlice: StateCreator<
}
}
},
+ workspacesMarkedUnreadByUser: {},
setWorkspaceUnreadByUser: (worktreeId, isUnread, executionHostId) => {
- if (!isUnread) {
- noteWorkspaceRead(worktreeId)
- const removed = removeAndDismiss(workspaceMarkerTabIds(get(), worktreeId))
- // Journal-positioned chat alerts retire only with the explicit read that bounds them.
- if (removed) {
- emitAgentSubjectReads([...removed.paneKeys], undefined, 'explicit')
+ setWorkspacesMarkedUnreadByUser([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.
+ // Before the acknowledgement below, so its settle finds the flag already down and skips a second persist.
+ 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.
+ const paneKeys = new Set([
+ ...(removed?.paneKeys ?? []),
+ ...workspaceAgentPaneKeys(get(), worktreeId)
+ ])
+ if (paneKeys.size > 0) {
+ get().acknowledgeAgents([...paneKeys], undefined, 'explicit')
+ }
+ },
+ settleWorkspacesAfterExplicitRead: (worktreeIds) => {
+ const marked = get().workspacesMarkedUnreadByUser
+ for (const worktreeId of new Set(worktreeIds)) {
+ // The user's own mark is a source of its own; reading an agent there doesn't answer it.
+ if (!marked[worktreeId]) {
+ settleWorkspaceUnreadFlag(set, get, worktreeId, onScreenHostId(get(), worktreeId))
}
}
- // Update only the clicked host’s row; markers stay workspace-wide because tab keys omit host.
- void get().updateWorktreeMeta(worktreeId, { isUnread }, { executionHostId })
}
}
}
+
+/** Every agent pane, live or retained, that belongs to the workspace. */
+function workspaceAgentPaneKeys(state: AppState, worktreeId: string): string[] {
+ const keys: string[] = []
+ for (const [paneKey, entry] of Object.entries(state.agentStatusByPaneKey)) {
+ if (entry.worktreeId === worktreeId) {
+ keys.push(paneKey)
+ }
+ }
+ for (const [paneKey, retained] of Object.entries(state.retainedAgentsByPaneKey)) {
+ if (retained.worktreeId === worktreeId) {
+ keys.push(paneKey)
+ }
+ }
+ return keys
+}
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 545c3adee74..233015553ec 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
@@ -53,22 +53,34 @@ export function workspaceMarkerTabIds(
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 },
+ workspaceId: string
+): string[] {
+ const completionTabIds = new Set(
+ Object.keys(sources.unreadAgentCompletionPanes).map(paneKeyTabId)
+ )
+ return workspaceMarkerTabs(sources, workspaceId)
+ .filter(
+ (tab) =>
+ Boolean(sources.unreadTerminalTabs?.[tab.id]) ||
+ // Why the current session's key only: a tab rebound to a new session no longer owns the old one.
+ (tab.sessionId === null
+ ? completionTabIds.has(tab.id)
+ : Boolean(
+ sources.unreadAgentCompletionPanes[
+ structuredAgentSessionPaneKey(tab.id, tab.sessionId)
+ ]
+ ))
+ )
+ .map((tab) => tab.id)
+}
+
/** What the workspace flag follows: a tab or completion marker on a live tab. */
export function workspaceHasLiveMarker(
sources: LiveMarkerSources & { unreadAgentCompletionPanes: MarkerRecord },
workspaceId: string
): boolean {
- const completionTabIds = new Set(
- Object.keys(sources.unreadAgentCompletionPanes).map(paneKeyTabId)
- )
- return workspaceMarkerTabs(sources, workspaceId).some(
- (tab) =>
- Boolean(sources.unreadTerminalTabs?.[tab.id]) ||
- // Why the current session's key only: a tab rebound to a new session no longer owns the old one.
- (tab.sessionId === null
- ? completionTabIds.has(tab.id)
- : Boolean(
- sources.unreadAgentCompletionPanes[structuredAgentSessionPaneKey(tab.id, tab.sessionId)]
- ))
- )
+ return workspaceLiveMarkerTabIds(sources, workspaceId).length > 0
}
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
new file mode 100644
index 00000000000..558e089908b
--- /dev/null
+++ b/src/renderer/src/store/slices/workspace-attention/workspace-unread-explicit-read.test.ts
@@ -0,0 +1,76 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type * as WorktreeMetaPersistModule from '../worktrees/metadata/worktree-meta-persist'
+import type { AgentStatusEntry } from '../../../../../shared/agent-status/contracts/agent-status-types'
+import '../../../../../shared/constants'
+import {
+ addLiveTerminalTab,
+ createAttentionStore,
+ installAttentionMockApi,
+ WS_A,
+ type AttentionStore,
+ type PaneTarget
+} from './workspace-unread-characterization-fixture'
+import { completeAgentTurn, toggleUnreadByUser } from './workspace-unread-characterization-driver'
+import { isWorkspaceFlagSet } from './workspace-unread-flag'
+
+vi.mock('sonner', () => ({
+ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn(), dismiss: vi.fn() }
+}))
+
+vi.mock('../worktrees/metadata/worktree-meta-persist', async (importOriginal) => {
+ const actual = await importOriginal()
+ return { ...actual, persistWorktreeMeta: vi.fn(async () => undefined) }
+})
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ vi.useFakeTimers({ toFake: ['Date'] })
+ vi.setSystemTime(1000)
+ installAttentionMockApi()
+})
+
+afterEach(() => {
+ vi.useRealTimers()
+})
+
+function finishedAgentOffScreen(store: AttentionStore): PaneTarget {
+ const pane = addLiveTerminalTab(store, WS_A, 'tab-a1')
+ const entry: AgentStatusEntry = {
+ state: 'done',
+ prompt: 'Fix the flaky test',
+ updatedAt: 900,
+ stateStartedAt: 900,
+ stateHistory: [],
+ agentType: 'claude',
+ paneKey: pane.paneKey,
+ tabId: pane.tabId,
+ worktreeId: WS_A
+ }
+ store.setState({ agentStatusByPaneKey: { [pane.paneKey]: entry } })
+ completeAgentTurn(store, pane)
+ return pane
+}
+
+describe('explicit reads keep the workspace flag and its agents in step', () => {
+ it('Mark read also reads the agents, so their lines lose the dot with the flag', () => {
+ const { store } = createAttentionStore()
+ const pane = finishedAgentOffScreen(store)
+ const row = store.getState().worktreesByRepo.repo1!.find((w) => w.id === WS_A)!
+ expect(row.isUnread).toBe(true)
+
+ toggleUnreadByUser(store, row)
+
+ expect(isWorkspaceFlagSet(store.getState(), WS_A)).toBe(false)
+ expect(store.getState().acknowledgedAgentsByPaneKey[pane.paneKey]).toBeGreaterThanOrEqual(900)
+ })
+
+ it('reading the only unread agent elsewhere lowers the flag instead of leaving a phantom dot', () => {
+ const { store } = createAttentionStore()
+ const pane = finishedAgentOffScreen(store)
+ expect(isWorkspaceFlagSet(store.getState(), WS_A)).toBe(true)
+
+ store.getState().acknowledgeAgents([pane.paneKey], undefined, 'explicit')
+
+ expect(isWorkspaceFlagSet(store.getState(), WS_A)).toBe(false)
+ })
+})