mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 01:47:24 +00:00
Fix sidebar lineage rollup: unread dot on children, running status on collapsed parent
- A card's unread dot and bold title now include its own visible agent rows' unread results, so expanding a parent reveals which child the unread came from. - A collapsed parent's status lane shows the most urgent descendant state (needs you > failed > working) when it beats the parent's own status. - Host-scoped workspace unread markers and related attention fixes.
This commit is contained in:
+83
@@ -0,0 +1,83 @@
|
||||
import { defineFixture } from '../../../../component-fixtures/component-fixture'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { Worktree } from '../../../../../../shared/worktree/types'
|
||||
import { FIXTURE_REPO, makeWorktree } from '../../worktree-card-slot-fixture-seeds'
|
||||
import WorktreeCard from './WorktreeCard'
|
||||
|
||||
const now = Date.now()
|
||||
const workspace: Worktree = {
|
||||
...makeWorktree({ id: 'details-row', name: 'deploy-preview', agents: [] }, now),
|
||||
comment: 'Preview ready for review',
|
||||
cliProvenance: { kind: 'created-by-cli', createdAt: now },
|
||||
linkedItems: [
|
||||
{
|
||||
provider: 'github',
|
||||
type: 'issue',
|
||||
number: 123,
|
||||
url: 'https://github.com/stablyai/orca/issues/123'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function fixture(detailsRow: boolean, indicators = true) {
|
||||
return defineFixture({
|
||||
width: 320,
|
||||
setup: () =>
|
||||
useAppStore.setState((state) => ({
|
||||
repos: [FIXTURE_REPO],
|
||||
worktreesByRepo: { [FIXTURE_REPO.id]: [workspace] },
|
||||
settings: state.settings
|
||||
? {
|
||||
...state.settings,
|
||||
experimentalNewWorktreeCardStyle: true,
|
||||
worktreeCardDetailsRow: detailsRow
|
||||
}
|
||||
: null,
|
||||
worktreeCardProperties: indicators
|
||||
? ['status', 'branch', 'host', 'issue', 'comment', 'cli', 'ports']
|
||||
: ['status', 'branch'],
|
||||
workspacePortScan: {
|
||||
key: 'details-row',
|
||||
result: {
|
||||
platform: 'unknown',
|
||||
scannedAt: now,
|
||||
ports: [
|
||||
{
|
||||
kind: 'workspace',
|
||||
id: 'preview',
|
||||
bindHost: '127.0.0.1',
|
||||
connectHost: 'localhost',
|
||||
port: 3000,
|
||||
protocol: 'http',
|
||||
owner: {
|
||||
worktreeId: workspace.id,
|
||||
repoId: workspace.repoId,
|
||||
displayName: workspace.displayName,
|
||||
path: workspace.path,
|
||||
confidence: 'cwd'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})),
|
||||
render: () => (
|
||||
<WorktreeCard
|
||||
worktree={workspace}
|
||||
repo={FIXTURE_REPO}
|
||||
isActive={false}
|
||||
hideRepoBadge
|
||||
hostContextLabel="Build server"
|
||||
nativeDragEnabled={false}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export const shown = fixture(true)
|
||||
export const hidden = fixture(false)
|
||||
export const displayOptionsOff = fixture(false, false)
|
||||
export const hiddenWithConflict = defineFixture({
|
||||
...fixture(false),
|
||||
store: { gitConflictOperationByWorktree: { [workspace.id]: 'cherry-pick' } }
|
||||
})
|
||||
+5
-1
@@ -13,6 +13,7 @@ import { FilledBellIcon } from '../identity/worktree-card-glyphs'
|
||||
import { checksLabel } from '../review/workspace-review-check-label'
|
||||
import StatusIndicator from './StatusIndicator'
|
||||
import { useWorktreeActivityStatus } from '../activity/use-worktree-activity-status'
|
||||
import { laneStatusWithLineage } from './worktree-card-lineage-lane-status'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useAppStore } from '@/store/index'
|
||||
import {
|
||||
@@ -35,6 +36,8 @@ type WorktreeCardStatusSlotProps = {
|
||||
showStatus: boolean
|
||||
showUnreadAction: boolean
|
||||
isUnread: boolean
|
||||
/** A collapsed descendant's needs-you, failed or working state; replaces a quieter own status. */
|
||||
lineageStatus?: WorktreeStatus | null
|
||||
unreadTooltip: string
|
||||
onToggleUnread: React.MouseEventHandler<HTMLButtonElement>
|
||||
onPointerDown: React.PointerEventHandler<HTMLButtonElement>
|
||||
@@ -126,6 +129,7 @@ export function WorktreeCardStatusSlot({
|
||||
showStatus,
|
||||
showUnreadAction,
|
||||
isUnread,
|
||||
lineageStatus = null,
|
||||
unreadTooltip,
|
||||
onToggleUnread,
|
||||
onPointerDown,
|
||||
@@ -140,7 +144,7 @@ export function WorktreeCardStatusSlot({
|
||||
const t = useTranslate()
|
||||
const collectionChecks = reviewChecks && reviewChecks.total > 1 ? reviewChecks : undefined
|
||||
const isReviewStale = reviewStale || Boolean(collectionChecks?.stale)
|
||||
const status = useWorktreeActivityStatus(worktreeId)
|
||||
const status = laneStatusWithLineage(useWorktreeActivityStatus(worktreeId), lineageStatus)
|
||||
// One subscription per card: rows mount by the hundreds, and every listener runs on each store write.
|
||||
const { isSleeping, vmRuntime, vmWaking } = useAppStore(
|
||||
useShallow((state) => ({
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { laneStatusWithLineage, lineageLaneStatus } from './worktree-card-lineage-lane-status'
|
||||
|
||||
describe('lineageLaneStatus', () => {
|
||||
it('claims the most urgent loud state and ignores unread', () => {
|
||||
expect(lineageLaneStatus([{ kind: 'unread', at: 1, manual: false }])).toBeNull()
|
||||
expect(
|
||||
lineageLaneStatus([
|
||||
{ kind: 'unread', at: 1, manual: false },
|
||||
{ kind: 'working', at: 2 }
|
||||
])
|
||||
).toBe('working')
|
||||
expect(lineageLaneStatus([{ kind: 'working', at: 2, monitoring: true }])).toBe('monitoring')
|
||||
expect(
|
||||
lineageLaneStatus([
|
||||
{ kind: 'needs', count: 1 },
|
||||
{ kind: 'failed' },
|
||||
{ kind: 'working', at: 2 }
|
||||
])
|
||||
).toBe('permission')
|
||||
})
|
||||
})
|
||||
|
||||
describe('laneStatusWithLineage', () => {
|
||||
it('lets a running descendant replace a quiet parent', () => {
|
||||
expect(laneStatusWithLineage('inactive', 'working')).toBe('working')
|
||||
expect(laneStatusWithLineage('done', 'permission')).toBe('permission')
|
||||
expect(laneStatusWithLineage('active', null)).toBe('active')
|
||||
})
|
||||
|
||||
it('keeps the parent when its own state is as urgent or more', () => {
|
||||
expect(laneStatusWithLineage('permission', 'working')).toBe('permission')
|
||||
expect(laneStatusWithLineage('working', 'monitoring')).toBe('working')
|
||||
expect(laneStatusWithLineage('failed', 'failed')).toBe('failed')
|
||||
})
|
||||
})
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import type { WorktreeStatus } from '../../../../../../shared/worktree-status-rollup'
|
||||
import type { SlotException } from '../../worktree-card-slot-signal'
|
||||
|
||||
// Only the states loud enough to take over a collapsed parent's lane; unread stays on the bold title.
|
||||
const LANE_URGENCY: Partial<Record<WorktreeStatus, number>> = {
|
||||
permission: 0,
|
||||
failed: 1,
|
||||
working: 2,
|
||||
monitoring: 3
|
||||
}
|
||||
|
||||
/** The lane status collapsed descendants would claim: needs you, then failed, then working. */
|
||||
export function lineageLaneStatus(exceptions: readonly SlotException[]): WorktreeStatus | null {
|
||||
let best: WorktreeStatus | null = null
|
||||
for (const exception of exceptions) {
|
||||
const status: WorktreeStatus | null =
|
||||
exception.kind === 'needs'
|
||||
? 'permission'
|
||||
: exception.kind === 'failed'
|
||||
? 'failed'
|
||||
: exception.kind === 'working'
|
||||
? exception.monitoring
|
||||
? 'monitoring'
|
||||
: 'working'
|
||||
: null
|
||||
if (status && (best === null || LANE_URGENCY[status]! < LANE_URGENCY[best]!)) {
|
||||
best = status
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/** The parent's own status, unless a collapsed descendant's is more urgent. */
|
||||
export function laneStatusWithLineage(
|
||||
own: WorktreeStatus,
|
||||
lineage: WorktreeStatus | null | undefined
|
||||
): WorktreeStatus {
|
||||
if (!lineage) {
|
||||
return own
|
||||
}
|
||||
const ownUrgency = LANE_URGENCY[own] ?? Number.POSITIVE_INFINITY
|
||||
return LANE_URGENCY[lineage]! < ownUrgency ? lineage : own
|
||||
}
|
||||
+7
-2
@@ -4,7 +4,10 @@ import { cn } from '@/lib/class-names'
|
||||
import { useWslHostOffForRepo } from '../../use-wsl-host-off-review'
|
||||
import { WorktreeCardStatusSlot } from './WorktreeCardStatusSlot'
|
||||
import type { WorktreeCardController } from '../controller/use-worktree-card-controller'
|
||||
import { useWorktreeCardRolledUpUnread } from './worktree-card-title-status'
|
||||
import {
|
||||
useWorktreeCardLineageLaneStatus,
|
||||
useWorktreeCardRolledUpUnread
|
||||
} from './worktree-card-title-status'
|
||||
|
||||
/** The card's leading status lane: activity, review and the unread toggle or dot. */
|
||||
export function WorktreeCardStatusLane({
|
||||
@@ -15,8 +18,9 @@ export function WorktreeCardStatusLane({
|
||||
showUnreadAction: boolean
|
||||
}): React.JSX.Element {
|
||||
const reviewStale = useWslHostOffForRepo(card.repo)
|
||||
// The dot also speaks for folded agents and collapsed child workspaces.
|
||||
// The dot also speaks for its agents and collapsed child workspaces.
|
||||
const rolledUpUnread = useWorktreeCardRolledUpUnread()
|
||||
const lineageStatus = useWorktreeCardLineageLaneStatus()
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -31,6 +35,7 @@ export function WorktreeCardStatusLane({
|
||||
showStatus={card.showStatus}
|
||||
showUnreadAction={showUnreadAction}
|
||||
isUnread={card.worktree.isUnread || rolledUpUnread}
|
||||
lineageStatus={lineageStatus}
|
||||
unreadTooltip={card.unreadTooltip}
|
||||
onPointerDown={card.stopQuickActionPointerPropagation}
|
||||
onToggleUnread={card.handleToggleUnreadQuick}
|
||||
|
||||
+75
-14
@@ -4,7 +4,15 @@ import { useShallow } from 'zustand/react/shallow'
|
||||
import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData'
|
||||
import { useNow } from '@/hooks/use-now'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { HostScopedWorktreeKey } from '../../../../../../shared/worktree/host-qualified-identity'
|
||||
import type { WorktreeStatus } from '../../../../../../shared/worktree-status-rollup'
|
||||
import { lineageLaneStatus } from './worktree-card-lineage-lane-status'
|
||||
import {
|
||||
composeWorktreeHostIdentity,
|
||||
type HostScopedWorktreeKey
|
||||
} from '../../../../../../shared/worktree/host-qualified-identity'
|
||||
import type { AppState } from '@/store/types'
|
||||
import type { Worktree } from '../../../../../../shared/worktree/types'
|
||||
import { worktreeAgentProjectionInputs } from '../activity/worktree-agent-rows-projection'
|
||||
import { selectAgentRowsUnread } from '../activity/worktree-card-agent-ack-inputs'
|
||||
import { selectSendTargetControlInputs } from '../activity/worktree-card-send-target-inputs'
|
||||
import { useWorktreeAgentExpansionState } from '../activity/worktree-card-agents-expansion-state'
|
||||
@@ -24,6 +32,7 @@ import {
|
||||
slotTargetsFromAgentLines,
|
||||
slotTargetsOfKind,
|
||||
workspaceUnreadTarget,
|
||||
workspaceUnreadTargetsOutsideAgentRows,
|
||||
type SlotTarget
|
||||
} from '../../worktree-card-slot-targets'
|
||||
import {
|
||||
@@ -39,6 +48,10 @@ type TitleStatus = {
|
||||
offline: boolean
|
||||
/** What the title speaks for: hidden agents, collapsed child workspaces, the workspace's own unread. */
|
||||
exceptions: readonly SlotException[]
|
||||
/** Dot and bold: any unread the card holds, including a visible agent line's. */
|
||||
unread: boolean
|
||||
/** The lane status collapsed descendants claim (needs you, failed, working); null when quiet. */
|
||||
lineageStatus: WorktreeStatus | null
|
||||
at: number | null
|
||||
resolveTargets: (kind: SlotException['kind']) => SlotTarget[]
|
||||
}
|
||||
@@ -51,6 +64,31 @@ function useSlotAgentSignals(agents: DashboardAgentRow[]): SlotAgentSignals {
|
||||
return useMemo(() => buildSlotAgentSignals(agents, unread), [agents, unread])
|
||||
}
|
||||
|
||||
function createWorkspaceUnreadSelector(worktree: Worktree, visibleAgents: readonly SlotTarget[]) {
|
||||
const target = workspaceUnreadTarget(
|
||||
worktree.id,
|
||||
worktree.lastActivityAt || null,
|
||||
worktree.hostId
|
||||
)
|
||||
let inputs: readonly unknown[] = []
|
||||
let unread = false
|
||||
return (state: AppState) => {
|
||||
const next = [
|
||||
...worktreeAgentProjectionInputs(state),
|
||||
state.unreadTerminalTabs,
|
||||
state.unreadAgentCompletionPanes,
|
||||
state.workspacesMarkedUnreadByUser
|
||||
]
|
||||
if (!next.every((value, index) => value === inputs[index])) {
|
||||
inputs = next
|
||||
unread =
|
||||
worktree.isUnread &&
|
||||
workspaceUnreadTargetsOutsideAgentRows(state, target, visibleAgents).length > 0
|
||||
}
|
||||
return unread
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the card's rolled-up status once, for the status lane, the title and the title's
|
||||
* right-hand slot: rows mount by the hundreds, so the three must not each subscribe.
|
||||
@@ -66,7 +104,8 @@ export function WorktreeCardTitleStatusProvider({
|
||||
const agents = card.inlineAgentRows
|
||||
const signals = useSlotAgentSignals(agents)
|
||||
const offline = card.isRuntimeDisconnected || card.isSshDisconnected
|
||||
const markedByUser = useAppStore((s) => Boolean(s.workspacesMarkedUnreadByUser[worktree.id]))
|
||||
const identity = composeWorktreeHostIdentity(worktree.hostId ?? 'local', worktree.id)
|
||||
const markedByUser = useAppStore((s) => Boolean(s.workspacesMarkedUnreadByUser[identity]))
|
||||
const pickingSendTarget = useAppStore(
|
||||
(s) => selectSendTargetControlInputs(s, worktree.id).targetMode !== null
|
||||
)
|
||||
@@ -83,9 +122,16 @@ export function WorktreeCardTitleStatusProvider({
|
||||
const collapsedLineage = useCollapsedLineageSlots(hiddenDescendants)
|
||||
const lineageLatestAt = useLineageLatestAt(descendants)
|
||||
const agentUnread = signals.allSignals.some((signal) => signal.kind === 'unread')
|
||||
// Any unread source counts (a terminal tab, a pane, the user's mark) unless a visible row already shows it.
|
||||
const workspaceUnread = worktree.isUnread && !(linesShown && agentUnread)
|
||||
const lastActivityAt = worktree.lastActivityAt > 0 ? worktree.lastActivityAt : null
|
||||
const visibleAgentTargets = useMemo(
|
||||
() => (linesShown ? slotTargetsFromAgentLines(worktree.id, signals, worktree.hostId) : []),
|
||||
[linesShown, signals, worktree.hostId, worktree.id]
|
||||
)
|
||||
const selectWorkspaceUnread = useMemo(
|
||||
() => createWorkspaceUnreadSelector(worktree, visibleAgentTargets),
|
||||
[visibleAgentTargets, worktree]
|
||||
)
|
||||
const workspaceUnread = useAppStore(selectWorkspaceUnread)
|
||||
const slot = titleRowSlot({
|
||||
offline,
|
||||
lines: signals.allSignals,
|
||||
@@ -99,13 +145,19 @@ export function WorktreeCardTitleStatusProvider({
|
||||
(kind: SlotException['kind']) => {
|
||||
const state = useAppStore.getState()
|
||||
return slotTargetsOfKind(
|
||||
expandWorkspaceUnreadTargets(state, [
|
||||
...(linesShown ? [] : slotTargetsFromAgentLines(worktree.id, signals, worktree.hostId)),
|
||||
...collectLineageSlotTargets(state, hiddenDescendants),
|
||||
[
|
||||
...expandWorkspaceUnreadTargets(state, [
|
||||
...(linesShown ? [] : slotTargetsFromAgentLines(worktree.id, signals, worktree.hostId)),
|
||||
...collectLineageSlotTargets(state, hiddenDescendants)
|
||||
]),
|
||||
...(workspaceUnread
|
||||
? [workspaceUnreadTarget(worktree.id, lastActivityAt, worktree.hostId)]
|
||||
? workspaceUnreadTargetsOutsideAgentRows(
|
||||
state,
|
||||
workspaceUnreadTarget(worktree.id, lastActivityAt, worktree.hostId),
|
||||
visibleAgentTargets
|
||||
)
|
||||
: [])
|
||||
]),
|
||||
],
|
||||
kind
|
||||
)
|
||||
},
|
||||
@@ -114,6 +166,7 @@ export function WorktreeCardTitleStatusProvider({
|
||||
lastActivityAt,
|
||||
linesShown,
|
||||
signals,
|
||||
visibleAgentTargets,
|
||||
workspaceUnread,
|
||||
worktree.hostId,
|
||||
worktree.id
|
||||
@@ -121,17 +174,25 @@ export function WorktreeCardTitleStatusProvider({
|
||||
)
|
||||
const exceptions = slot.kind === 'status' ? slot.exceptions : NO_EXCEPTIONS
|
||||
const at = slot.kind === 'status' ? slot.at : null
|
||||
// Why: a collapsed parent rolls up its children's unread agent lines, so each child must show
|
||||
// the same dot even when the line is visible, or expanding hides where the unread came from.
|
||||
const unread = agentUnread || exceptions.some((exception) => exception.kind === 'unread')
|
||||
const lineageStatus = lineageLaneStatus(collapsedLineage)
|
||||
const value = useMemo<TitleStatus>(
|
||||
() => ({ offline, exceptions, at, resolveTargets }),
|
||||
[at, exceptions, offline, resolveTargets]
|
||||
() => ({ offline, exceptions, unread, lineageStatus, at, resolveTargets }),
|
||||
[at, exceptions, lineageStatus, offline, resolveTargets, unread]
|
||||
)
|
||||
return <TitleStatusContext.Provider value={value}>{children}</TitleStatusContext.Provider>
|
||||
}
|
||||
|
||||
/** True while the card speaks for an unread result: its own, a folded agent's or a collapsed child's. */
|
||||
/** True while the card holds an unread result: its own, any of its agents' or a collapsed child's. */
|
||||
export function useWorktreeCardRolledUpUnread(): boolean {
|
||||
const status = useContext(TitleStatusContext)
|
||||
return status?.exceptions.some((exception) => exception.kind === 'unread') === true
|
||||
return useContext(TitleStatusContext)?.unread === true
|
||||
}
|
||||
|
||||
/** What collapsed child workspaces claim for the lane, so a running child shows on its parent. */
|
||||
export function useWorktreeCardLineageLaneStatus(): WorktreeStatus | null {
|
||||
return useContext(TitleStatusContext)?.lineageStatus ?? null
|
||||
}
|
||||
|
||||
/** The title row's right edge: a pill per loud status the card speaks for, then the time. */
|
||||
|
||||
+7
-2
@@ -23,7 +23,7 @@
|
||||
"ports": "options.show.ports && ports.count > 0",
|
||||
"indicators": "vars.details || vars.ports",
|
||||
"metaIndicators": "!vars.compact && vars.metaRow && vars.indicators",
|
||||
"titleIndicators": "vars.compact && vars.indicators",
|
||||
"titleIndicators": "!vars.metaRow && vars.indicators",
|
||||
"hasMetaRow": "vars.metaRow ? vars.metaRepo || vars.hostBadge || vars.folderInMeta || vars.branch || vars.identityInMeta || vars.detachedInMeta || vars.conflict || git.cacheTimerActive || vars.metaIndicators : vars.conflict || git.cacheTimerActive",
|
||||
"star": "vars.compact && ws.isMain && !vars.folder",
|
||||
"headerActions": "vars.star || place.deleteQuickAction",
|
||||
@@ -74,6 +74,11 @@
|
||||
"when": "ws.isSparse",
|
||||
"props": { "hasHoverDetails": "=vars.hoverDetails && !ws.isRenaming" }
|
||||
},
|
||||
{
|
||||
"el": "HostBadge",
|
||||
"when": "vars.newStyle && !vars.metaRow && vars.hostBadge",
|
||||
"props": { "label": "=vars.hostLabel" }
|
||||
},
|
||||
{
|
||||
"el": "Row",
|
||||
"when": "vars.titleIndicators",
|
||||
@@ -130,7 +135,7 @@
|
||||
{ "el": "MetaRepoBadge", "when": "vars.metaRepo" },
|
||||
{
|
||||
"el": "HostBadge",
|
||||
"when": "vars.hostBadge",
|
||||
"when": "vars.metaRow && vars.hostBadge",
|
||||
"props": { "label": "=vars.hostLabel" }
|
||||
},
|
||||
{
|
||||
|
||||
@@ -19,7 +19,11 @@ import {
|
||||
selectCollapsedLineageSlots,
|
||||
selectLineageLatestAt
|
||||
} from './worktree-card-slot-rollup'
|
||||
import { slotTargetsOfKind, workspaceUnreadTarget } from './worktree-card-slot-targets'
|
||||
import {
|
||||
slotTargetsOfKind,
|
||||
workspaceUnreadTarget,
|
||||
workspaceUnreadTargetsOutsideAgentRows
|
||||
} from './worktree-card-slot-targets'
|
||||
import { fromLineageSlotRecord } from './worktree-card-slot-summary'
|
||||
|
||||
function collapsedSlot(...args: Parameters<typeof selectCollapsedLineageSlots>) {
|
||||
@@ -61,7 +65,10 @@ beforeEach(() => {
|
||||
runtimeAgentOrchestrationByPaneKey: {},
|
||||
paneForegroundAgentByPaneKey: {},
|
||||
worktreesByRepo: {},
|
||||
repos: []
|
||||
repos: [],
|
||||
unreadTerminalTabs: {},
|
||||
unreadAgentCompletionPanes: {},
|
||||
workspacesMarkedUnreadByUser: {}
|
||||
})
|
||||
})
|
||||
afterEach(() => {
|
||||
@@ -142,6 +149,66 @@ it('expires hidden working evidence on the same freshness tick as agent rows', (
|
||||
expect(collapsedSlot(useAppStore.getState(), [identity])).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps independent bells and completion panes in title targets alongside a visible unread agent', () => {
|
||||
useAppStore.setState({
|
||||
tabsByWorktree: {
|
||||
child: ['agent', 'shell', 'remote'].map((id) =>
|
||||
makeTab({
|
||||
id,
|
||||
worktreeId: 'child',
|
||||
ptyId: id === 'remote' ? 'remote:box@@shell' : `pty-${id}`,
|
||||
title: ''
|
||||
})
|
||||
)
|
||||
},
|
||||
agentStatusByPaneKey: { [pane('agent')]: entry('agent', { state: 'done' }) },
|
||||
unreadAgentCompletionPanes: { [pane('agent')]: 'agent-completion' },
|
||||
unreadTerminalTabs: { shell: 'terminal-bell', remote: 'terminal-bell' }
|
||||
})
|
||||
const target = workspaceUnreadTarget('child', 1_950_000, 'local')
|
||||
const visibleAgents = collectLineageSlotTargets(useAppStore.getState(), [identity])
|
||||
expect(
|
||||
workspaceUnreadTargetsOutsideAgentRows(useAppStore.getState(), target, visibleAgents).map(
|
||||
(target) => target.tabId
|
||||
)
|
||||
).toEqual(['shell'])
|
||||
|
||||
useAppStore.setState({ unreadTerminalTabs: {} })
|
||||
expect(
|
||||
workspaceUnreadTargetsOutsideAgentRows(useAppStore.getState(), target, visibleAgents)
|
||||
).toEqual([])
|
||||
|
||||
const otherPane = 'agent:22222222-2222-4222-8222-222222222222'
|
||||
useAppStore.setState({
|
||||
unreadAgentCompletionPanes: {
|
||||
[pane('agent')]: 'agent-completion',
|
||||
[otherPane]: 'agent-completion'
|
||||
}
|
||||
})
|
||||
expect(
|
||||
workspaceUnreadTargetsOutsideAgentRows(useAppStore.getState(), target, visibleAgents).map(
|
||||
(target) => target.tabId
|
||||
)
|
||||
).toEqual(['agent'])
|
||||
|
||||
useAppStore.setState({
|
||||
unreadAgentCompletionPanes: { [pane('agent')]: 'agent-completion' },
|
||||
workspacesMarkedUnreadByUser: { [identity]: true }
|
||||
})
|
||||
expect(
|
||||
workspaceUnreadTargetsOutsideAgentRows(useAppStore.getState(), target, visibleAgents).map(
|
||||
(target) => ({ paneKey: target.paneKey, tabId: target.tabId, marked: target.markedByUser })
|
||||
)
|
||||
).toEqual([{ paneKey: null, tabId: undefined, marked: true }])
|
||||
|
||||
useAppStore.setState({ unreadAgentCompletionPanes: {}, workspacesMarkedUnreadByUser: {} })
|
||||
expect(
|
||||
workspaceUnreadTargetsOutsideAgentRows(useAppStore.getState(), target, visibleAgents).map(
|
||||
(target) => ({ paneKey: target.paneKey, tabId: target.tabId, marked: target.markedByUser })
|
||||
)
|
||||
).toEqual([{ paneKey: null, tabId: undefined, marked: false }])
|
||||
})
|
||||
|
||||
it('updates hidden subtree attention after status changes, acknowledgement, and freshness expiry', () => {
|
||||
const parent = makeWorktree({ id: 'parent', repoId: 'repo1', hostId: 'local', lastActivityAt: 0 })
|
||||
const child = makeWorktree({ id: 'child', repoId: 'repo1', hostId: 'local', lastActivityAt: 0 })
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
fromLineageSlotRecord,
|
||||
type LineageSlotRecord
|
||||
} from './worktree-card-slot-summary'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { AppState } from '@/store/types'
|
||||
@@ -190,8 +190,20 @@ export function selectLineageLatestAt(
|
||||
return at || null
|
||||
}
|
||||
|
||||
function createLineageLatestAtSelector(source: WorktreeLineageSource) {
|
||||
let inputs: readonly unknown[] = []
|
||||
let at: number | null = null
|
||||
return (state: AppState) => {
|
||||
const next = worktreeAgentProjectionInputs(state)
|
||||
if (!next.every((value, index) => value === inputs[index])) {
|
||||
inputs = next
|
||||
at = selectLineageLatestAt(state, source)
|
||||
}
|
||||
return at
|
||||
}
|
||||
}
|
||||
|
||||
export function useLineageLatestAt(source: WorktreeLineageSource): number | null {
|
||||
return useAppStore(
|
||||
useCallback((state: AppState) => selectLineageLatestAt(state, source), [source])
|
||||
)
|
||||
const select = useMemo(() => createLineageLatestAtSelector(source), [source])
|
||||
return useAppStore(select)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { activateStructuredAgentSessionTab } from '@/lib/agent-session/structure
|
||||
import { activateTabAndFocusPane } from '@/lib/workspace/tabs/activate-tab-and-focus-pane'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { AppState } from '@/store/types'
|
||||
import { workspaceMarkerTabIdsForHost } from '@/store/slices/workspace-attention/workspace-marker-host'
|
||||
import {
|
||||
paneKeyTabId,
|
||||
workspaceLiveMarkerTabIds
|
||||
@@ -31,6 +32,9 @@ export type SlotTarget = SlotLineSignal & {
|
||||
tabId?: string
|
||||
/** The user marked the workspace unread; it stands on its own instead of yielding to agents. */
|
||||
markedByUser?: boolean
|
||||
/** Completion subjects represented by this tab target; bells remain independent of agent rows. */
|
||||
unreadPaneKeys?: readonly string[]
|
||||
terminalBell?: boolean
|
||||
retained: boolean
|
||||
agentType: AgentType | null
|
||||
entry: AgentStatusEntry | null
|
||||
@@ -92,17 +96,36 @@ export function expandWorkspaceUnreadTargets(
|
||||
if (target.kind !== 'unread' || target.paneKey !== null || target.tabId !== undefined) {
|
||||
return [target]
|
||||
}
|
||||
const markedByUser = Boolean(state.workspacesMarkedUnreadByUser[target.worktreeId])
|
||||
const tabTargets = workspaceLiveMarkerTabIds(state, target.worktreeId).map(
|
||||
(tabId): SlotTarget => ({ ...target, tabId })
|
||||
const markedByUser = Boolean(
|
||||
state.workspacesMarkedUnreadByUser[
|
||||
composeWorktreeHostIdentity(target.executionHostId ?? 'local', target.worktreeId)
|
||||
]
|
||||
)
|
||||
const ownedTabs = workspaceMarkerTabIdsForHost(state, target.worktreeId, target.executionHostId)
|
||||
const completionKeys = Object.keys(state.unreadAgentCompletionPanes)
|
||||
const representedTabs = getStructuredAgentSessionTabsByPaneKey(
|
||||
state.unifiedTabsByWorktree[target.worktreeId]
|
||||
)
|
||||
const structuredKeysByTabId = new Map([...representedTabs].map(([key, tab]) => [tab.id, key]))
|
||||
const tabTargets = workspaceLiveMarkerTabIds(state, target.worktreeId)
|
||||
.filter((tabId) => ownedTabs.has(tabId))
|
||||
.map((tabId): SlotTarget => ({
|
||||
...target,
|
||||
tabId,
|
||||
terminalBell: state.unreadTerminalTabs[tabId] === 'terminal-bell',
|
||||
unreadPaneKeys: completionKeys.filter(
|
||||
(key) =>
|
||||
paneKeyTabId(key) === tabId &&
|
||||
(!structuredKeysByTabId.has(tabId) || structuredKeysByTabId.get(tabId) === key)
|
||||
)
|
||||
}))
|
||||
return tabTargets.length === 0 || markedByUser
|
||||
? [...tabTargets, { ...target, markedByUser }]
|
||||
: tabTargets
|
||||
})
|
||||
}
|
||||
|
||||
function workspaceKey(target: SlotTarget): string {
|
||||
function workspaceKey(target: SlotTarget) {
|
||||
return composeWorktreeHostIdentity(target.executionHostId, target.worktreeId)
|
||||
}
|
||||
|
||||
@@ -113,20 +136,27 @@ export function slotTargetsOfKind(
|
||||
): SlotTarget[] {
|
||||
const ofKind = targets.filter((target) => target.kind === kind)
|
||||
const agentTargets = ofKind.filter((target) => target.paneKey !== null)
|
||||
const withAgent = new Set(agentTargets.map(workspaceKey))
|
||||
const agentTabs = new Set(
|
||||
agentTargets.map((target) => `${workspaceKey(target)}\n${paneKeyTabId(target.paneKey!) ?? ''}`)
|
||||
)
|
||||
const agentPanes = new Set(
|
||||
agentTargets.map((target) => `${workspaceKey(target)}\n${target.paneKey}`)
|
||||
)
|
||||
const seen = new Set<string>()
|
||||
return ofKind
|
||||
.filter((target) => {
|
||||
if (target.paneKey !== null || target.markedByUser) {
|
||||
if (target.paneKey !== null || target.markedByUser || target.terminalBell) {
|
||||
return true
|
||||
}
|
||||
if (target.tabId !== undefined && target.unreadPaneKeys?.length) {
|
||||
return !target.unreadPaneKeys.every((key) =>
|
||||
agentPanes.has(`${workspaceKey(target)}\n${key}`)
|
||||
)
|
||||
}
|
||||
// A marker tab whose agent already has its own target is that agent's unread, not another one.
|
||||
return target.tabId !== undefined
|
||||
? !agentTabs.has(`${workspaceKey(target)}\n${target.tabId}`)
|
||||
: !withAgent.has(workspaceKey(target))
|
||||
: true
|
||||
})
|
||||
.filter((target) => {
|
||||
const key = `${workspaceKey(target)}\n${target.paneKey ?? ''}\n${target.tabId ?? ''}`
|
||||
@@ -139,6 +169,18 @@ export function slotTargetsOfKind(
|
||||
.sort((a, b) => (b.at ?? 0) - (a.at ?? 0))
|
||||
}
|
||||
|
||||
/** Only the unread sources not already represented by the card's visible agent rows. */
|
||||
export function workspaceUnreadTargetsOutsideAgentRows(
|
||||
state: AppState,
|
||||
target: SlotTarget,
|
||||
visibleAgents: readonly SlotTarget[]
|
||||
): SlotTarget[] {
|
||||
return slotTargetsOfKind(
|
||||
[...visibleAgents, ...expandWorkspaceUnreadTargets(state, [target])],
|
||||
'unread'
|
||||
).filter((candidate) => candidate.paneKey === null)
|
||||
}
|
||||
|
||||
/** Open the target the way its own row would, after unfolding its card's agent list. */
|
||||
export function openSlotTarget(target: SlotTarget): void {
|
||||
if (target.tabId !== undefined) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { StateCreator } from 'zustand'
|
||||
import type { StoredAgentAttentionUnread } from '@/attention/agent-attention-contract'
|
||||
import type { AgentAttentionUnreadWrite } from '@/attention/agent-attention-policy'
|
||||
import type { ExecutionHostId } from '../../../../../shared/execution-host'
|
||||
import type { HostScopedWorktreeKey } from '../../../../../shared/worktree/host-qualified-identity'
|
||||
import type { AppState } from '../../types'
|
||||
|
||||
export type WorkspaceAttentionSet = Parameters<StateCreator<AppState>>[0]
|
||||
@@ -38,8 +39,8 @@ export type WorkspaceAttentionSlice = {
|
||||
screen: OnScreenAttention,
|
||||
options: { screenArrived: boolean; markersRead?: boolean }
|
||||
) => void
|
||||
/** Workspaces the user marked unread this session, so the slot can say so instead of guessing. Renderer-local. */
|
||||
workspacesMarkedUnreadByUser: Record<string, true>
|
||||
/** Host-qualified workspaces the user marked unread this session. Renderer-local. */
|
||||
workspacesMarkedUnreadByUser: Partial<Record<HostScopedWorktreeKey, true>>
|
||||
/** Sidebar "Mark as read" (also removes every marker and reads every agent in the workspace) / "Mark as unread". */
|
||||
setWorkspaceUnreadByUser: (
|
||||
worktreeId: string,
|
||||
|
||||
@@ -15,7 +15,13 @@ import {
|
||||
settleWorkspaceUnreadFlag,
|
||||
noteWorkspaceRead
|
||||
} from './workspace-unread-flag'
|
||||
import { workspaceMarkerTabIds, workspaceMarkerTabs, paneKeyTabId } from './workspace-live-markers'
|
||||
import { workspaceMarkerTabs, paneKeyTabId } from './workspace-live-markers'
|
||||
import { workspaceMarkerTabIdsForHost } from './workspace-marker-host'
|
||||
import {
|
||||
composeWorktreeHostIdentity,
|
||||
type HostScopedWorktreeKey
|
||||
} from '../../../../../shared/worktree/host-qualified-identity'
|
||||
import { getResolvedExecutionHostIdForWorktree } from '@/lib/workspace/ownership/resolved-worktree-execution-host'
|
||||
import { getAgentPaneExecutionHost } from '../agent-status/agent-pane-execution-host'
|
||||
import { resolveAgentStatusWorktreeId } from '../../../../../shared/agent-status/identity/agent-status-worktree-attribution'
|
||||
import { isTabOnScreen } from '@/hooks/agent-auto-ack-targets'
|
||||
@@ -74,20 +80,23 @@ export const createWorkspaceAttentionSlice: StateCreator<
|
||||
return removed
|
||||
}
|
||||
const paneAttentionEnabled = (): boolean => get().settings?.experimentalTerminalAttention === true
|
||||
const setWorkspacesMarkedUnreadByUser = (worktreeIds: readonly string[], marked: boolean): void =>
|
||||
const setWorkspacesMarkedUnreadByUser = (
|
||||
identities: readonly HostScopedWorktreeKey[],
|
||||
marked: boolean
|
||||
): void =>
|
||||
set((s) => {
|
||||
const changed = worktreeIds.filter(
|
||||
(worktreeId) => Boolean(s.workspacesMarkedUnreadByUser[worktreeId]) !== marked
|
||||
const changed = identities.filter(
|
||||
(identity) => Boolean(s.workspacesMarkedUnreadByUser[identity]) !== marked
|
||||
)
|
||||
if (changed.length === 0) {
|
||||
return s
|
||||
}
|
||||
const next = { ...s.workspacesMarkedUnreadByUser }
|
||||
for (const worktreeId of changed) {
|
||||
for (const identity of changed) {
|
||||
if (marked) {
|
||||
next[worktreeId] = true
|
||||
next[identity] = true
|
||||
} else {
|
||||
delete next[worktreeId]
|
||||
delete next[identity]
|
||||
}
|
||||
}
|
||||
return { workspacesMarkedUnreadByUser: next }
|
||||
@@ -128,7 +137,15 @@ export const createWorkspaceAttentionSlice: StateCreator<
|
||||
readOnScreenAttention: ({ workspaceIds, targets }, { screenArrived, markersRead }) => {
|
||||
// Arriving fulfils "come back to this", even while other tabs still hold markers.
|
||||
if (screenArrived) {
|
||||
setWorkspacesMarkedUnreadByUser(workspaceIds, false)
|
||||
const state = get()
|
||||
setWorkspacesMarkedUnreadByUser(
|
||||
workspaceIds.flatMap((id) => {
|
||||
const host =
|
||||
onScreenHostId(state, id) ?? getResolvedExecutionHostIdForWorktree(state, id)
|
||||
return host ? [composeWorktreeHostIdentity(host, id)] : []
|
||||
}),
|
||||
false
|
||||
)
|
||||
}
|
||||
// Agent acknowledgement may already have removed this scan's completion markers.
|
||||
if (
|
||||
@@ -143,14 +160,18 @@ export const createWorkspaceAttentionSlice: StateCreator<
|
||||
},
|
||||
workspacesMarkedUnreadByUser: {},
|
||||
setWorkspaceUnreadByUser: (worktreeId, isUnread, executionHostId) => {
|
||||
setWorkspacesMarkedUnreadByUser([worktreeId], isUnread)
|
||||
setWorkspacesMarkedUnreadByUser(
|
||||
[composeWorktreeHostIdentity(executionHostId, worktreeId)],
|
||||
isUnread
|
||||
)
|
||||
if (isUnread) {
|
||||
void get().updateWorktreeMeta(worktreeId, { isUnread }, { executionHostId })
|
||||
return
|
||||
}
|
||||
noteWorkspaceRead(worktreeId)
|
||||
const removed = removeAndDismiss(workspaceMarkerTabIds(get(), worktreeId))
|
||||
// Update only the clicked host’s row; markers stay workspace-wide because tab keys omit host.
|
||||
const removed = removeAndDismiss(
|
||||
workspaceMarkerTabIdsForHost(get(), worktreeId, executionHostId)
|
||||
)
|
||||
void get().updateWorktreeMeta(worktreeId, { isUnread }, { executionHostId })
|
||||
// Mark read reads the agents too, or their lines keep the dot the user just cleared.
|
||||
// Journal-positioned chat alerts retire only with this explicit read that bounds them.
|
||||
|
||||
@@ -46,13 +46,6 @@ export function hasLiveTabMarker(sources: LiveMarkerSources, workspaceId: string
|
||||
)
|
||||
}
|
||||
|
||||
export function workspaceMarkerTabIds(
|
||||
sources: LiveMarkerSources,
|
||||
workspaceId: string
|
||||
): Set<string> {
|
||||
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 },
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { AppState } from '../../types'
|
||||
import type { ExecutionHostId } from '../../../../../shared/execution-host'
|
||||
import { structuredAgentSessionPaneKey } from '../../../../../shared/agent-session/view/structured-agent-session-projection'
|
||||
import { getAgentPaneExecutionHost } from '../agent-status/agent-pane-execution-host'
|
||||
import { workspaceMarkerTabs, type MarkerTab } from './workspace-live-markers'
|
||||
|
||||
function markerTabHost(state: AppState, workspaceId: string, tab: MarkerTab) {
|
||||
const terminal = state.tabsByWorktree[workspaceId]?.find((candidate) => candidate.id === tab.id)
|
||||
const layout = state.terminalLayoutsByTabId[tab.id]
|
||||
const paneKey =
|
||||
tab.sessionId === null
|
||||
? `${tab.id}:${layout?.activeLeafId ?? ''}`
|
||||
: structuredAgentSessionPaneKey(tab.id, tab.sessionId)
|
||||
const entry = state.agentStatusByPaneKey[paneKey]
|
||||
const ptyId =
|
||||
(layout?.activeLeafId ? layout.ptyIdsByLeafId?.[layout.activeLeafId] : undefined) ??
|
||||
terminal?.ptyId ??
|
||||
state.ptyIdsByTabId[tab.id]?.[0]
|
||||
if (tab.sessionId === null && !entry && !ptyId) {
|
||||
const stampedHost = state.unifiedTabsByWorktree[workspaceId]?.find(
|
||||
(candidate) => candidate.contentType === 'terminal' && candidate.entityId === tab.id
|
||||
)?.executionHostId
|
||||
if (stampedHost) {
|
||||
return stampedHost
|
||||
}
|
||||
}
|
||||
return getAgentPaneExecutionHost(state, paneKey, entry, ptyId)
|
||||
}
|
||||
|
||||
/** Filter before removing markers or resolving jumps: workspace IDs can name several hosts. */
|
||||
export function workspaceMarkerTabIdsForHost(
|
||||
state: AppState,
|
||||
workspaceId: string,
|
||||
executionHostId: ExecutionHostId | undefined
|
||||
): Set<string> {
|
||||
return new Set(
|
||||
workspaceMarkerTabs(state, workspaceId)
|
||||
.filter(
|
||||
(tab) =>
|
||||
executionHostId === undefined ||
|
||||
markerTabHost(state, workspaceId, tab) === executionHostId
|
||||
)
|
||||
.map((tab) => tab.id)
|
||||
)
|
||||
}
|
||||
+100
@@ -17,6 +17,11 @@ import { makeTab } from '../../terminals/terminal-tab.test-fixture'
|
||||
import { makeUnifiedTab } from '../tabs/tab-group.test-fixture'
|
||||
import { structuredAgentSessionPaneKey } from '../../../../../shared/agent-session/view/structured-agent-session-projection'
|
||||
import { publicRuntimeEnvironment } from '@/runtime/rpc/testing/paired-runtime-environment-test-fixture'
|
||||
import { makeWorktree } from '../worktrees/worktree.test-fixture'
|
||||
import {
|
||||
expandWorkspaceUnreadTargets,
|
||||
workspaceUnreadTarget
|
||||
} from '@/components/sidebar/worktree-card-slot-targets'
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn(), dismiss: vi.fn() }
|
||||
@@ -57,6 +62,101 @@ function finishedAgentOffScreen(store: AttentionStore): PaneTarget {
|
||||
}
|
||||
|
||||
describe('explicit workspace and agent reads', () => {
|
||||
it('keeps another host’s manual unread target when arriving at a same-ID workspace', () => {
|
||||
const { store } = createAttentionStore({ windowOnScreen: false })
|
||||
store.setState({
|
||||
worktreesByRepo: {
|
||||
repo1: [
|
||||
makeWorktree({ id: WS_A, repoId: 'repo1', hostId: 'local' }),
|
||||
makeWorktree({ id: WS_A, repoId: 'repo1', hostId: 'runtime:box' })
|
||||
]
|
||||
},
|
||||
activeWorktreeId: WS_A,
|
||||
activeWorkspaceExecutionHostId: 'local'
|
||||
})
|
||||
store.getState().setWorkspaceUnreadByUser(WS_A, true, 'local')
|
||||
const local = workspaceUnreadTarget(WS_A, 900, 'local')
|
||||
const remote = workspaceUnreadTarget(WS_A, 900, 'runtime:box')
|
||||
expect(
|
||||
expandWorkspaceUnreadTargets(store.getState(), [local, remote]).map((target) => [
|
||||
target.executionHostId,
|
||||
target.markedByUser
|
||||
])
|
||||
).toEqual([
|
||||
['local', true],
|
||||
['runtime:box', false]
|
||||
])
|
||||
|
||||
store.getState().setWorkspaceUnreadByUser(WS_A, true, 'runtime:box')
|
||||
store
|
||||
.getState()
|
||||
.readOnScreenAttention({ workspaceIds: [WS_A], targets: [] }, { screenArrived: true })
|
||||
expect(
|
||||
expandWorkspaceUnreadTargets(store.getState(), [local, remote]).map((target) => [
|
||||
target.executionHostId,
|
||||
target.markedByUser
|
||||
])
|
||||
).toEqual([
|
||||
['local', false],
|
||||
['runtime:box', true]
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves another host’s tab markers and notifications when marking a same-ID workspace read', () => {
|
||||
const dismiss = installAttentionMockApi()
|
||||
const { store } = createAttentionStore({ windowOnScreen: false })
|
||||
const local = addLiveTerminalTab(store, WS_A, 'local')
|
||||
const remote = addLiveTerminalTab(store, WS_A, 'remote')
|
||||
const state = store.getState()
|
||||
store.setState({
|
||||
tabsByWorktree: {
|
||||
...state.tabsByWorktree,
|
||||
[WS_A]: state.tabsByWorktree[WS_A]!.map((tab) =>
|
||||
tab.id === 'remote' ? { ...tab, ptyId: null } : tab
|
||||
)
|
||||
},
|
||||
unifiedTabsByWorktree: {
|
||||
...state.unifiedTabsByWorktree,
|
||||
[WS_A]: [
|
||||
...state.unifiedTabsByWorktree[WS_A]!.filter((tab) => tab.entityId !== 'remote'),
|
||||
makeUnifiedTab({
|
||||
id: 'unified-remote',
|
||||
entityId: 'remote',
|
||||
worktreeId: WS_A,
|
||||
groupId: 'group',
|
||||
contentType: 'terminal',
|
||||
executionHostId: 'runtime:box'
|
||||
})
|
||||
]
|
||||
},
|
||||
ptyIdsByTabId: { ...state.ptyIdsByTabId, remote: [] },
|
||||
terminalLayoutsByTabId: {
|
||||
...state.terminalLayoutsByTabId,
|
||||
remote: {
|
||||
...state.terminalLayoutsByTabId.remote!,
|
||||
ptyIdsByLeafId: {}
|
||||
}
|
||||
},
|
||||
unreadTerminalTabs: { local: 'terminal-bell', remote: 'terminal-bell' },
|
||||
unreadTerminalPanes: { [local.paneKey]: 'terminal-bell', [remote.paneKey]: 'terminal-bell' },
|
||||
unreadAgentCompletionPanes: {
|
||||
[local.paneKey]: 'agent-completion',
|
||||
[remote.paneKey]: 'agent-completion'
|
||||
}
|
||||
})
|
||||
|
||||
store.getState().setWorkspaceUnreadByUser(WS_A, false, 'local')
|
||||
|
||||
expect(store.getState().unreadTerminalTabs).toEqual({ remote: 'terminal-bell' })
|
||||
expect(store.getState().unreadTerminalPanes).toEqual({
|
||||
'remote:11111111-1111-4111-8111-111111111111': 'terminal-bell'
|
||||
})
|
||||
expect(store.getState().unreadAgentCompletionPanes).toEqual({
|
||||
'remote:11111111-1111-4111-8111-111111111111': 'agent-completion'
|
||||
})
|
||||
expect(dismiss).toHaveBeenCalledWith([], ['local:11111111-1111-4111-8111-111111111111'])
|
||||
})
|
||||
|
||||
it('Mark read also reads the agents, so their lines lose the dot with the flag', () => {
|
||||
const { store } = createAttentionStore()
|
||||
const pane = finishedAgentOffScreen(store)
|
||||
|
||||
@@ -17,7 +17,9 @@ export function read(
|
||||
typeof host.worktreeCardDetailsRow === 'boolean' ? environmentId : null
|
||||
}
|
||||
return {
|
||||
worktreeCardDetailsRow: host.worktreeCardDetailsRow !== false,
|
||||
...(typeof host.worktreeCardDetailsRow === 'boolean'
|
||||
? { worktreeCardDetailsRow: host.worktreeCardDetailsRow }
|
||||
: {}),
|
||||
...(typeof host.compactWorktreeCards === 'boolean'
|
||||
? { compactWorktreeCards: host.compactWorktreeCards }
|
||||
: {}),
|
||||
|
||||
@@ -52,6 +52,30 @@ describe('web settings preload API', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('retains a local details-row choice when an older paired host omits it', async () => {
|
||||
vi.doMock('./web-runtime-client', () => ({
|
||||
WebRuntimeClient: class {
|
||||
call(): Promise<RuntimeRpcResponse<unknown>> {
|
||||
return Promise.resolve({
|
||||
id: 'settings',
|
||||
ok: true,
|
||||
result: { settings: {} },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
}
|
||||
close(): void {}
|
||||
}
|
||||
}))
|
||||
const globals = installBrowserGlobals('Linux')
|
||||
writeStoredRuntimeEnvironment(globals.storage)
|
||||
const { installWebPreloadApi } = await import('./web-preload-api')
|
||||
installWebPreloadApi()
|
||||
await globals.window.api.settings.get()
|
||||
await globals.window.api.settings.set({ worktreeCardDetailsRow: false })
|
||||
|
||||
expect((await globals.window.api.settings.get()).worktreeCardDetailsRow).toBe(false)
|
||||
})
|
||||
|
||||
it('arms the OSC 52 notice when ui.get is the read that runs the migration', async () => {
|
||||
// Seed after module load to model another tab’s storage update; first ui.get must migrate settings before snapshotting UI or it erases the one-shot notice arm.
|
||||
const globals = installBrowserGlobals('Linux')
|
||||
|
||||
Reference in New Issue
Block a user