mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -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
|
||||
|
||||
+4
@@ -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
|
||||
|
||||
@@ -6121,7 +6121,8 @@
|
||||
"setParentFor": "Set parent for"
|
||||
},
|
||||
"WorktreeCardStatusSlot": {
|
||||
"branchIdentity": "Branch"
|
||||
"branchIdentity": "Branch",
|
||||
"sleeping": "Sleeping"
|
||||
},
|
||||
"NewExternalWorktreesInboxLine": {
|
||||
"9f2d4c8b17": "Hide external worktrees permanently for {{value0}}",
|
||||
|
||||
Reference in New Issue
Block a user