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) + }) +})