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.
This commit is contained in:
Jinjing
2026-11-13 18:25:08 -08:00
parent 463479da14
commit e687d2e97c
12 changed files with 412 additions and 134 deletions
+18 -1
View File
@@ -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;
@@ -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 (
<span {...slotProps} className={cn(base, 'font-semibold text-agent-question-text')}>
<AgentQuestionIcon className="size-2.5 shrink-0" />
{signal.count > 1 ? signal.count : null}
</span>
)
}
return (
<span
{...slotProps}
@@ -106,13 +89,6 @@ export function WorktreeCardSlot({
</span>
)
case 'failed':
if (compact) {
return (
<span {...slotProps} className={cn(base, 'text-destructive')}>
<CircleX className="size-2.5 shrink-0" aria-hidden="true" />
</span>
)
}
return (
<span {...slotProps} className={cn(base, 'font-semibold text-destructive')}>
{/* 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 (
<span {...slotProps} className={cn(base, 'text-muted-foreground/70')}>
<span
{...slotProps}
className={cn(
base,
timeTone === 'unread'
? 'font-medium text-foreground'
: timeTone === 'working'
? 'text-muted-foreground'
: 'text-muted-foreground/70'
)}
>
{formatSlotTime(signal.at, now)}
</span>
)
}
}
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 (
<span className="inline-flex shrink-0 items-center gap-0.5">
{exceptions.map((exception) => (
<SlotJumpTrigger
key={exception.kind}
label={slotLabel(exception) ?? ''}
resolveTargets={() => resolveTargets(exception.kind)}
className="-my-0.5 inline-flex h-4 min-w-3.5 items-center justify-center px-0.5"
>
<span data-worktree-card-slot={exception.kind} className="inline-flex">
<SlotKindGlyph
kind={exception.kind}
monitoring={exception.kind === 'working' && exception.monitoring === true}
spinnerClassName="size-[9px]"
/>
</span>
</SlotJumpTrigger>
))}
<WorktreeCardSlot
signal={{ kind: 'time', at }}
now={now}
timeTone={titleTimeTone(exceptions)}
/>
</span>
)
}
@@ -249,6 +249,18 @@ function worktreesFor(seeds: readonly WorkspaceSeed[], now: number): Map<string,
return new Map(seeds.map((seed) => [seed.id, makeWorktree(seed, now)]))
}
function seedOpenCards(ids: readonly string[]): void {
const none = new Set<string>()
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: () => <FlatList />
})
export const nesting = defineFixture({
width: SIDEBAR_WIDTH,
setup: () => seedWorkspaces(NESTED, now),
setup: () => {
seedWorkspaces(NESTED, now)
seedOpenCards(['wt-parent', 'wt-child'])
},
render: () => (
<SidebarFrame>
<Card
@@ -313,7 +331,8 @@ export const agentsExpanded = defineFixture({
const state = {
expandedLineageParents: none,
expandedIdleChildGroups: none,
compactRootListExpanded: true
compactRootListExpanded: true,
cardOpen: true
}
FLAT.forEach((seed) => agentExpansion.seedWorktreeAgentExpansionStateForTests(seed.id, state))
},
@@ -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 = <WorktreeCardSlot signal={titleSlot} now={now} compact />
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 ? (
<div className="mt-[3px] flex min-w-0 pr-1.5 pl-3.5">
<WorktreeCardChildrenChip
childIdentities={lineageChildIdentities}
@@ -248,9 +253,19 @@ export function WorktreeCardSlotContent({
</>
)
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 (
<div
className="flex w-full min-w-0 flex-col overflow-hidden"
className={cn(
'flex w-full min-w-0 flex-col overflow-hidden',
showThread && 'worktree-card-slot-thread'
)}
data-worktree-card-open={open || undefined}
data-worktree-card-parent-content=""
data-worktree-card-style="experimental-2"
>
@@ -284,17 +299,44 @@ export function WorktreeCardSlotContent({
{showDeleteQuickAction ? <WorktreeCardDeleteQuickAction card={card} /> : null}
{sshDown && host ? (
<SshHostSlot card={card} label={host.label} />
) : titleTargetKind ? (
<SlotJumpTrigger
resolveTargets={resolveTitleTargets}
label={slotLabel(titleSlot) ?? ''}
className="-mx-1 -my-0.5 px-1 py-0.5"
>
{titleSlotElement}
</SlotJumpTrigger>
) : titleSlot.kind === 'offline' ? (
<WorktreeCardSlot signal={titleSlot} now={now} />
) : (
titleSlotElement
<WorktreeCardTitleSlot
exceptions={titleSlot.exceptions}
at={titleSlot.at}
now={now}
resolveTargets={resolveTitleTargets}
/>
)}
{canOpen ? (
<button
type="button"
className={cn(
'-mr-0.5 inline-flex size-3.5 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-opacity hover:text-foreground focus-visible:opacity-100 disabled:pointer-events-none',
open
? 'opacity-100'
: 'opacity-0 can-hover:group-hover/worktree-card:opacity-100'
)}
aria-label={openToggleLabel}
aria-expanded={open}
disabled={pickingSendTarget}
onPointerDown={card.stopQuickActionPointerPropagation}
onClick={(event) => {
event.stopPropagation()
dispatchSuppressScrollAdjustment()
onToggleOpen()
}}
>
<ChevronRight
className={cn(
'size-3 transition-transform motion-reduce:transition-none',
open && 'rotate-90'
)}
aria-hidden
/>
</button>
) : null}
</span>
</div>
{showDetailsRow ? (
@@ -5,6 +5,8 @@ export type WorktreeAgentExpansionState = {
expandedLineageParents: ReadonlySet<string>
expandedIdleChildGroups: ReadonlySet<string>
compactRootListExpanded: boolean
// 2.0 cards fold to their title (and details) row until opened.
cardOpen: boolean
}
const EMPTY_PANE_KEYS: ReadonlySet<string> = new Set()
@@ -12,7 +14,8 @@ const EMPTY_PANE_KEYS: ReadonlySet<string> = 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<string, Set<() => 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<string>
expandedIdleChildGroups: ReadonlySet<string>
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
}
}
@@ -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 }
}
@@ -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 ? (
<WorktreeCardSlotContent card={card} presentation={buildWorktreeCardPresentation(card)} />
<WorktreeCardSlotContent
card={card}
presentation={buildWorktreeCardPresentation(card)}
open={cardOpen.open}
onToggleOpen={cardOpen.toggle}
/>
) : (
templateContent
)
@@ -128,7 +135,7 @@ export function WorktreeCardSurface({ card }: { card: WorktreeCardController }):
parentCardContent
)}
{lineageChildren ? (
{lineageChildren && (!slotLayout || cardOpen.open) ? (
<div
className={cn(
'space-y-1',
@@ -155,14 +155,12 @@ export const SlotAgentLineRow = React.memo(function SlotAgentLineRow({
<Loader2 className="size-3 shrink-0 animate-spin text-muted-foreground motion-reduce:animate-none" />
) : null
) : onDismiss ? (
// Time and dismiss share the slot; hover or focus swaps in the dismiss control.
<span className="group/dismiss grid shrink-0 items-center justify-items-end [&>*]:col-start-1 [&>*]:row-start-1">
<span className="transition-opacity can-hover:group-hover/slot-line:opacity-0 group-has-[:focus-visible]/dismiss:opacity-0">
<WorktreeCardSlot signal={slotForLine(displaySignal)} now={now} stale={offline} />
</span>
// Time and dismiss share the slot; hover or keyboard focus swaps in the dismiss control.
// Button comes first so the time can follow it as a `peer` without :has(); z-10 keeps it on top.
<span className="grid shrink-0 items-center justify-items-end [&>*]:col-start-1 [&>*]:row-start-1">
<button
type="button"
className="inline-flex items-center justify-center text-muted-foreground/70 opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 can-hover:group-hover/slot-line:opacity-100"
className="peer z-10 inline-flex items-center justify-center text-muted-foreground/70 opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 can-hover:group-hover/slot-line:opacity-100"
aria-label={translate(
'auto.components.dashboard.DashboardAgentRow.b06e13fcf7',
'Dismiss agent'
@@ -174,6 +172,9 @@ export const SlotAgentLineRow = React.memo(function SlotAgentLineRow({
>
<X className="size-3" />
</button>
<span className="transition-opacity peer-focus-visible:opacity-0 can-hover:group-hover/slot-line:opacity-0">
<WorktreeCardSlot signal={slotForLine(displaySignal)} now={now} stale={offline} />
</span>
</span>
) : (
<WorktreeCardSlot signal={slotForLine(displaySignal)} now={now} stale={offline} />
@@ -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 <AgentQuestionIcon className="size-2.5 shrink-0" />
case 'failed':
return <CircleX className="size-2.5 shrink-0 text-destructive" aria-hidden />
case 'working':
return <AgentWorkingSpinner className="size-2 border-[1.5px]" />
return monitoring ? (
<Activity className="size-2.5 shrink-0 text-agent-working" aria-hidden />
) : (
<AgentWorkingSpinner className={cn(spinnerClassName, 'border-[1.5px]')} />
)
case 'unread':
return <span className="size-1.5 shrink-0 rounded-full bg-unread" />
case 'seen':
@@ -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. */
@@ -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<Record<SlotSignal['kind'], number>> = {
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<SlotException['kind'], SlotException>()
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
}
}
+2
View File
@@ -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",