mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 10:27:33 +00:00
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:
@@ -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 ? (
|
||||
|
||||
+23
-4
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+23
@@ -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
-2
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user