diff --git a/src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCardDetailsRow.fixture.tsx b/src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCardDetailsRow.fixture.tsx new file mode 100644 index 00000000000..6a479325612 --- /dev/null +++ b/src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCardDetailsRow.fixture.tsx @@ -0,0 +1,83 @@ +import { defineFixture } from '../../../../component-fixtures/component-fixture' +import { useAppStore } from '@/store' +import type { Worktree } from '../../../../../../shared/worktree/types' +import { FIXTURE_REPO, makeWorktree } from '../../worktree-card-slot-fixture-seeds' +import WorktreeCard from './WorktreeCard' + +const now = Date.now() +const workspace: Worktree = { + ...makeWorktree({ id: 'details-row', name: 'deploy-preview', agents: [] }, now), + comment: 'Preview ready for review', + cliProvenance: { kind: 'created-by-cli', createdAt: now }, + linkedItems: [ + { + provider: 'github', + type: 'issue', + number: 123, + url: 'https://github.com/stablyai/orca/issues/123' + } + ] +} + +function fixture(detailsRow: boolean, indicators = true) { + return defineFixture({ + width: 320, + setup: () => + useAppStore.setState((state) => ({ + repos: [FIXTURE_REPO], + worktreesByRepo: { [FIXTURE_REPO.id]: [workspace] }, + settings: state.settings + ? { + ...state.settings, + experimentalNewWorktreeCardStyle: true, + worktreeCardDetailsRow: detailsRow + } + : null, + worktreeCardProperties: indicators + ? ['status', 'branch', 'host', 'issue', 'comment', 'cli', 'ports'] + : ['status', 'branch'], + workspacePortScan: { + key: 'details-row', + result: { + platform: 'unknown', + scannedAt: now, + ports: [ + { + kind: 'workspace', + id: 'preview', + bindHost: '127.0.0.1', + connectHost: 'localhost', + port: 3000, + protocol: 'http', + owner: { + worktreeId: workspace.id, + repoId: workspace.repoId, + displayName: workspace.displayName, + path: workspace.path, + confidence: 'cwd' + } + } + ] + } + } + })), + render: () => ( + + ) + }) +} + +export const shown = fixture(true) +export const hidden = fixture(false) +export const displayOptionsOff = fixture(false, false) +export const hiddenWithConflict = defineFixture({ + ...fixture(false), + store: { gitConflictOperationByWorktree: { [workspace.id]: 'cherry-pick' } } +}) diff --git a/src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCardStatusSlot.tsx b/src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCardStatusSlot.tsx index c40fa0da736..a16719aa345 100644 --- a/src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCardStatusSlot.tsx +++ b/src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCardStatusSlot.tsx @@ -13,6 +13,7 @@ import { FilledBellIcon } from '../identity/worktree-card-glyphs' import { checksLabel } from '../review/workspace-review-check-label' import StatusIndicator from './StatusIndicator' import { useWorktreeActivityStatus } from '../activity/use-worktree-activity-status' +import { laneStatusWithLineage } from './worktree-card-lineage-lane-status' import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store/index' import { @@ -35,6 +36,8 @@ type WorktreeCardStatusSlotProps = { showStatus: boolean showUnreadAction: boolean isUnread: boolean + /** A collapsed descendant's needs-you, failed or working state; replaces a quieter own status. */ + lineageStatus?: WorktreeStatus | null unreadTooltip: string onToggleUnread: React.MouseEventHandler onPointerDown: React.PointerEventHandler @@ -126,6 +129,7 @@ export function WorktreeCardStatusSlot({ showStatus, showUnreadAction, isUnread, + lineageStatus = null, unreadTooltip, onToggleUnread, onPointerDown, @@ -140,7 +144,7 @@ export function WorktreeCardStatusSlot({ const t = useTranslate() const collectionChecks = reviewChecks && reviewChecks.total > 1 ? reviewChecks : undefined const isReviewStale = reviewStale || Boolean(collectionChecks?.stale) - const status = useWorktreeActivityStatus(worktreeId) + const status = laneStatusWithLineage(useWorktreeActivityStatus(worktreeId), lineageStatus) // One subscription per card: rows mount by the hundreds, and every listener runs on each store write. const { isSleeping, vmRuntime, vmWaking } = useAppStore( useShallow((state) => ({ diff --git a/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-lineage-lane-status.test.ts b/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-lineage-lane-status.test.ts new file mode 100644 index 00000000000..23b0f7e6e0a --- /dev/null +++ b/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-lineage-lane-status.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { laneStatusWithLineage, lineageLaneStatus } from './worktree-card-lineage-lane-status' + +describe('lineageLaneStatus', () => { + it('claims the most urgent loud state and ignores unread', () => { + expect(lineageLaneStatus([{ kind: 'unread', at: 1, manual: false }])).toBeNull() + expect( + lineageLaneStatus([ + { kind: 'unread', at: 1, manual: false }, + { kind: 'working', at: 2 } + ]) + ).toBe('working') + expect(lineageLaneStatus([{ kind: 'working', at: 2, monitoring: true }])).toBe('monitoring') + expect( + lineageLaneStatus([ + { kind: 'needs', count: 1 }, + { kind: 'failed' }, + { kind: 'working', at: 2 } + ]) + ).toBe('permission') + }) +}) + +describe('laneStatusWithLineage', () => { + it('lets a running descendant replace a quiet parent', () => { + expect(laneStatusWithLineage('inactive', 'working')).toBe('working') + expect(laneStatusWithLineage('done', 'permission')).toBe('permission') + expect(laneStatusWithLineage('active', null)).toBe('active') + }) + + it('keeps the parent when its own state is as urgent or more', () => { + expect(laneStatusWithLineage('permission', 'working')).toBe('permission') + expect(laneStatusWithLineage('working', 'monitoring')).toBe('working') + expect(laneStatusWithLineage('failed', 'failed')).toBe('failed') + }) +}) diff --git a/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-lineage-lane-status.ts b/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-lineage-lane-status.ts new file mode 100644 index 00000000000..d5776e1f526 --- /dev/null +++ b/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-lineage-lane-status.ts @@ -0,0 +1,43 @@ +import type { WorktreeStatus } from '../../../../../../shared/worktree-status-rollup' +import type { SlotException } from '../../worktree-card-slot-signal' + +// Only the states loud enough to take over a collapsed parent's lane; unread stays on the bold title. +const LANE_URGENCY: Partial> = { + permission: 0, + failed: 1, + working: 2, + monitoring: 3 +} + +/** The lane status collapsed descendants would claim: needs you, then failed, then working. */ +export function lineageLaneStatus(exceptions: readonly SlotException[]): WorktreeStatus | null { + let best: WorktreeStatus | null = null + for (const exception of exceptions) { + const status: WorktreeStatus | null = + exception.kind === 'needs' + ? 'permission' + : exception.kind === 'failed' + ? 'failed' + : exception.kind === 'working' + ? exception.monitoring + ? 'monitoring' + : 'working' + : null + if (status && (best === null || LANE_URGENCY[status]! < LANE_URGENCY[best]!)) { + best = status + } + } + return best +} + +/** The parent's own status, unless a collapsed descendant's is more urgent. */ +export function laneStatusWithLineage( + own: WorktreeStatus, + lineage: WorktreeStatus | null | undefined +): WorktreeStatus { + if (!lineage) { + return own + } + const ownUrgency = LANE_URGENCY[own] ?? Number.POSITIVE_INFINITY + return LANE_URGENCY[lineage]! < ownUrgency ? lineage : own +} diff --git a/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-status-lane.tsx b/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-status-lane.tsx index d4c591509dc..f65d59573c4 100644 --- a/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-status-lane.tsx +++ b/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-status-lane.tsx @@ -4,7 +4,10 @@ import { cn } from '@/lib/class-names' import { useWslHostOffForRepo } from '../../use-wsl-host-off-review' import { WorktreeCardStatusSlot } from './WorktreeCardStatusSlot' import type { WorktreeCardController } from '../controller/use-worktree-card-controller' -import { useWorktreeCardRolledUpUnread } from './worktree-card-title-status' +import { + useWorktreeCardLineageLaneStatus, + useWorktreeCardRolledUpUnread +} from './worktree-card-title-status' /** The card's leading status lane: activity, review and the unread toggle or dot. */ export function WorktreeCardStatusLane({ @@ -15,8 +18,9 @@ export function WorktreeCardStatusLane({ showUnreadAction: boolean }): React.JSX.Element { const reviewStale = useWslHostOffForRepo(card.repo) - // The dot also speaks for folded agents and collapsed child workspaces. + // The dot also speaks for its agents and collapsed child workspaces. const rolledUpUnread = useWorktreeCardRolledUpUnread() + const lineageStatus = useWorktreeCardLineageLaneStatus() return (