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 (