fix commit

This commit is contained in:
Jinjing
2026-11-13 18:25:09 -08:00
parent 6bbd13e994
commit 3faef60173
21 changed files with 393 additions and 88 deletions
@@ -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,
@@ -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 && (
<DropdownMenuItem onSelect={handleToggleRead} disabled={isDeleting}>
{worktree.isUnread ? <BellOff className="size-3.5" /> : <Bell className="size-3.5" />}
{worktree.isUnread
{showsUnread ? <BellOff className="size-3.5" /> : <Bell className="size-3.5" />}
{showsUnread
? t('auto.components.sidebar.WorktreeContextMenu.8dacff1fe0', 'Mark Read')
: t('auto.components.sidebar.WorktreeContextMenu.f50603c6b2', 'Mark Unread')}
</DropdownMenuItem>
@@ -55,6 +55,7 @@ function runNewChildWorktree(args: {
setCreateGroupDialogOpen: () => {},
setWorkspaceUnreadByUser: state.setWorkspaceUnreadByUser,
setWorktreesPinnedAndReveal: state.setWorktreesPinnedAndReveal,
showsUnread: false,
sleepableWorktrees: [],
subtreeSleepableWorktrees: [],
updateWorktreeMeta: state.updateWorktreeMeta,
@@ -53,6 +53,7 @@ export function useWorktreeContextMenuCommands(args: {
setWorktreesPinnedAndReveal: ReturnType<
typeof useAppStore.getState
>['setWorktreesPinnedAndReveal']
showsUnread: boolean
sleepableWorktrees: readonly Worktree[]
subtreeSleepableWorktrees: readonly Worktree[]
updateWorktreeMeta: ReturnType<typeof useAppStore.getState>['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])
@@ -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,
@@ -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'
@@ -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,
@@ -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,
@@ -158,6 +158,29 @@ export const SlotAgentLineRow = React.memo(function SlotAgentLineRow({
) : (
<WorktreeCardSlot signal={slotForLine(displaySignal)} now={now} stale={offline} />
)
// 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 ? (
<button
type="button"
className={cn(
'z-10 inline-flex items-center justify-center text-muted-foreground/70 transition-opacity hover:text-foreground focus-visible:opacity-100 can-hover:group-hover/slot-line:opacity-100',
pillSlot
? 'w-0 overflow-hidden opacity-0 focus-visible:ml-1 focus-visible:w-3 can-hover:group-hover/slot-line:ml-1 can-hover:group-hover/slot-line:w-3'
: 'peer opacity-0'
)}
aria-label={translate(
'auto.components.dashboard.DashboardAgentRow.b06e13fcf7',
'Dismiss agent'
)}
title={translate('auto.components.dashboard.DashboardAgentRow.5ae84475cc', 'Dismiss')}
onClick={handleDismiss}
onMouseDown={stopPropagation}
onKeyDown={stopPropagation}
>
<X className="size-3 shrink-0" />
</button>
) : null
const slot = sendTarget ? (
sendTarget.status === 'eligible' ? (
<span className="inline-flex h-4 shrink-0 items-center gap-1 rounded border border-worktree-sidebar-border px-1.5 text-3xs font-medium text-foreground">
@@ -167,24 +190,16 @@ export const SlotAgentLineRow = React.memo(function SlotAgentLineRow({
) : sendTarget.status === 'sending' ? (
<Loader2 className="size-3 shrink-0 animate-spin text-muted-foreground motion-reduce:animate-none" />
) : null
) : onDismiss ? (
) : dismissButton && pillSlot ? (
<span className="inline-flex shrink-0 items-center">
{lineSlot}
{dismissButton}
</span>
) : 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.
<span className="grid shrink-0 items-center justify-items-end [&>*]:col-start-1 [&>*]:row-start-1">
<button
type="button"
className="peer z-10 inline-flex items-center justify-center text-muted-foreground/70 opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 can-hover:group-hover/slot-line:opacity-100"
aria-label={translate(
'auto.components.dashboard.DashboardAgentRow.b06e13fcf7',
'Dismiss agent'
)}
title={translate('auto.components.dashboard.DashboardAgentRow.5ae84475cc', 'Dismiss')}
onClick={handleDismiss}
onMouseDown={stopPropagation}
onKeyDown={stopPropagation}
>
<X className="size-3" />
</button>
{dismissButton}
<span className="transition-opacity peer-focus-visible:opacity-0 can-hover:group-hover/slot-line:opacity-0">
{lineSlot}
</span>
@@ -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 {
@@ -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({
<DropdownMenuLabel>{label}</DropdownMenuLabel>
{menu.sources.map((target) => (
<SlotTargetMenuItem
key={`${composeWorktreeHostIdentity(target.executionHostId, target.worktreeId)}\n${target.paneKey ?? ''}`}
key={`${composeWorktreeHostIdentity(target.executionHostId, target.worktreeId)}\n${target.paneKey ?? ''}\n${target.tabId ?? ''}`}
target={target}
now={menu.openedAt}
/>
@@ -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'
@@ -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
)
}
@@ -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<string>()
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) {
@@ -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'
+2 -1
View File
@@ -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",
@@ -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<string> {
const ids = new Set<string>()
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<string>()
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)
@@ -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<string, true>
/** 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<string>) => void
}
@@ -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
}
@@ -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
}
@@ -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<typeof WorktreeMetaPersistModule>()
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)
})
})