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 (