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.
This commit is contained in:
mmarabel
2026-09-19 00:55:50 -07:00
committed by GitHub
parent b7c06900e2
commit 340627f3a3
18 changed files with 603 additions and 67 deletions
+23
View File
@@ -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);
}
@@ -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:')
})
})
@@ -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<GlobalSettings> | 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(
<WorktreeCard
worktree={makeWorktree()}
repo={makeRepo()}
isActive={false}
nativeDragEnabled
flushSurface
affiliateListMode
/>
)
})
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(
<WorktreeCard
worktree={makeWorktree()}
repo={makeRepo()}
isActive={false}
nativeDragEnabled
flushSurface
affiliateListMode
/>
)
})
expect(container.querySelector('[data-worktree-sleeping-dim=""]')).toBeNull()
})
it('keeps legacy sleeping cards undimmed', () => {
sleepMocks.sleeping = true
act(() => {
root.render(
<WorktreeCard
worktree={makeWorktree()}
repo={makeRepo()}
isActive={false}
nativeDragEnabled
flushSurface
affiliateListMode
/>
)
})
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(
<WorktreeCard
worktree={makeWorktree({ isUnread: true })}
repo={makeRepo()}
isActive={false}
nativeDragEnabled
flushSurface
affiliateListMode
/>
)
})
// 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()
})
})
@@ -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
@@ -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
@@ -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',
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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',
@@ -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(
<WorktreeCardStatusSlot
worktreeId="wt-1"
@@ -276,12 +282,17 @@ describe('WorktreeCardStatusSlot', () => {
/>
)
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(
<WorktreeCardStatusSlot
worktreeId="wt-1"
@@ -292,21 +303,19 @@ describe('WorktreeCardStatusSlot', () => {
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(
<WorktreeCardStatusSlot
worktreeId="wt-1"
showStatus
@@ -317,16 +326,33 @@ describe('WorktreeCardStatusSlot', () => {
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(
<WorktreeCardStatusSlot
worktreeId="wt-1"
showStatus
showUnreadAction={false}
isUnread={false}
unreadTooltip="Mark as unread"
onPointerDown={vi.fn()}
onToggleUnread={vi.fn()}
newCardStyle
hasBranchIdentity
/>
)
// 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(
<WorktreeCardStatusSlot
worktreeId="wt-1"
@@ -337,14 +363,14 @@ describe('WorktreeCardStatusSlot', () => {
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(
<WorktreeCardStatusSlot
worktreeId="wt-1"
@@ -458,17 +486,16 @@ describe('WorktreeCardStatusSlot', () => {
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')
@@ -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<WorktreeStatus>(['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 = <GitBranch className={branchStatusIconClassName} aria-hidden="true" />
const passiveStatus =
canShowReviewStatus && prDisplay ? (
<span className={cn('inline-flex size-5 items-center justify-center p-0.5', className)}>
<ReviewIcon review={prDisplay} className={reviewStatusIconClassName} variant="generic" />
<span className="sr-only">{passiveStatusAnnouncement}</span>
const sleepingStatusIcon = <Moon className={sleepingStatusIconClassName} aria-hidden="true" />
const passiveStatus = canShowSleepingStatus ? (
<span className={cn('inline-flex size-5 items-center justify-center p-0.5', className)}>
{sleepingStatusIcon}
<span className="sr-only">{passiveStatusAnnouncement}</span>
</span>
) : canShowReviewStatus && prDisplay ? (
<span className={cn('inline-flex size-5 items-center justify-center p-0.5', className)}>
<ReviewIcon review={prDisplay} className={reviewStatusIconClassName} variant="generic" />
<span className="sr-only">{passiveStatusAnnouncement}</span>
</span>
) : canShowBranchStatus ? (
<span className={cn('inline-flex size-5 items-center justify-center p-0.5', className)}>
{branchStatusIcon}
<span className="sr-only">{passiveStatusAnnouncement}</span>
</span>
) : newCardStyle && showStatus ? (
<>
<span className={cn('inline-flex size-5 items-center justify-center', className)}>
<StatusIndicator status={status} aria-hidden="true" tooltipSide="right" />
</span>
) : canShowBranchStatus ? (
<span className={cn('inline-flex size-5 items-center justify-center p-0.5', className)}>
{branchStatusIcon}
<span className="sr-only">{passiveStatusAnnouncement}</span>
</span>
) : newCardStyle && showStatus ? (
<>
<span className={cn('inline-flex size-5 items-center justify-center', className)}>
<StatusIndicator status={status} aria-hidden="true" tooltipSide="right" />
</span>
<span className="sr-only">{passiveStatusAnnouncement}</span>
</>
) : (
<>
<StatusIndicator
status={status}
aria-hidden="true"
className={className}
tooltipSide="right"
/>
<span className="sr-only">{statusLabel}</span>
</>
)
<span className="sr-only">{passiveStatusAnnouncement}</span>
</>
) : (
<>
<StatusIndicator
status={status}
aria-hidden="true"
className={className}
tooltipSide="right"
/>
<span className="sr-only">{statusLabel}</span>
</>
)
const unreadActionEnabled = showUnreadAction && !newCardStyle
@@ -0,0 +1,223 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { resetAgentStatusEpochClockForTests } from '@/lib/agent-status-epoch-clock'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
import { getWorktreeIdsWithLiveAgent, isInactiveWorkspace } from '@/lib/worktree-activity-state'
import {
useIsSleepingWorktree,
resetWorktreeSleepStateCacheForTests
} from './use-worktree-sleep-state'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
type MockState = {
tabsByWorktree: Record<string, TerminalTab[]>
browserTabsByWorktree: Record<string, { id: string }[]>
ptyIdsByTabId: Record<string, string[]>
agentStatusEpoch: number
agentStatusByPaneKey: Record<string, AgentStatusEntry>
runtimeAgentOrchestrationByPaneKey: Record<string, NonNullable<AgentStatusEntry['orchestration']>>
migrationUnsupportedByPtyId: Record<string, never>
retainedAgentsByPaneKey: Record<string, unknown>
}
let mockState: MockState
vi.mock('@/store', () => ({
useAppStore: (selector: (state: MockState) => unknown) => selector(mockState)
}))
function makeTab(id: string, worktreeId: string): TerminalTab {
return {
id,
worktreeId,
ptyId: 'pty-1',
title: 'bash',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 0
}
}
function makeAgentStatusEntry(args: {
paneKey: string
state: AgentStatusEntry['state']
worktreeId?: string
}): AgentStatusEntry {
return {
paneKey: args.paneKey,
state: args.state,
prompt: '',
updatedAt: 1_000,
stateStartedAt: 1_000,
stateHistory: [],
worktreeId: args.worktreeId,
orchestration: undefined
}
}
function SleepProbe({ worktreeId }: { worktreeId: string }) {
return <span>{String(useIsSleepingWorktree(worktreeId))}</span>
}
describe('useIsSleepingWorktree', () => {
beforeEach(() => {
resetWorktreeSleepStateCacheForTests()
// Why: the epoch clock samples wall time once per epoch, so a suite that keeps
// epoch 0 would otherwise reuse the previous test's timestamp.
resetAgentStatusEpochClockForTests()
vi.spyOn(Date, 'now').mockReturnValue(2_000)
mockState = {
tabsByWorktree: {},
browserTabsByWorktree: {},
ptyIdsByTabId: {},
agentStatusEpoch: 0,
agentStatusByPaneKey: {},
runtimeAgentOrchestrationByPaneKey: {},
migrationUnsupportedByPtyId: {},
retainedAgentsByPaneKey: {}
}
})
afterEach(() => {
vi.restoreAllMocks()
})
it('treats a worktree with no tabs or agents as sleeping', () => {
expect(renderToStaticMarkup(<SleepProbe worktreeId="repo1::/path/wt1" />)).toBe(
'<span>true</span>'
)
})
it('treats a worktree with a live PTY as awake', () => {
const worktreeId = 'repo1::/path/wt1'
mockState = {
...mockState,
tabsByWorktree: { [worktreeId]: [makeTab('tab-1', worktreeId)] },
ptyIdsByTabId: { 'tab-1': ['pty-1'] }
}
expect(renderToStaticMarkup(<SleepProbe worktreeId={worktreeId} />)).toBe('<span>false</span>')
})
it('treats a worktree with only a dead tab as sleeping', () => {
const worktreeId = 'repo1::/path/wt1'
mockState = {
...mockState,
tabsByWorktree: { [worktreeId]: [makeTab('tab-1', worktreeId)] },
ptyIdsByTabId: {}
}
expect(renderToStaticMarkup(<SleepProbe worktreeId={worktreeId} />)).toBe('<span>true</span>')
})
it('treats a browser tab as awake', () => {
const worktreeId = 'repo1::/path/wt1'
mockState = {
...mockState,
browserTabsByWorktree: { [worktreeId]: [{ id: 'browser-1' }] }
}
expect(renderToStaticMarkup(<SleepProbe worktreeId={worktreeId} />)).toBe('<span>false</span>')
})
it('treats retained done rows without runtime as sleeping (#19624)', () => {
const worktreeId = 'repo1::/path/wt1'
const tab = makeTab('tab-1', worktreeId)
const paneKey = makePaneKey('tab-1', LEAF_ID)
mockState = {
...mockState,
retainedAgentsByPaneKey: {
[paneKey]: {
entry: makeAgentStatusEntry({ paneKey, state: 'done' }),
worktreeId,
tab,
agentType: 'codex',
startedAt: 1_000
}
}
}
expect(renderToStaticMarkup(<SleepProbe worktreeId={worktreeId} />)).toBe('<span>true</span>')
})
it('keeps a fresh working agent awake through a PTY gap', () => {
const worktreeId = 'repo1::/path/wt1'
const paneKey = makePaneKey('tab-1', LEAF_ID)
mockState = {
...mockState,
agentStatusByPaneKey: {
[paneKey]: makeAgentStatusEntry({ paneKey, state: 'working', worktreeId })
}
}
expect(renderToStaticMarkup(<SleepProbe worktreeId={worktreeId} />)).toBe('<span>false</span>')
})
it('agrees with the hide-sleeping filter on a stale agent row', () => {
const worktreeId = 'repo1::/path/wt1'
const paneKey = makePaneKey('tab-1', LEAF_ID)
const entry = makeAgentStatusEntry({ paneKey, state: 'working', worktreeId })
vi.spyOn(Date, 'now').mockReturnValue(9_000_000)
resetAgentStatusEpochClockForTests()
mockState = {
...mockState,
// Why: the filter's freshness window is the shared predicate, so an agent row
// this old must stop holding the workspace awake for the moon too.
agentStatusByPaneKey: { [paneKey]: { ...entry, updatedAt: 0 } },
agentStatusEpoch: 1
}
expect(renderToStaticMarkup(<SleepProbe worktreeId={worktreeId} />)).toBe('<span>true</span>')
})
it('matches isInactiveWorkspace across runtime shapes', () => {
const worktreeId = 'repo1::/path/wt1'
const paneKey = makePaneKey('tab-1', LEAF_ID)
const cases = [
{ label: 'bare', state: { ...mockState } },
{
label: 'live pty',
state: {
...mockState,
tabsByWorktree: { [worktreeId]: [makeTab('tab-1', worktreeId)] },
ptyIdsByTabId: { 'tab-1': ['pty-1'] }
}
},
{
label: 'browser tab',
state: { ...mockState, browserTabsByWorktree: { [worktreeId]: [{ id: 'b-1' }] } }
},
{
label: 'live agent',
state: {
...mockState,
agentStatusByPaneKey: {
[paneKey]: makeAgentStatusEntry({ paneKey, state: 'working', worktreeId })
}
}
}
]
// Why assert against the shared predicate itself: the point of the hook is that
// the moon and the hide-sleeping filter can never drift apart (#19624).
for (const { label, state } of cases) {
resetWorktreeSleepStateCacheForTests()
resetAgentStatusEpochClockForTests()
mockState = state
const expected = isInactiveWorkspace(
worktreeId,
state.tabsByWorktree,
state.ptyIdsByTabId,
state.browserTabsByWorktree,
getWorktreeIdsWithLiveAgent(state.agentStatusByPaneKey, state.tabsByWorktree, Date.now())
)
expect(`${label}:${renderToStaticMarkup(<SleepProbe worktreeId={worktreeId} />)}`).toBe(
`${label}:<span>${String(expected)}</span>`
)
}
})
})
@@ -0,0 +1,81 @@
import { useAppStore } from '@/store'
import { getAgentStatusEpochNow } from '@/lib/agent-status-epoch-clock'
import { getWorktreeIdsWithLiveAgent, isInactiveWorkspace } from '@/lib/worktree-activity-state'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
type TabLike = { id: string }
// Why optional: suites mount cards with partial store mocks, and the shared
// predicate already reads a missing slice as empty.
type SleepStateInput = {
agentStatusByPaneKey?: Record<string, AgentStatusEntry> | null
agentStatusEpoch?: number
tabsByWorktree?: Record<string, readonly TabLike[]> | null
ptyIdsByTabId?: Record<string, string[]> | null
browserTabsByWorktree?: Record<string, readonly TabLike[]> | null
}
type LiveAgentGeneration = {
agentStatusByPaneKey: SleepStateInput['agentStatusByPaneKey']
tabsByWorktree: SleepStateInput['tabsByWorktree']
agentStatusNow: number
worktreeIds: ReadonlySet<string>
}
let liveAgentGeneration: LiveAgentGeneration | null = null
// Why cached across cards: zustand re-runs every mounted card's selector on every
// store write, and the live-agent set is a whole-store scan. Keyed on the same
// slices plus the status epoch, so it rebuilds exactly when the filter's own
// snapshot would.
function selectWorktreeIdsWithLiveAgent(state: SleepStateInput): ReadonlySet<string> {
const agentStatusNow = getAgentStatusEpochNow(state.agentStatusEpoch ?? 0)
if (
liveAgentGeneration &&
liveAgentGeneration.agentStatusByPaneKey === state.agentStatusByPaneKey &&
liveAgentGeneration.tabsByWorktree === state.tabsByWorktree &&
liveAgentGeneration.agentStatusNow === agentStatusNow
) {
return liveAgentGeneration.worktreeIds
}
const worktreeIds = getWorktreeIdsWithLiveAgent(
state.agentStatusByPaneKey,
state.tabsByWorktree,
agentStatusNow
)
liveAgentGeneration = {
agentStatusByPaneKey: state.agentStatusByPaneKey,
tabsByWorktree: state.tabsByWorktree,
agentStatusNow,
worktreeIds
}
return worktreeIds
}
/**
* Whether a workspace is asleep: no live terminal, no browser tab, and no live
* agent holding it awake through a PTY gap.
*
* Why not `status === 'inactive'`: a slept workspace keeps its retained done
* rows, so its status still reads 'done' — keying the sleeping glyph on status
* misses exactly the completed-but-slept cards it must distinguish (#19624).
*
* Why `isInactiveWorkspace`: the hide-sleeping filter decides with that same
* predicate, so the moon and the filter cannot disagree about which workspaces
* are asleep.
*/
export function useIsSleepingWorktree(worktreeId: string): boolean {
return useAppStore((state) =>
isInactiveWorkspace(
worktreeId,
state.tabsByWorktree,
state.ptyIdsByTabId,
state.browserTabsByWorktree,
selectWorktreeIdsWithLiveAgent(state)
)
)
}
export function resetWorktreeSleepStateCacheForTests(): void {
liveAgentGeneration = null
}
@@ -4,6 +4,7 @@ import { LoaderCircle } from 'lucide-react'
import { cn } from '@/lib/utils'
import { AutoRenameFailedDialog } from './AutoRenameFailedDialog'
import WorktreeContextMenu from './WorktreeContextMenu'
import { useIsSleepingWorktree } from './use-worktree-sleep-state'
import { WorktreeCardParentContent } from './worktree-card-parent-content'
import { buildWorktreeCardPresentation } from './worktree-card-presentation'
import type { WorktreeCardController } from './use-worktree-card-controller'
@@ -40,6 +41,7 @@ export function WorktreeCardSurface({ card }: { card: WorktreeCardController }):
setShowRenameErrorDialog
} = card
const { titleOnlyCard, cardStyle } = presentation
const isSleeping = useIsSleepingWorktree(worktree.id)
const parentCardContent = <WorktreeCardParentContent card={card} presentation={presentation} />
@@ -66,6 +68,9 @@ export function WorktreeCardSurface({ card }: { card: WorktreeCardController }):
],
titleRenaming && '!border-transparent !bg-transparent !shadow-none !ring-0',
isDeleting && 'opacity-50 grayscale cursor-not-allowed',
// Why: sleep dim carries the awake/sleeping distinction in new-card style,
// where quiet statuses share the branch/PR lane (#19624). Same token as
// the disconnected dim; legacy keeps its green/gray dots untouched.
// Why: no SSH dim — the inline host control now states the disconnected state
// explicitly, and a subtree opacity would composite its destructive tint and spinner
// down to an illegible alpha (a descendant cannot escape an ancestor's opacity).
@@ -94,7 +99,15 @@ export function WorktreeCardSurface({ card }: { card: WorktreeCardController }):
</div>
</div>
)}
{parentCardContent}
{isSleeping && newCardStyle && !isDeleting ? (
// Why a token mix (see [data-worktree-sleeping-dim] in main.css), not opacity:
// opacity dims toward whatever is painted behind, so the step shrank on lighter
// surfaces and vanished on custom backgrounds (#19624). Scoped to the parent row
// so awake lineage children keep their own brightness.
<div data-worktree-sleeping-dim="">{parentCardContent}</div>
) : (
parentCardContent
)}
{newCardStyle && lineageChildren ? (
<div
+2 -1
View File
@@ -6121,7 +6121,8 @@
"setParentFor": "Set parent for"
},
"WorktreeCardStatusSlot": {
"branchIdentity": "Branch"
"branchIdentity": "Branch",
"sleeping": "Sleeping"
},
"NewExternalWorktreesInboxLine": {
"9f2d4c8b17": "Hide external worktrees permanently for {{value0}}",