diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 781d2affad1..6adfafc34b3 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -1797,7 +1797,8 @@ html.native-shell .app-layout { content: ''; position: absolute; left: -9px; - top: 0; + /* Reach up through the margin so the open parent's thread runs straight into its children. */ + top: -9px; bottom: 8px; width: 1px; background: var(--worktree-card-slot-guide); @@ -1808,6 +1809,22 @@ html.native-shell .app-layout { position: relative; } +/* An open 2.0 card hangs a line from its title through the rows it owns. */ +.worktree-card-slot-thread { + position: relative; +} + +.worktree-card-slot-thread::before { + content: ''; + position: absolute; + left: 5px; + top: 22px; + bottom: 3px; + width: 1px; + background: color-mix(in srgb, var(--worktree-sidebar-foreground) 16%, transparent); + pointer-events: none; +} + .worktree-card-slot-lineage > *::before { content: ''; position: absolute; diff --git a/src/renderer/src/components/sidebar/WorktreeCardSlot.tsx b/src/renderer/src/components/sidebar/WorktreeCardSlot.tsx index 2e3ac7cbe67..fbbc1d8496a 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardSlot.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardSlot.tsx @@ -5,7 +5,9 @@ import { AgentWorkingSpinner } from '@/components/AgentWorkingSpinner' import { translate } from '@/i18n/i18n' import { formatShortTimeAgo } from '@/lib/short-time-ago' import { cn } from '@/lib/utils' -import type { SlotSignal } from './worktree-card-slot-signal' +import type { SlotException, SlotSignal } from './worktree-card-slot-signal' +import type { SlotTarget } from './worktree-card-slot-targets' +import { SlotJumpTrigger, SlotKindGlyph } from './worktree-card-slot-jump' function formatSlotTime(at: number | null, now: number): string { return at === null ? '' : formatShortTimeAgo(at, now) @@ -38,14 +40,14 @@ export function WorktreeCardSlot({ signal, now, stale = false, - compact = false + timeTone }: { signal: SlotSignal now: number /** The host is offline: show what was last seen, never a live state. */ stale?: boolean - /** Title row: needs/failed as a glyph so long names keep their width; the clickable parent labels it. */ - compact?: boolean + /** A plain time beside an unread or working glyph reads like that state's own time. */ + timeTone?: 'unread' | 'working' }): React.JSX.Element | null { if (signal.kind === 'quiet') { return null @@ -53,18 +55,7 @@ export function WorktreeCardSlot({ const base = 'inline-flex min-w-[22px] shrink-0 items-center justify-end gap-1 whitespace-nowrap text-3xs leading-none tabular-nums' const label = slotLabel(signal) - // A compact exception sits inside a labelled button; offline and times keep their tooltip. - const labelledByParent = - compact && - !stale && - (signal.kind === 'needs' || - signal.kind === 'failed' || - signal.kind === 'unread' || - signal.kind === 'working') - const slotProps = { - 'data-worktree-card-slot': signal.kind, - title: labelledByParent ? undefined : label - } + const slotProps = { 'data-worktree-card-slot': signal.kind, title: label } if (stale || signal.kind === 'offline') { return ( @@ -80,14 +71,6 @@ export function WorktreeCardSlot({ switch (signal.kind) { case 'needs': - if (compact) { - return ( - - - {signal.count > 1 ? signal.count : null} - - ) - } return ( ) case 'failed': - if (compact) { - return ( - - - ) - } return ( {/* A crossed circle, not a red dot, so failed never reads like the blue unread dot. */} @@ -140,9 +116,65 @@ export function WorktreeCardSlot({ ) case 'time': return ( - + {formatSlotTime(signal.at, now)} ) } } + +function titleTimeTone(exceptions: readonly SlotException[]): 'unread' | 'working' | undefined { + if (exceptions.some((exception) => exception.kind === 'unread')) { + return 'unread' + } + return exceptions.some((exception) => exception.kind === 'working') ? 'working' : undefined +} + +/** Title row: a glyph per exception, most urgent first, then the time; each glyph opens its sources. */ +export function WorktreeCardTitleSlot({ + exceptions, + at, + now, + resolveTargets +}: { + exceptions: readonly SlotException[] + at: number | null + now: number + resolveTargets: (kind: SlotException['kind']) => SlotTarget[] +}): React.JSX.Element { + return ( + + {exceptions.map((exception) => ( + resolveTargets(exception.kind)} + className="-my-0.5 inline-flex h-4 min-w-3.5 items-center justify-center px-0.5" + > + + + + + ))} + + + ) +} diff --git a/src/renderer/src/components/sidebar/WorktreeCardSlotContent.fixture.tsx b/src/renderer/src/components/sidebar/WorktreeCardSlotContent.fixture.tsx index 2401b2338a4..cd749b38817 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardSlotContent.fixture.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardSlotContent.fixture.tsx @@ -249,6 +249,18 @@ function worktreesFor(seeds: readonly WorkspaceSeed[], now: number): Map [seed.id, makeWorktree(seed, now)])) } +function seedOpenCards(ids: readonly string[]): void { + const none = new Set() + ids.forEach((id) => + agentExpansion.seedWorktreeAgentExpansionStateForTests(id, { + expandedLineageParents: none, + expandedIdleChildGroups: none, + compactRootListExpanded: false, + cardOpen: true + }) + ) +} + const now = Date.now() const flatWorktrees = worktreesFor(FLAT, now) const nestedWorktrees = worktreesFor(NESTED, now) @@ -272,13 +284,19 @@ function FlatList(): React.JSX.Element { export const exceptions = defineFixture({ width: SIDEBAR_WIDTH, - setup: () => seedWorkspaces(FLAT, now), + setup: () => { + seedWorkspaces(FLAT, now) + seedOpenCards(['wt-needs']) + }, render: () => }) export const nesting = defineFixture({ width: SIDEBAR_WIDTH, - setup: () => seedWorkspaces(NESTED, now), + setup: () => { + seedWorkspaces(NESTED, now) + seedOpenCards(['wt-parent', 'wt-child']) + }, render: () => ( agentExpansion.seedWorktreeAgentExpansionStateForTests(seed.id, state)) }, diff --git a/src/renderer/src/components/sidebar/WorktreeCardSlotContent.tsx b/src/renderer/src/components/sidebar/WorktreeCardSlotContent.tsx index 12ea5663102..927aebf4d1b 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardSlotContent.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardSlotContent.tsx @@ -1,9 +1,11 @@ import React, { useCallback, useMemo } from 'react' +import { ChevronRight } from 'lucide-react' import { useShallow } from 'zustand/react/shallow' import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData' import type { HostScopedWorktreeKey } from '../../../../shared/worktree/host-qualified-identity' import { useNow } from '@/hooks/use-now' +import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' import { canConnectSshStatus, isConnectingSshStatus } from '@/ssh/ssh-connection-recoverability' import { useAppStore } from '@/store' @@ -24,10 +26,10 @@ import type { WorktreeCardPresentation } from './workspace-card/surface/worktree import { WorktreeCardSecondaryRows } from './workspace-card/surface/worktree-card-secondary-rows' import { collectLineageSlotTargets, - useCollapsedLineageSlot, + useCollapsedLineageSlots, useLineageLatestAt } from './worktree-card-slot-rollup' -import { titleRowSlot } from './worktree-card-slot-signal' +import { titleRowSlot, type SlotException } from './worktree-card-slot-signal' import { buildSlotAgentSignals, type SlotAgentSignals } from './worktree-card-slot-agent-signals' import { WorktreeCardDeleteQuickAction, @@ -35,10 +37,8 @@ import { } from './worktree-card-title-actions' import { getWorktreeCardHost, WorktreeCardHostName } from './worktree-card-host-name' import { WorktreeCardChildrenChip } from './worktree-card-children-chip' -import { slotLabel, WorktreeCardSlot } from './WorktreeCardSlot' -import { SlotJumpTrigger } from './worktree-card-slot-jump' +import { WorktreeCardSlot, WorktreeCardTitleSlot } from './WorktreeCardSlot' import { - slotTargetKind, slotTargetsFromAgentLines, slotTargetsOfKind, workspaceUnreadTarget @@ -97,10 +97,14 @@ function SshHostSlot({ /** Experimental 2.0 card: name and one right-hand slot, then one line per agent. */ export function WorktreeCardSlotContent({ card, - presentation + presentation, + open: openRequested, + onToggleOpen }: { card: WorktreeCardController presentation: WorktreeCardPresentation + open: boolean + onToggleOpen: () => void }): React.JSX.Element { const { worktree, @@ -136,8 +140,11 @@ export function WorktreeCardSlotContent({ const pickingSendTarget = useAppStore( (s) => selectSendTargetControlInputs(s, worktree.id).targetMode !== null ) + const open = openRequested || pickingSendTarget const descendants = lineageDescendantIdentities ?? NO_DESCENDANTS - const collapsedLineage = useCollapsedLineageSlot(lineageCollapsed ? descendants : NO_DESCENDANTS) + // A closed card hides its children too, so the title slot speaks for them. + const lineageHidden = lineageCollapsed || !open + const collapsedLineage = useCollapsedLineageSlots(lineageHidden ? descendants : NO_DESCENDANTS) const lineageLatestAt = useLineageLatestAt(descendants) // Several agents fold into the 1.0 summary row; the title slot speaks for them until expanded. const { compactRootListExpanded: agentsExpanded, toggleCompactRootList } = @@ -146,7 +153,9 @@ export function WorktreeCardSlotContent({ dispatchSuppressScrollAdjustment() toggleCompactRootList() }, [toggleCompactRootList]) - const showLines = showInlineAgentList && lines.length > 0 + const hasLines = showInlineAgentList && lines.length > 0 + const canOpen = hasLines || showLineageChildChip + const showLines = open && hasLines const foldsAgents = showLines && lines.length > 1 && !pickingSendTarget const ownLinesShown = showLines && (!foldsAgents || agentsExpanded) const agentUnread = signals.allSignals.some((signal) => signal.kind === 'unread') @@ -162,35 +171,31 @@ export function WorktreeCardSlotContent({ lastActivityAt: worktree.lastActivityAt > 0 ? worktree.lastActivityAt : null, lineageLatestAt }) - const titleTargetKind = offline ? null : slotTargetKind(titleSlot) const worktreeLastActivityAt = worktree.lastActivityAt > 0 ? worktree.lastActivityAt : null // Only what the title row speaks for: hidden agent lines, collapsed children, the workspace's own mark. const workspaceUnreadShown = workspaceUnread !== null - const resolveTitleTargets = useCallback(() => { - if (!titleTargetKind) { - return [] - } - return slotTargetsOfKind( - [ - ...(ownLinesShown ? [] : slotTargetsFromAgentLines(worktree.id, signals)), - ...(lineageCollapsed ? collectLineageSlotTargets(useAppStore.getState(), descendants) : []), - ...(workspaceUnreadShown - ? [workspaceUnreadTarget(worktree.id, worktreeLastActivityAt)] - : []) - ], - titleTargetKind - ) - }, [ - descendants, - lineageCollapsed, - ownLinesShown, - signals, - titleTargetKind, - workspaceUnreadShown, - worktree.id, - worktreeLastActivityAt - ]) - const titleSlotElement = + const resolveTitleTargets = useCallback( + (kind: SlotException['kind']) => + slotTargetsOfKind( + [ + ...(ownLinesShown ? [] : slotTargetsFromAgentLines(worktree.id, signals)), + ...(lineageHidden ? collectLineageSlotTargets(useAppStore.getState(), descendants) : []), + ...(workspaceUnreadShown + ? [workspaceUnreadTarget(worktree.id, worktreeLastActivityAt)] + : []) + ], + kind + ), + [ + descendants, + lineageHidden, + ownLinesShown, + signals, + workspaceUnreadShown, + worktree.id, + worktreeLastActivityAt + ] + ) const showDetailsRow = settings?.worktreeCardDetailsRow === true && (presentation.hasMetaRow || presentation.detailsAndPorts !== null) @@ -234,7 +239,7 @@ export function WorktreeCardSlotContent({ ) : ( agentLines )} - {showLineageChildChip ? ( + {open && showLineageChildChip ? (
) + const openToggleLabel = open + ? translate('sidebar.cardSlot.closeCard', 'Hide workspace activity') + : translate('sidebar.cardSlot.openCard', 'Show workspace activity') + // The line ties everything below the title to this card; a lone title row has nothing to tie. + const showThread = showDetailsRow || (open && canOpen) + return (
@@ -284,17 +299,44 @@ export function WorktreeCardSlotContent({ {showDeleteQuickAction ? : null} {sshDown && host ? ( - ) : titleTargetKind ? ( - - {titleSlotElement} - + ) : titleSlot.kind === 'offline' ? ( + ) : ( - titleSlotElement + )} + {canOpen ? ( + + ) : null}
{showDetailsRow ? ( diff --git a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-agents-expansion-state.ts b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-agents-expansion-state.ts index c6fcb4cada2..c31e7e479df 100644 --- a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-agents-expansion-state.ts +++ b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-agents-expansion-state.ts @@ -5,6 +5,8 @@ export type WorktreeAgentExpansionState = { expandedLineageParents: ReadonlySet expandedIdleChildGroups: ReadonlySet compactRootListExpanded: boolean + // 2.0 cards fold to their title (and details) row until opened. + cardOpen: boolean } const EMPTY_PANE_KEYS: ReadonlySet = new Set() @@ -12,7 +14,8 @@ const EMPTY_PANE_KEYS: ReadonlySet = new Set() const DEFAULT_EXPANSION_STATE: WorktreeAgentExpansionState = { expandedLineageParents: EMPTY_PANE_KEYS, expandedIdleChildGroups: EMPTY_PANE_KEYS, - compactRootListExpanded: false + compactRootListExpanded: false, + cardOpen: false } // Retain disclosures across virtualizer/lineage remounts in a bounded renderer-session cache; reload resets them. @@ -38,6 +41,7 @@ function persistExpansionState(worktreeId: string, state: WorktreeAgentExpansion expansionByWorktreeId.delete(worktreeId) if ( state.compactRootListExpanded || + state.cardOpen || state.expandedLineageParents.size > 0 || state.expandedIdleChildGroups.size > 0 ) { @@ -51,10 +55,14 @@ const expansionListeners = new Map void>>() /** Unfold a card's agent list from outside the card, e.g. when a rolled-up slot opens one of its agents. */ export function expandWorktreeAgentList(worktreeId: string): void { const base = readExpansionState(worktreeId) - if (base.compactRootListExpanded) { + if (base.compactRootListExpanded && base.cardOpen) { return } - persistExpansionState(worktreeId, { ...base, compactRootListExpanded: true }) + persistExpansionState(worktreeId, { ...base, compactRootListExpanded: true, cardOpen: true }) + notifyExpansion(worktreeId) +} + +function notifyExpansion(worktreeId: string): void { for (const listener of expansionListeners.get(worktreeId) ?? []) { listener() } @@ -87,9 +95,11 @@ export type WorktreeAgentExpansionControls = { expandedLineageParents: ReadonlySet expandedIdleChildGroups: ReadonlySet compactRootListExpanded: boolean + cardOpen: boolean toggleLineageParent: (paneKey: string) => void toggleIdleChildGroup: (parentPaneKey: string) => void toggleCompactRootList: () => void + toggleCardOpen: () => void } /** Restore each worktree’s disclosures across virtualizer and sibling-lineage remounts. */ @@ -107,6 +117,8 @@ export function useWorktreeAgentExpansionState(worktreeId: string): WorktreeAgen (next: WorktreeAgentExpansionState) => { persistExpansionState(worktreeId, next) setRendered({ worktreeId, state: next }) + // The surface and its content both read this state; keep every mounted reader in step. + notifyExpansion(worktreeId) }, [worktreeId] ) @@ -144,13 +156,20 @@ export function useWorktreeAgentExpansionState(worktreeId: string): WorktreeAgen commit({ ...base, compactRootListExpanded: !base.compactRootListExpanded }) }, [commit, worktreeId]) + const toggleCardOpen = useCallback(() => { + const base = readExpansionState(worktreeId) + commit({ ...base, cardOpen: !base.cardOpen }) + }, [commit, worktreeId]) + return { expandedLineageParents: current.expandedLineageParents, expandedIdleChildGroups: current.expandedIdleChildGroups, compactRootListExpanded: current.compactRootListExpanded, + cardOpen: current.cardOpen, toggleLineageParent, toggleIdleChildGroup, - toggleCompactRootList + toggleCompactRootList, + toggleCardOpen } } diff --git a/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-open-state.ts b/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-open-state.ts new file mode 100644 index 00000000000..0dabf189ab0 --- /dev/null +++ b/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-open-state.ts @@ -0,0 +1,23 @@ +import { useAppStore } from '@/store' +import { + getWorktreeIdFromHostIdentity, + type HostScopedWorktreeKey +} from '../../../../../../shared/worktree/host-qualified-identity' +import { useWorktreeAgentExpansionState } from '../activity/worktree-card-agents-expansion-state' + +/** A 2.0 card is open when the user opened it, or when it holds the active workspace so selection never hides. */ +export function useWorktreeCardOpen( + worktreeId: string, + descendants: readonly HostScopedWorktreeKey[] | undefined +): { open: boolean; toggle: () => void } { + const { cardOpen, toggleCardOpen } = useWorktreeAgentExpansionState(worktreeId) + const holdsActive = useAppStore((s) => { + const activeId = s.activeWorktreeId + return ( + activeId !== null && + descendants !== undefined && + descendants.some((identity) => getWorktreeIdFromHostIdentity(identity) === activeId) + ) + }) + return { open: cardOpen || holdsActive, toggle: toggleCardOpen } +} diff --git a/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-surface.tsx b/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-surface.tsx index 60ef696ade1..cfd604b070b 100644 --- a/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-surface.tsx +++ b/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-surface.tsx @@ -9,6 +9,7 @@ import WorktreeContextMenu from '../../workspace-actions/context-menu/WorktreeCo import { useIsSleepingWorktree } from '../../workspace-actions/sleep/use-worktree-sleep-state' import { getWorktreeCardFrameGeometry } from './worktree-card-frame-geometry' import type { WorktreeCardController } from '../controller/use-worktree-card-controller' +import { useWorktreeCardOpen } from './worktree-card-open-state' import { DEFAULT_WORKSPACE_CARD_TEMPLATE } from '../template/default-workspace-card' import { renderWorkspaceCardTemplate } from '../template/render-workspace-card-template' @@ -48,6 +49,7 @@ export function WorktreeCardSurface({ card }: { card: WorktreeCardController }): } = card const slotLayout = card.worktreeCardStyle === 'experimental-2' const isSleeping = useIsSleepingWorktree(worktree.id) + const cardOpen = useWorktreeCardOpen(worktree.id, card.lineageDescendantIdentities) const geometryFor = (statusLane: boolean) => getWorktreeCardFrameGeometry({ flushSurface, contentIndent, statusLane }) const { content: templateContent, frame } = renderWorkspaceCardTemplate( @@ -61,7 +63,12 @@ export function WorktreeCardSurface({ card }: { card: WorktreeCardController }): } ) const parentCardContent = slotLayout ? ( - + ) : ( templateContent ) @@ -128,7 +135,7 @@ export function WorktreeCardSurface({ card }: { card: WorktreeCardController }): parentCardContent )} - {lineageChildren ? ( + {lineageChildren && (!slotLayout || cardOpen.open) ? (
) : null ) : onDismiss ? ( - // Time and dismiss share the slot; hover or focus swaps in the dismiss control. - - - - + // 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. + + + + ) : ( diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-jump.tsx b/src/renderer/src/components/sidebar/worktree-card-slot-jump.tsx index 1267265cc05..a19e524c0a7 100644 --- a/src/renderer/src/components/sidebar/worktree-card-slot-jump.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-slot-jump.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useState } from 'react' -import { CircleX } from 'lucide-react' +import { Activity, CircleX } from 'lucide-react' import { AgentQuestionIcon } from '@/components/AgentQuestionIcon' import { AgentWorkingSpinner } from '@/components/AgentWorkingSpinner' import { @@ -20,14 +20,27 @@ import type { SlotLineKind } from './worktree-card-slot-signal' import { openSlotTarget, type SlotTarget } from './worktree-card-slot-targets' /** The slot's glyphs, so a count or menu item reads the same as the row it stands for. */ -export function SlotKindGlyph({ kind }: { kind: SlotLineKind }): React.JSX.Element | null { +export function SlotKindGlyph({ + kind, + monitoring = false, + spinnerClassName = 'size-2' +}: { + kind: SlotLineKind + /** Working, but watching rather than taking turns. */ + monitoring?: boolean + spinnerClassName?: string +}): React.JSX.Element | null { switch (kind) { case 'needs': return case 'failed': return case 'working': - return + return monitoring ? ( + + ) : ( + + ) case 'unread': return case 'seen': diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-rollup.ts b/src/renderer/src/components/sidebar/worktree-card-slot-rollup.ts index 66f74fb907d..5659b314342 100644 --- a/src/renderer/src/components/sidebar/worktree-card-slot-rollup.ts +++ b/src/renderer/src/components/sidebar/worktree-card-slot-rollup.ts @@ -1,4 +1,4 @@ -import { useCallback } from 'react' +import { useCallback, useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' import type { AppState } from '@/store/types' @@ -18,7 +18,12 @@ import { selectRetainedAgentEntriesForWorktree } from './workspace-card/activity/worktree-agent-row-selectors' import { projectSlotAgentLine } from './worktree-card-slot-agent-signals' -import { slotExceptionFor, type SlotSignal } from './worktree-card-slot-signal' +import { + slotExceptionFor, + slotExceptionsFor, + type SlotException, + type SlotSignal +} from './worktree-card-slot-signal' import { slotTargetsOfKind, workspaceUnreadTarget, @@ -121,15 +126,84 @@ function splitIdentities(key: string): HostScopedWorktreeKey[] { return key ? (key.split('\n') as HostScopedWorktreeKey[]) : [] } -export function useCollapsedLineageSlot( +/** Flat so `useShallow` can compare it across store updates; one field per exception. */ +type LineageSlotRecord = { + needs: number + failed: boolean + unread: boolean + unreadAt: number | null + working: boolean + workingAt: number | null + monitoring: boolean +} + +function toLineageSlotRecord(exceptions: readonly SlotException[]): LineageSlotRecord { + const record: LineageSlotRecord = { + needs: 0, + failed: false, + unread: false, + unreadAt: null, + working: false, + workingAt: null, + monitoring: false + } + for (const exception of exceptions) { + switch (exception.kind) { + case 'needs': + record.needs = exception.count + break + case 'failed': + record.failed = true + break + case 'unread': + record.unread = true + record.unreadAt = exception.at + break + case 'working': + record.working = true + record.workingAt = exception.at + record.monitoring = exception.monitoring === true + break + } + } + return record +} + +function fromLineageSlotRecord(record: LineageSlotRecord): SlotException[] { + const exceptions: SlotException[] = [] + if (record.needs > 0) { + exceptions.push({ kind: 'needs', count: record.needs }) + } + if (record.failed) { + exceptions.push({ kind: 'failed' }) + } + if (record.unread) { + exceptions.push({ kind: 'unread', at: record.unreadAt, manual: false }) + } + if (record.working) { + exceptions.push({ kind: 'working', at: record.workingAt, monitoring: record.monitoring }) + } + return exceptions +} + +const EMPTY_LINEAGE_SLOT_RECORD = toLineageSlotRecord([]) + +/** Every exception inside collapsed child workspaces, most urgent first. */ +export function useCollapsedLineageSlots( descendants: readonly HostScopedWorktreeKey[] -): SlotSignal | null { +): SlotException[] { const descendantKey = descendants.join('\n') const selector = useCallback( - (state: AppState) => selectCollapsedLineageSlot(state, splitIdentities(descendantKey)), + (state: AppState) => + descendantKey + ? toLineageSlotRecord( + slotExceptionsFor(collectLineageSlotTargets(state, splitIdentities(descendantKey))) + ) + : EMPTY_LINEAGE_SLOT_RECORD, [descendantKey] ) - return useAppStore(useShallow(selector)) + const record = useAppStore(useShallow(selector)) + return useMemo(() => fromLineageSlotRecord(record), [record]) } /** The most recent activity among child workspaces, so a parent never looks older than its children. */ diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-signal.ts b/src/renderer/src/components/sidebar/worktree-card-slot-signal.ts index 218f7796bc8..4b610e2ebd2 100644 --- a/src/renderer/src/components/sidebar/worktree-card-slot-signal.ts +++ b/src/renderer/src/components/sidebar/worktree-card-slot-signal.ts @@ -79,45 +79,53 @@ function latest(lines: readonly SlotLineSignal[]): number | null { return at } -/** The exception a set of lines reports, or null when every line is quiet. */ -export function slotExceptionFor(lines: readonly SlotLineSignal[]): SlotSignal | null { +export type SlotException = Extract< + SlotSignal, + { kind: 'needs' } | { kind: 'failed' } | { kind: 'unread' } | { kind: 'working' } +> + +const SLOT_EXCEPTION_ORDER: readonly SlotException['kind'][] = [ + 'needs', + 'failed', + 'unread', + 'working' +] + +/** Every exception a set of lines reports, one per kind, most urgent first. */ +export function slotExceptionsFor(lines: readonly SlotLineSignal[]): SlotException[] { + const exceptions: SlotException[] = [] const needs = lines.filter((line) => line.kind === 'needs').length if (needs > 0) { - return { kind: 'needs', count: needs } + exceptions.push({ kind: 'needs', count: needs }) } if (lines.some((line) => line.kind === 'failed')) { - return { kind: 'failed' } + exceptions.push({ kind: 'failed' }) } const unread = lines.filter((line) => line.kind === 'unread') if (unread.length > 0) { - return { kind: 'unread', at: latest(unread), manual: false } + exceptions.push({ kind: 'unread', at: latest(unread), manual: false }) } const working = lines.filter((line) => line.kind === 'working') if (working.length > 0) { - return { + exceptions.push({ kind: 'working', at: latest(working), monitoring: working.every((line) => line.monitoring === true) - } + }) } - return null + return exceptions +} + +/** The most urgent exception a set of lines reports, or null when every line is quiet. */ +export function slotExceptionFor(lines: readonly SlotLineSignal[]): SlotSignal | null { + return slotExceptionsFor(lines)[0] ?? null } export function slotForLine(line: SlotLineSignal): SlotSignal { return slotExceptionFor([line]) ?? { kind: 'time', at: line.at } } -const SLOT_EXCEPTION_RANK: Partial> = { - needs: 0, - failed: 1, - unread: 2, - working: 3 -} - -export function mergeSlotExceptions(a: SlotSignal | null, b: SlotSignal | null): SlotSignal | null { - if (!a || !b) { - return a ?? b - } +function mergeSameKind(a: SlotException, b: SlotException): SlotException { if (a.kind === 'needs' && b.kind === 'needs') { return { kind: 'needs', count: a.count + b.count } } @@ -127,41 +135,62 @@ export function mergeSlotExceptions(a: SlotSignal | null, b: SlotSignal | null): ) { return (b.at ?? 0) > (a.at ?? 0) ? b : a } - return (SLOT_EXCEPTION_RANK[a.kind] ?? 9) <= (SLOT_EXCEPTION_RANK[b.kind] ?? 9) ? a : b + return a } +/** One exception per kind across several sources, most urgent first. */ +export function mergeSlotExceptions( + ...lists: readonly (readonly SlotException[])[] +): SlotException[] { + const byKind = new Map() + for (const exception of lists.flat()) { + const previous = byKind.get(exception.kind) + byKind.set(exception.kind, previous ? mergeSameKind(previous, exception) : exception) + } + return SLOT_EXCEPTION_ORDER.flatMap((kind) => byKind.get(kind) ?? []) +} + +/** The title row: offline, or every exception it speaks for followed by a time. */ +export type TitleRowSlot = + | { kind: 'offline' } + | { kind: 'status'; exceptions: SlotException[]; at: number | null } + /** * The title row speaks for exceptions the user can't see (hidden agents, collapsed children, - * the workspace's own unread mark); otherwise it always shows the time so expanding doesn't jump. + * the workspace's own unread mark), and always shows the time so expanding doesn't jump. */ export function titleRowSlot(args: { offline: boolean lines: readonly SlotLineSignal[] - collapsedLineage: SlotSignal | null + collapsedLineage: readonly SlotException[] /** Set while the workspace is unread and no visible agent line already shows it; manual = the user marked it. */ workspaceUnread: { manual: boolean } | null ownLinesShown: boolean lastActivityAt: number | null lineageLatestAt?: number | null -}): SlotSignal { +}): TitleRowSlot { if (args.offline) { return { kind: 'offline' } } - const workspaceSignal: SlotSignal | null = args.workspaceUnread - ? { kind: 'unread', at: args.lastActivityAt, manual: args.workspaceUnread.manual } - : null - const hiddenLines = args.ownLinesShown ? [] : args.lines - const exception = mergeSlotExceptions( - mergeSlotExceptions(slotExceptionFor(hiddenLines), args.collapsedLineage), + const workspaceSignal: SlotException[] = args.workspaceUnread + ? [{ kind: 'unread', at: args.lastActivityAt, manual: args.workspaceUnread.manual }] + : [] + const exceptions = mergeSlotExceptions( + slotExceptionsFor(args.ownLinesShown ? [] : args.lines), + args.collapsedLineage, workspaceSignal ) - if (exception) { - return exception + // Unread and working keep their own time (when it landed, how long it has run). + for (const exception of exceptions) { + if ((exception.kind === 'unread' || exception.kind === 'working') && exception.at !== null) { + return { kind: 'status', exceptions, at: exception.at } + } } const ownAt = latest(args.lines) ?? args.lastActivityAt const lineageAt = args.lineageLatestAt ?? null return { - kind: 'time', + kind: 'status', + exceptions, at: ownAt === null || (lineageAt !== null && lineageAt > ownAt) ? lineageAt : ownAt } } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index ba2cfed4288..b6dd9fe3384 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -254,6 +254,8 @@ "keywordUnhide": "unhide" }, "cardSlot": { + "openCard": "Show workspace activity", + "closeCard": "Hide workspace activity", "needsYouLabel": "Waiting on you", "failedLabel": "Last turn failed", "markedUnread": "Marked unread by you",