From 340627f3a31eec69ca4cadce9f624bf4faa74bf1 Mon Sep 17 00:00:00 2001 From: mmarabel <166927047+mmarabel@users.noreply.github.com> Date: Sat, 19 Sep 2026 09:55:50 +0200 Subject: [PATCH] fix(sidebar): distinguish sleeping workspaces in the new card style (#21540) * fix(sidebar): distinguish sleeping workspaces in the new card style The experimental card style mapped every quiet status to the same branch/PR glyph, so a workspace that finished its work and a workspace that went to sleep looked identical. Sleeping workspaces now show a Moon in the status lane and dim the row. Sleep is keyed on runtime liveness (no live PTY, no browser tabs, no fresh agent activity), not on status, because a slept workspace keeps its retained done rows and still reports 'done'. The same liveness verdict already drives the hide-sleeping filter, including its SSH-gap protection. The dim redefines the theme's own tokens on the sleeping row with an oklab mix of the sidebar foreground into the sidebar surface. Opacity or a painted veil dims toward whatever is behind the card, which collapses to nothing on a custom background and shrinks as the surface lightens; a token mix is a fixed perceptual step on any theme and keeps themed hues instead of greying them. Legacy card style is untouched. * fix(sidebar): share the hide-sleeping predicate and register the sleeping label Review found two problems with the first pass. The sleep hook re-derived liveness instead of reusing isInactiveWorkspace, and the two definitions already disagreed: a fresh interrupted agent kept the row awake here while the filter's isFreshNonDoneAgentStatus treated the same workspace as sleeping. The hook now calls the filter's own predicate, with the live-agent set cached per store generation so every card does not rescan the status map, so the moon and the filter cannot drift apart. The new 'Sleeping' translate() key was never added to en.json, which fails the localization catalog and extraction CI jobs. --- src/renderer/src/assets/main.css | 23 ++ .../assets/worktree-card-active-style.test.ts | 18 ++ .../WorktreeCard.affiliate-list-mode.test.tsx | 95 ++++++++ .../WorktreeCard.compact-hover.test.tsx | 4 + ....compact-ports-hover-independence.test.tsx | 4 + ...orktreeCard.hosted-review-refresh.test.tsx | 4 + .../sidebar/WorktreeCard.lineage.test.tsx | 8 +- .../WorktreeCard.merged-pr-display.test.tsx | 4 + .../WorktreeCard.pinned-repo-icon.test.tsx | 4 + .../sidebar/WorktreeCard.pr-display.test.tsx | 4 + .../WorktreeCard.quick-actions.test.tsx | 4 + ...WorktreeCard.ssh-reconnect-prompt.test.tsx | 4 + .../sidebar/WorktreeCardStatusSlot.test.tsx | 89 ++++--- .../sidebar/WorktreeCardStatusSlot.tsx | 83 ++++--- .../sidebar/use-worktree-sleep-state.test.tsx | 223 ++++++++++++++++++ .../sidebar/use-worktree-sleep-state.ts | 81 +++++++ .../sidebar/worktree-card-surface.tsx | 15 +- src/renderer/src/i18n/locales/en.json | 3 +- 18 files changed, 603 insertions(+), 67 deletions(-) create mode 100644 src/renderer/src/components/sidebar/use-worktree-sleep-state.test.tsx create mode 100644 src/renderer/src/components/sidebar/use-worktree-sleep-state.ts diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 5278f980fd8..9787ac27c2d 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -1274,6 +1274,29 @@ html.native-shell .app-layout { box-shadow: 0 0 0 1px color-mix(in srgb, var(--sidebar-ring) 18%, transparent); } +/* Why: a sleeping card dims by mixing the theme's own text tokens toward the + sidebar surface in oklab, not by painting opacity over whatever is behind it. + An opacity step shrinks as the surface lightens, so the cue faded on lighter + themes and custom tints; an oklab mix is a fixed perceptual step, keeps themed + hues instead of greying them, and reaches every descendant through the tokens + they already use (#19624). The amber unread badge sits outside this family and + stays full-bright. */ +[data-worktree-sleeping-dim] { + color: color-mix(in oklab, var(--worktree-sidebar-foreground) 52%, var(--worktree-sidebar)); + --foreground: color-mix( + in oklab, + var(--worktree-sidebar-foreground) 52%, + var(--worktree-sidebar) + ); + --muted-foreground: color-mix( + in oklab, + var(--worktree-sidebar-foreground) 34%, + var(--worktree-sidebar) + ); + --muted: color-mix(in oklab, var(--worktree-sidebar-foreground) 5%, var(--worktree-sidebar)); + --border: color-mix(in oklab, var(--worktree-sidebar-foreground) 5%, var(--worktree-sidebar)); +} + .worktree-agent-row-hover:hover { background: color-mix(in srgb, var(--sidebar-foreground) 1.25%, transparent); } diff --git a/src/renderer/src/assets/worktree-card-active-style.test.ts b/src/renderer/src/assets/worktree-card-active-style.test.ts index 814c7ae55ee..5057da135b9 100644 --- a/src/renderer/src/assets/worktree-card-active-style.test.ts +++ b/src/renderer/src/assets/worktree-card-active-style.test.ts @@ -42,4 +42,22 @@ describe('worktree card active styling', () => { expect(secondary).toContain('var(--sidebar-ring) 15%') expect(darkSecondary).toContain('var(--sidebar-ring) 18%') }) + + it('dims sleeping cards through theme tokens so the cue survives any surface', () => { + const sleeping = getCssRuleBody('[data-worktree-sleeping-dim]') + + // Why oklab: a fixed perceptual step. An sRGB alpha over the painted backdrop + // shrank as the surface lightened, so slept and awake read alike (#19624). + expect(sleeping).toContain('in oklab') + // Why token-anchored: the mix is defined by the theme's own foreground and + // surface, so a custom background or tint scales it instead of cancelling it. + expect(sleeping).toContain('var(--worktree-sidebar-foreground)') + expect(sleeping).toContain('var(--worktree-sidebar)') + // Why these two: title text and the muted lane (Moon, host badge) carry the cue. + expect(sleeping).toContain('--foreground:') + expect(sleeping).toContain('--muted-foreground:') + // Why not opacity/filter: both dim toward the backdrop or strip themed hues. + expect(sleeping).not.toContain('opacity:') + expect(sleeping).not.toContain('filter:') + }) }) diff --git a/src/renderer/src/components/sidebar/WorktreeCard.affiliate-list-mode.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.affiliate-list-mode.test.tsx index 73927976237..e451ce5681f 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.affiliate-list-mode.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.affiliate-list-mode.test.tsx @@ -14,6 +14,9 @@ const updateWorktreeMeta = vi.fn() const testDoubles = vi.hoisted(() => ({ activateWorktreeFromSidebar: vi.fn() })) +const sleepMocks = vi.hoisted(() => ({ + sleeping: false +})) let worktreeCardProperties: WorktreeCardProperty[] = ['status', 'comment'] let settings: Partial | null = null @@ -74,6 +77,10 @@ vi.mock('./use-worktree-activity-status', () => ({ useWorktreeActivityStatus: () => 'idle' })) +vi.mock('./use-worktree-sleep-state', () => ({ + useIsSleepingWorktree: () => sleepMocks.sleeping +})) + vi.mock('./CacheTimer', () => ({ default: () => null, usePromptCacheCountdownStartedAt: () => null @@ -154,6 +161,7 @@ describe('WorktreeCard affiliate list mode', () => { vi.clearAllMocks() worktreeCardProperties = ['status', 'comment'] settings = null + sleepMocks.sleeping = false }) afterEach(() => { @@ -228,4 +236,91 @@ describe('WorktreeCard affiliate list mode', () => { expect(container.querySelector('[data-testid="inline-agents"]')).not.toBeNull() }) + + it('dims the full card surface for sleeping workspaces in new card style', () => { + sleepMocks.sleeping = true + settings = { experimentalNewWorktreeCardStyle: true } + + act(() => { + root.render( + + ) + }) + + expect(container.querySelector('[data-worktree-sleeping-dim=""]')).not.toBeNull() + const dim = container.querySelector('[data-worktree-sleeping-dim=""]') + expect(dim).not.toBeNull() + // Why pinned: the dim is a theme-token mix in main.css, not an opacity or filter + // over the painted backdrop — those faded out on lighter surfaces and custom tints. + expect(dim?.getAttribute('class') ?? '').not.toContain('opacity-') + expect(dim?.getAttribute('class') ?? '').not.toContain('backdrop-') + }) + + it('keeps awake cards at full opacity in new card style', () => { + settings = { experimentalNewWorktreeCardStyle: true } + + act(() => { + root.render( + + ) + }) + + expect(container.querySelector('[data-worktree-sleeping-dim=""]')).toBeNull() + }) + + it('keeps legacy sleeping cards undimmed', () => { + sleepMocks.sleeping = true + + act(() => { + root.render( + + ) + }) + + expect(container.querySelector('[data-worktree-sleeping-dim=""]')).toBeNull() + }) + + it('keeps the unread badge rendered on a dimmed sleeping card', () => { + sleepMocks.sleeping = true + settings = { experimentalNewWorktreeCardStyle: true } + + act(() => { + root.render( + + ) + }) + + // Why both: the dim marks the row as sleeping while the unread badge still + // renders, so an unread sleeping row stays noticeable. + expect(container.querySelector('[data-worktree-sleeping-dim=""]')).not.toBeNull() + expect(container.querySelector('[data-worktree-unread-alert=""]')).not.toBeNull() + }) }) diff --git a/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx index 2d6678fb11c..069eb2c0701 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx @@ -99,6 +99,10 @@ vi.mock('./use-worktree-activity-status', () => ({ useWorktreeActivityStatus: () => 'active' })) +vi.mock('./use-worktree-sleep-state', () => ({ + useIsSleepingWorktree: () => false +})) + vi.mock('./CacheTimer', () => ({ default: () => null, usePromptCacheCountdownStartedAt: cacheTimerMocks.usePromptCacheCountdownStartedAt diff --git a/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx index 2c1f10945d3..2b5411f7899 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx @@ -114,6 +114,10 @@ vi.mock('./use-worktree-activity-status', () => ({ useWorktreeActivityStatus: () => 'active' })) +vi.mock('./use-worktree-sleep-state', () => ({ + useIsSleepingWorktree: () => false +})) + vi.mock('./CacheTimer', () => ({ default: () => null, usePromptCacheCountdownStartedAt: cacheTimerMocks.usePromptCacheCountdownStartedAt diff --git a/src/renderer/src/components/sidebar/WorktreeCard.hosted-review-refresh.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.hosted-review-refresh.test.tsx index 6d9fa6d1b5d..269f9ed9ac7 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.hosted-review-refresh.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.hosted-review-refresh.test.tsx @@ -76,6 +76,10 @@ vi.mock('./use-worktree-activity-status', () => ({ useWorktreeActivityStatus: () => 'active' })) +vi.mock('./use-worktree-sleep-state', () => ({ + useIsSleepingWorktree: () => false +})) + function makeRepo(): Repo { return { id: 'repo-1', diff --git a/src/renderer/src/components/sidebar/WorktreeCard.lineage.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.lineage.test.tsx index 1e26254e712..9147a881c47 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.lineage.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.lineage.test.tsx @@ -36,9 +36,7 @@ vi.mock('@/store', () => ({ }) })) -vi.mock('@/lib/worktree-activation', () => ({ - activateAndRevealWorktree: vi.fn() -})) +vi.mock('@/lib/worktree-activation', () => ({ activateAndRevealWorktree: vi.fn() })) vi.mock('@/components/ui/tooltip', () => ({ Tooltip: ({ children }: { children: ReactNode }) => <>{children}, @@ -46,6 +44,10 @@ vi.mock('@/components/ui/tooltip', () => ({ TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} })) +vi.mock('./use-worktree-sleep-state', () => ({ + useIsSleepingWorktree: () => false +})) + vi.mock('./CacheTimer', () => ({ default: () => null, usePromptCacheCountdownStartedAt: () => null diff --git a/src/renderer/src/components/sidebar/WorktreeCard.merged-pr-display.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.merged-pr-display.test.tsx index 7c9669a22f9..481b6ee25a4 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.merged-pr-display.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.merged-pr-display.test.tsx @@ -57,6 +57,10 @@ vi.mock('./use-worktree-activity-status', () => ({ useWorktreeActivityStatus: () => 'active' })) +vi.mock('./use-worktree-sleep-state', () => ({ + useIsSleepingWorktree: () => false +})) + vi.mock('./CacheTimer', () => ({ default: () => null, usePromptCacheCountdownStartedAt: () => null diff --git a/src/renderer/src/components/sidebar/WorktreeCard.pinned-repo-icon.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.pinned-repo-icon.test.tsx index 47017f87fab..ba317dbfed2 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.pinned-repo-icon.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.pinned-repo-icon.test.tsx @@ -53,6 +53,10 @@ vi.mock('./use-worktree-activity-status', () => ({ useWorktreeActivityStatus: () => 'idle' })) +vi.mock('./use-worktree-sleep-state', () => ({ + useIsSleepingWorktree: () => false +})) + vi.mock('./CacheTimer', () => ({ default: () => null, usePromptCacheCountdownStartedAt: () => null diff --git a/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx index 1ae9026cc74..b605c5258ee 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx @@ -61,6 +61,10 @@ vi.mock('./use-worktree-activity-status', () => ({ useWorktreeActivityStatus: () => 'active' })) +vi.mock('./use-worktree-sleep-state', () => ({ + useIsSleepingWorktree: () => false +})) + vi.mock('./CacheTimer', () => ({ default: () => null, usePromptCacheCountdownStartedAt: () => null diff --git a/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx index ffef859d9ca..c411b58bd9a 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx @@ -61,6 +61,10 @@ vi.mock('./use-worktree-activity-status', () => ({ useWorktreeActivityStatus: () => 'idle' })) +vi.mock('./use-worktree-sleep-state', () => ({ + useIsSleepingWorktree: () => false +})) + vi.mock('./CacheTimer', () => ({ default: () => null, usePromptCacheCountdownStartedAt: () => null diff --git a/src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx index 55ef3263ad4..3ee353bf61a 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx @@ -73,6 +73,10 @@ vi.mock('./use-worktree-activity-status', () => ({ useWorktreeActivityStatus: () => 'idle' })) +vi.mock('./use-worktree-sleep-state', () => ({ + useIsSleepingWorktree: () => false +})) + vi.mock('./WorktreeContextMenu', () => ({ default: ({ children }: { children: ReactNode }) => <>{children}, CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', diff --git a/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx index 9baed34e995..f8fc458a5dd 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx @@ -5,7 +5,8 @@ import { WorktreeCardStatusSlot } from './WorktreeCardStatusSlot' import type { WorktreeCardPrDisplay } from './worktree-card-pr-display' const mocks = vi.hoisted(() => ({ - status: 'active' + status: 'active', + sleeping: false })) vi.mock('@/components/ui/tooltip', () => ({ @@ -20,9 +21,14 @@ vi.mock('./use-worktree-activity-status', () => ({ useWorktreeActivityStatus: () => mocks.status })) +vi.mock('./use-worktree-sleep-state', () => ({ + useIsSleepingWorktree: () => mocks.sleeping +})) + describe('WorktreeCardStatusSlot', () => { beforeEach(() => { mocks.status = 'active' + mocks.sleeping = false }) const review: WorktreeCardPrDisplay = { @@ -71,7 +77,6 @@ describe('WorktreeCardStatusSlot', () => { onPointerDown={vi.fn()} onToggleUnread={vi.fn()} newCardStyle - hasBranchIdentity={false} /> ) @@ -98,7 +103,6 @@ describe('WorktreeCardStatusSlot', () => { onPointerDown={vi.fn()} onToggleUnread={vi.fn()} newCardStyle - hasBranchIdentity={false} /> ) @@ -123,7 +127,6 @@ describe('WorktreeCardStatusSlot', () => { onPointerDown={vi.fn()} onToggleUnread={vi.fn()} newCardStyle - hasBranchIdentity={false} /> ) @@ -260,8 +263,11 @@ describe('WorktreeCardStatusSlot', () => { expect(markup).not.toContain('bg-emerald-500') }) - it('uses PR status instead of the inactive dot when new card style is on', () => { - mocks.status = 'inactive' + it('keeps sleeping distinct from PR status when new card style is on', () => { + // Why done, not inactive: a slept workspace keeps its retained done rows, + // so its status still reads 'done' — the exact case from #19624. + mocks.status = 'done' + mocks.sleeping = true const markup = renderToStaticMarkup( { /> ) - expect(markup).toContain('PR checks: Failed') - expect(markup).toContain('text-rose-500/85') + // Why: sleep must stay distinct from awake completion; sleeping never collapses into PR. + expect(markup).toContain('Sleeping') + expect(markup).toContain('lucide-moon') + expect(markup).not.toContain('PR checks: Failed') + expect(markup).not.toContain('text-rose-500/85') expect(markup).not.toContain('bg-neutral-500/40') }) - it('uses a branch icon with branch-only accessible copy by default', () => { + it('keeps sleeping moon distinct from the awake green dot when new card style is on', () => { + mocks.status = 'done' + mocks.sleeping = true const markup = renderToStaticMarkup( { onPointerDown={vi.fn()} onToggleUnread={vi.fn()} newCardStyle - hasBranchIdentity /> ) - expect(markup).toContain('Branch') - expect(markup).not.toContain('Branch or folder path') - expect(markup).toContain('lucide-git-branch') - expect(markup).toContain('size-[13px] translate-x-px text-muted-foreground/70') - expect(markup).toContain('text-muted-foreground/70') + expect(markup).toContain('Sleeping') + expect(markup).toContain('lucide-moon') + expect(markup).not.toContain('lucide-git-branch') expect(markup).not.toContain('bg-emerald-500') - expect(markup).not.toContain('data-tooltip-root') + expect(markup).not.toContain('bg-neutral-500/40') }) - it('uses context-aware branch or folder path accessible copy', () => { - const markup = renderToStaticMarkup( + it('distinguishes awake branch from sleeping moon when new card style is on', () => { + mocks.status = 'done' + const awakeMarkup = renderToStaticMarkup( { onToggleUnread={vi.fn()} newCardStyle hasBranchIdentity - branchIdentityLabel="Branch or folder path" /> ) - - expect(markup).toContain('Branch or folder path') - expect(markup).toContain('lucide-git-branch') - expect(markup).not.toContain('data-tooltip-root') + expect(awakeMarkup).toContain('Branch') + expect(awakeMarkup).toContain('lucide-git-branch') + expect(awakeMarkup).not.toContain('lucide-moon') + mocks.sleeping = true + const sleepingMarkup = renderToStaticMarkup( + + ) + // Why: sleeping wins over the branch lane, even with an identity present. + expect(sleepingMarkup).toContain('Sleeping') + expect(sleepingMarkup).toContain('lucide-moon') + expect(sleepingMarkup).not.toContain('lucide-git-branch') }) - it('keeps the quiet dot when the row has no branch identity', () => { + it('shows the green awake dot for quiet done workspaces when new card style is on', () => { + mocks.status = 'done' const markup = renderToStaticMarkup( { onPointerDown={vi.fn()} onToggleUnread={vi.fn()} newCardStyle - hasBranchIdentity={false} /> ) - expect(markup).toContain('Active') + expect(markup).toContain('Done') expect(markup).toContain('bg-emerald-500') expect(markup).not.toContain('lucide-git-branch') - expect(markup).not.toContain('data-tooltip-root') + expect(markup).not.toContain('lucide-moon') + expect(markup).toContain('data-tooltip-root') }) it('keeps working activity ahead of PR status in new card style', () => { @@ -447,7 +473,9 @@ describe('WorktreeCardStatusSlot', () => { expect(markup).not.toContain('data-tooltip-root') }) - it('overlays an unread badge on the branch icon in new card style', () => { + it('overlays an unread badge on the sleeping moon in new card style', () => { + mocks.status = 'done' + mocks.sleeping = true const markup = renderToStaticMarkup( { onPointerDown={vi.fn()} onToggleUnread={vi.fn()} newCardStyle - hasBranchIdentity /> ) - expect(markup).toContain('Branch · Unread') + expect(markup).toContain('Sleeping · Unread') expect(markup).toContain('data-worktree-status-lane-unread=""') expect(markup).toContain('data-worktree-unread-alert=""') expect(markup).not.toContain('Mark as read') expect(markup).not.toContain('group/unread') expect(markup).not.toContain('cursor-pointer') - expect(markup).toContain('lucide-git-branch') + expect(markup).toContain('lucide-moon') expect(markup).toContain('bg-amber-500') expect(markup).not.toContain('lucide-bell') expect(markup).not.toContain('text-amber-500') diff --git a/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.tsx b/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.tsx index 72b04b22b1f..aaceddad464 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { Bell, GitBranch } from 'lucide-react' +import { Bell, GitBranch, Moon } from 'lucide-react' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' import { cn } from '@/lib/utils' @@ -7,6 +7,7 @@ import { getWorktreeStatusLabel, type WorktreeStatus } from '@/lib/worktree-stat import { FilledBellIcon } from './WorktreeCardHelpers' import StatusIndicator from './StatusIndicator' import { useWorktreeActivityStatus } from './use-worktree-activity-status' +import { useIsSleepingWorktree } from './use-worktree-sleep-state' import type { WorktreeCardPrDisplay } from './worktree-card-pr-display' import { getReviewLabel, ReviewIcon } from './worktree-review-helpers' @@ -31,10 +32,16 @@ const QUIET_REVIEW_REPLACEABLE_STATUSES = new Set(['active', 'do function getDefaultBranchIdentityLabel(): string { return translate('auto.components.sidebar.WorktreeCardStatusSlot.branchIdentity', 'Branch') } +function getSleepingStatusLabel(): string { + return translate('auto.components.sidebar.WorktreeCardStatusSlot.sleeping', 'Sleeping') +} // Why: branch-style SVGs are optically left-heavy; this keeps them aligned with // the centered activity dots in the shared status column. const compactReviewAndBranchStatusIconClassName = 'size-[13px] translate-x-px' const branchStatusIconClassName = `${compactReviewAndBranchStatusIconClassName} text-muted-foreground/70` +// Why no faint tint here: the sleeping row is dimmed as a whole, so the glyph +// keeps full muted-foreground and dims with everything around it. +const sleepingStatusIconClassName = 'size-[13px] text-muted-foreground' // Why: a left-edge badge overlays unread on the status glyph without widening // the lane or indenting the title; ring-sidebar cuts the dot out from busy icons. const newCardUnreadAlertClassName = @@ -101,20 +108,29 @@ export function WorktreeCardStatusSlot({ className }: WorktreeCardStatusSlotProps): React.JSX.Element | null { const status = useWorktreeActivityStatus(worktreeId) + const isSleeping = useIsSleepingWorktree(worktreeId) const statusLabel = getWorktreeStatusLabel(status) || status + // Why: sleep must stay distinct from awake completion; a sleeping workspace + // never collapses into branch/PR, even when retained done rows keep its + // status at 'done'. Attention states keep their own glyphs by construction. + const canShowSleepingStatus = + newCardStyle && showStatus && isSleeping && QUIET_REVIEW_REPLACEABLE_STATUSES.has(status) const canShowReviewStatus = newCardStyle && showStatus && prDisplay !== null && + !canShowSleepingStatus && QUIET_REVIEW_REPLACEABLE_STATUSES.has(status) const canShowBranchStatus = newCardStyle && showStatus && hasBranchIdentity && prDisplay === null && + !canShowSleepingStatus && QUIET_REVIEW_REPLACEABLE_STATUSES.has(status) - const passiveStatusLabel = - canShowReviewStatus && prDisplay + const passiveStatusLabel = canShowSleepingStatus + ? getSleepingStatusLabel() + : canShowReviewStatus && prDisplay ? getReviewStatusLabel(prDisplay) : canShowBranchStatus ? (branchIdentityLabel ?? getDefaultBranchIdentityLabel()) @@ -127,35 +143,40 @@ export function WorktreeCardStatusSlot({ newCardStyle && isUnread && showStatus && status !== 'working' && status !== 'permission' const reviewStatusIconClassName = compactReviewAndBranchStatusIconClassName const branchStatusIcon =