From e687d2e97c86dcb320c438d325bb7231155ea32e Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 6 Nov 2026 13:54:01 -0800 Subject: [PATCH] Show every status in the sidebar card corner and let cards open and close The title-row corner now shows one glyph per status the card speaks for (needs, failed, unread, working), most urgent first, followed by the time, instead of only the most urgent one. Each glyph opens its own sources. Collapsed child workspaces report all their statuses through a flat, shallow-comparable record. Experimental 2.0 cards can also be opened and closed; a closed card's title slot speaks for its hidden agents and children. --- src/renderer/src/assets/main.css | 19 ++- .../components/sidebar/WorktreeCardSlot.tsx | 96 ++++++++----- .../WorktreeCardSlotContent.fixture.tsx | 25 +++- .../sidebar/WorktreeCardSlotContent.tsx | 132 ++++++++++++------ .../worktree-card-agents-expansion-state.ts | 27 +++- .../surface/worktree-card-open-state.ts | 23 +++ .../surface/worktree-card-surface.tsx | 11 +- .../worktree-card-slot-agent-line-row.tsx | 13 +- .../sidebar/worktree-card-slot-jump.tsx | 19 ++- .../sidebar/worktree-card-slot-rollup.ts | 86 +++++++++++- .../sidebar/worktree-card-slot-signal.ts | 93 +++++++----- src/renderer/src/i18n/locales/en.json | 2 + 12 files changed, 412 insertions(+), 134 deletions(-) create mode 100644 src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-open-state.ts 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",