mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 10:27:33 +00:00
fix commit
This commit is contained in:
@@ -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,
|
||||
|
||||
+3
-2
@@ -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>
|
||||
|
||||
+1
@@ -55,6 +55,7 @@ function runNewChildWorktree(args: {
|
||||
setCreateGroupDialogOpen: () => {},
|
||||
setWorkspaceUnreadByUser: state.setWorkspaceUnreadByUser,
|
||||
setWorktreesPinnedAndReveal: state.setWorktreesPinnedAndReveal,
|
||||
showsUnread: false,
|
||||
sleepableWorktrees: [],
|
||||
subtreeSleepableWorktrees: [],
|
||||
updateWorktreeMeta: state.updateWorktreeMeta,
|
||||
|
||||
+2
-1
@@ -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])
|
||||
|
||||
+7
@@ -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
-1
@@ -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) {
|
||||
|
||||
+3
-2
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+76
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user