From 43ae84df592f3dde874041668a3a73079889d01f Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 6 Nov 2026 23:48:29 -0800 Subject: [PATCH] wip --- .github/workflows/node-server-tests.yml | 8 + .../settings/runtime-client-settings-shape.ts | 2 + .../settings/runtime-client-settings.ts | 1 + .../runtime-workspace-view-change-events.ts | 1 + .../construction/runtime-store-contract.ts | 1 + .../sidebar/WorktreeCardSlotContent.tsx | 15 +- .../worktree-agent-rows-projection.ts | 16 +- .../review/worktree-review-presentation.tsx | 11 +- .../surface/worktree-card-surface.tsx | 2 +- .../sidebar/worktree-card-leading-tabs.ts | 1 + .../sidebar/worktree-card-pr-rollup.tsx | 241 +++++++++++++----- .../worktree-card-slot-invalidation.ts | 106 ++++++++ .../sidebar/worktree-card-slot-range.ts | 117 +++++++++ .../sidebar/worktree-card-slot-rollup.test.ts | 51 +++- .../sidebar/worktree-card-slot-rollup.ts | 84 +++--- .../worktree-card-disclosure-rows.test.ts | 91 +++++++ .../listing/worktree-card-disclosure-rows.ts | 22 +- .../sidebar/worktree-list/rows/folder-row.tsx | 2 + .../viewport/use-visible-review-refresh.ts | 45 +++- .../refresh/stale-worktree-refresh-actions.ts | 2 +- .../web/preload-api/web-runtime-session.ts | 2 + .../web-worktree-card-style-sync.ts | 9 +- .../src/web/web-preload-api-settings.test.ts | 39 +++ .../client-ui/client-settings-params.ts | 1 + src/shared/sidebar-list/row-types.ts | 5 + .../sidebar-list/sections/row-builders.ts | 5 + 26 files changed, 728 insertions(+), 152 deletions(-) create mode 100644 src/renderer/src/components/sidebar/worktree-card-slot-invalidation.ts create mode 100644 src/renderer/src/components/sidebar/worktree-card-slot-range.ts diff --git a/.github/workflows/node-server-tests.yml b/.github/workflows/node-server-tests.yml index bf9d65525a1..a5c5dc91b43 100644 --- a/.github/workflows/node-server-tests.yml +++ b/.github/workflows/node-server-tests.yml @@ -227,7 +227,15 @@ jobs: with: arches: ${{ (inputs.build_template && matrix.os == 'windows-2022') && 'x64 arm64' || (runner.arch == 'ARM64' && 'arm64' || 'x64') }} - uses: actions/download-artifact@v8 + id: mobile-web-artifact if: needs.changes.outputs.qualification != 'false' + continue-on-error: true + with: + name: orcad-mobile-web-bundle + path: out/mobile-web + - name: Retry mobile web artifact download + if: steps.mobile-web-artifact.outcome == 'failure' + uses: actions/download-artifact@v8 with: name: orcad-mobile-web-bundle path: out/mobile-web diff --git a/src/main/runtime/client/settings/runtime-client-settings-shape.ts b/src/main/runtime/client/settings/runtime-client-settings-shape.ts index 7b823e947f5..88df33e74c3 100644 --- a/src/main/runtime/client/settings/runtime-client-settings-shape.ts +++ b/src/main/runtime/client/settings/runtime-client-settings-shape.ts @@ -26,6 +26,7 @@ export type RuntimeClientSettings = Pick< | 'githubProjects' | 'experimentalNewWorktreeCardStyle' | 'worktreeCardStyle' + | 'worktreeCardDetailsRow' | 'compactWorktreeCards' | 'minimaxGroupId' | 'minimaxUsageModels' @@ -77,6 +78,7 @@ export type RuntimeClientSettingsUpdate = Pick< | 'githubProjects' | 'experimentalNewWorktreeCardStyle' | 'worktreeCardStyle' + | 'worktreeCardDetailsRow' | 'compactWorktreeCards' | 'minimaxGroupId' | 'minimaxUsageModels' diff --git a/src/main/runtime/client/settings/runtime-client-settings.ts b/src/main/runtime/client/settings/runtime-client-settings.ts index cfd28184a5f..8f9ba2cde54 100644 --- a/src/main/runtime/client/settings/runtime-client-settings.ts +++ b/src/main/runtime/client/settings/runtime-client-settings.ts @@ -94,6 +94,7 @@ export class RuntimeClientSettingsController { experimentalNewWorktreeCardStyle: settings.experimentalNewWorktreeCardStyle === true, // Always present, so a client can tell this host accepts the three-way choice. worktreeCardStyle: getWorktreeCardStyle(settings), + worktreeCardDetailsRow: settings.worktreeCardDetailsRow === true, compactWorktreeCards: settings.compactWorktreeCards === true, minimaxGroupId: settings.minimaxGroupId ?? '', minimaxUsageModels: settings.minimaxUsageModels ?? 'general', diff --git a/src/main/runtime/client/settings/runtime-workspace-view-change-events.ts b/src/main/runtime/client/settings/runtime-workspace-view-change-events.ts index 03e264daf90..3e1003d1f66 100644 --- a/src/main/runtime/client/settings/runtime-workspace-view-change-events.ts +++ b/src/main/runtime/client/settings/runtime-workspace-view-change-events.ts @@ -10,6 +10,7 @@ const WORKSPACE_VIEW_SETTINGS: readonly (keyof GlobalSettings)[] = [ 'compactWorktreeCards', 'experimentalNewWorktreeCardStyle', 'worktreeCardStyle', + 'worktreeCardDetailsRow', 'showPinnedWorktreesInGroups' ] diff --git a/src/main/runtime/construction/runtime-store-contract.ts b/src/main/runtime/construction/runtime-store-contract.ts index 13b1b8278bf..ea77ef7a205 100644 --- a/src/main/runtime/construction/runtime-store-contract.ts +++ b/src/main/runtime/construction/runtime-store-contract.ts @@ -169,6 +169,7 @@ export type RuntimeStore = { githubProjects?: GlobalSettings['githubProjects'] experimentalNewWorktreeCardStyle?: GlobalSettings['experimentalNewWorktreeCardStyle'] worktreeCardStyle?: GlobalSettings['worktreeCardStyle'] + worktreeCardDetailsRow?: GlobalSettings['worktreeCardDetailsRow'] compactWorktreeCards?: GlobalSettings['compactWorktreeCards'] minimaxGroupId?: GlobalSettings['minimaxGroupId'] minimaxUsageModels?: GlobalSettings['minimaxUsageModels'] diff --git a/src/renderer/src/components/sidebar/WorktreeCardSlotContent.tsx b/src/renderer/src/components/sidebar/WorktreeCardSlotContent.tsx index 15be9a2665a..dc4a238809b 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardSlotContent.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardSlotContent.tsx @@ -318,7 +318,20 @@ export function WorktreeCardSlotContent({ /> )} { + const review = card.statusLaneReview + if (review?.provider === 'github' && card.repo && review.url) { + card.handleOpenReviewInOrca(event, review) + } else if (review?.url) { + event.stopPropagation() + card.handleOpenReviewInBrowser(review.url) + } else { + event.stopPropagation() + card.detailsHoverControl.handleHoverOpenChange(true) + } + }} hiddenDescendants={lineageHidden ? descendants : NO_DESCENDANTS} /> {sshDown && host ? ( diff --git a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-agent-rows-projection.ts b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-agent-rows-projection.ts index e0944ff280a..ef433c13d1d 100644 --- a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-agent-rows-projection.ts +++ b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-agent-rows-projection.ts @@ -120,13 +120,11 @@ export function createWorktreeAgentRowsSelector(worktreeId: string, hostId?: Exe } } -/** Gate all model inputs before walking any workspace, including orchestration's ownership inputs. */ -export function worktreeAgentProjectionInputs(state: AppState): readonly unknown[] { +/** Structural changes can alter agent ownership; live status and acknowledgements invalidate only their owners. */ +export function worktreeAgentStructureInputs(state: AppState): readonly unknown[] { return [ state.tabsByWorktree, state.unifiedTabsByWorktree, - state.agentStatusByPaneKey, - state.agentStatusEpoch, state.migrationUnsupportedByPtyId, state.retainedAgentsByPaneKey, state.runtimePaneTitlesByTabId, @@ -134,8 +132,16 @@ export function worktreeAgentProjectionInputs(state: AppState): readonly unknown state.terminalLayoutsByTabId, state.runtimeAgentOrchestrationByPaneKey, state.paneForegroundAgentByPaneKey, - state.acknowledgedAgentsByPaneKey, selectWorktreesByRepo(state), ...structuredAgentSessionOwnerInputs(state) ] } + +export function worktreeAgentProjectionInputs(state: AppState): readonly unknown[] { + return [ + ...worktreeAgentStructureInputs(state), + state.agentStatusByPaneKey, + state.agentStatusEpoch, + state.acknowledgedAgentsByPaneKey + ] +} diff --git a/src/renderer/src/components/sidebar/workspace-card/review/worktree-review-presentation.tsx b/src/renderer/src/components/sidebar/workspace-card/review/worktree-review-presentation.tsx index 0ea0805c51f..c8bd6b09533 100644 --- a/src/renderer/src/components/sidebar/workspace-card/review/worktree-review-presentation.tsx +++ b/src/renderer/src/components/sidebar/workspace-card/review/worktree-review-presentation.tsx @@ -71,6 +71,12 @@ export function getReviewStateTone(state: WorktreeCardPrDisplay['state']): strin return 'text-muted-foreground opacity-70' } +export function getReviewTone( + review: Pick +): string { + return getCheckTone(review) ?? getReviewStateTone(review.state) +} + export function ReviewIcon({ review, className, @@ -87,9 +93,6 @@ export function ReviewIcon({ variant === 'provider' && review.provider === 'gitlab' ? GitMerge : PullRequestIcon const Icon = getReviewStateIcon(review.state) ?? providerIcon return createElement(Icon, { - className: cn( - className, - (showChecks ? getCheckTone(review) : null) ?? getReviewStateTone(review.state) - ) + className: cn(className, showChecks ? getReviewTone(review) : getReviewStateTone(review.state)) }) } diff --git a/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-surface.tsx b/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-surface.tsx index 2e0c89ab2bf..da219539430 100644 --- a/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-surface.tsx +++ b/src/renderer/src/components/sidebar/workspace-card/surface/worktree-card-surface.tsx @@ -150,7 +150,7 @@ export function WorktreeCardSurface({ card }: { card: WorktreeCardController }): parentCardContent )} - {lineageChildren ? ( + {lineageChildren && (!slotLayout || cardOpen.open) ? (
+const emptyStates = (): PrStates => ({ + open: { count: 0, review: null }, + draft: { count: 0, review: null }, + merged: { count: 0, review: null }, + closed: { count: 0, review: null } +}) -type PrStateCounts = Record - -const NO_COUNTS: PrStateCounts = { open: 0, draft: 0, merged: 0, closed: 0 } - -const treeCounts = new WeakMap< +function priority(review: WorktreeCardPrDisplay): number { + const outcome = review.status && getProviderChecksOutcome(review.checksSummary, review.status) + return outcome === 'failure' ? 0 : outcome === 'pending' ? 1 : outcome === 'cancelled' ? 2 : 3 +} +function merge(into: PrSummary, from: PrSummary): void { + into.count += from.count + if (from.review && (!into.review || priority(from.review) < priority(into.review))) { + into.review = from.review + } +} +function add(states: PrStates, review: WorktreeCardPrDisplay | null): void { + if (review?.state) { + merge(states[review.state], { count: 1, review }) + } +} +const treeStates = new WeakMap< WorktreeLineageTree, - { inputs: readonly unknown[]; counts: PrStateCounts[] } + { inputs: readonly unknown[]; states: PrStates[] } >() function reviewInputs(state: AppState): readonly unknown[] { return [ @@ -42,55 +75,47 @@ function reviewInputs(state: AppState): readonly unknown[] { ] } const repoReaders = new WeakMap>() -function ownPrCounts( - state: AppState, - worktree: NonNullable> -): PrStateCounts { +function ownReview(state: AppState, worktree: Worktree): WorktreeCardPrDisplay | null { let repoOf = repoReaders.get(state.repos) if (!repoOf) { repoOf = createHostScopedRepoOf(state.repos, getIndexedRepoMap(state.repos)) repoReaders.set(state.repos, repoOf) } - const repo = repoOf(worktree) - const review = selectWorktreeCardPrDisplay(state, worktree, repo) - return { ...NO_COUNTS, ...(review?.state ? { [review.state]: 1 } : {}) } + return selectWorktreeCardPrDisplay(state, worktree, repoOf(worktree)) } -function selectHiddenPrCounts(state: AppState, source: WorktreeLineageSource): PrStateCounts { +function hiddenStates(state: AppState, source: WorktreeLineageSource): PrStates { if (isLineageRange(source)) { const { tree } = source const inputs = reviewInputs(state) - const cached = treeCounts.get(tree) + const cached = treeStates.get(tree) if (cached && inputs.every((value, index) => value === cached.inputs[index])) { - return cached.counts[source.index]! + return cached.states[source.index]! } const own = tree.identities.map((identity, index) => - ownPrCounts(state, worktreeForIdentity(state, identity) ?? tree.worktrees[index]!) + ownReview(state, worktreeForIdentity(state, identity) ?? tree.worktrees[index]!) ) - const counts = own.map(() => ({ ...NO_COUNTS })) + const states = own.map(emptyStates) for (let i = own.length - 1; i >= 0; i--) { const parent = tree.parents[i]! if (parent >= 0) { + add(states[parent]!, own[i]!) for (const key of STATE_ORDER) { - counts[parent]![key] += own[i]![key] + counts[i]![key] + merge(states[parent]![key], states[i]![key]) } } } - treeCounts.set(tree, { inputs, counts }) - return counts[source.index]! + treeStates.set(tree, { inputs, states }) + return states[source.index]! } - const counts = { ...NO_COUNTS } + const states = emptyStates() for (const identity of lineageIdentities(source)) { const worktree = worktreeForIdentity(state, identity) if (worktree) { - const own = ownPrCounts(state, worktree) - for (const key of STATE_ORDER) { - counts[key] += own[key] - } + add(states, ownReview(state, worktree)) } } - return counts + return states } - function stateLabel(state: HostedReviewState, count: number): string { switch (state) { case 'open': @@ -104,59 +129,145 @@ function stateLabel(state: HostedReviewState, count: number): string { } } -/** - * The card's own PR plus those of hidden child workspaces, one icon per state with its count - * on the icon's corner, so a collapsed parent still says what its subtree has in review. - */ export function WorktreeCardPrRollup({ own, - hiddenDescendants + hiddenDescendants, + onOpenOwn, + ownRepresentsDescendant = false }: { own: WorktreeCardPrDisplay | null + ownRepresentsDescendant?: boolean hiddenDescendants: WorktreeLineageSource + onOpenOwn: (event: React.MouseEvent) => void }): React.JSX.Element | null { const select = useMemo(() => { let inputs: readonly unknown[] = [] - let counts = NO_COUNTS + let result = emptyStates() return (state: AppState) => { const next = reviewInputs(state) - if (next.every((value, index) => value === inputs[index])) { - return counts + if (!next.every((value, index) => value === inputs[index])) { + inputs = next + result = hiddenStates(state, hiddenDescendants) } - inputs = next - counts = selectHiddenPrCounts(state, hiddenDescendants) - return counts + return result } }, [hiddenDescendants]) const hidden = useAppStore(useShallow(select)) - const counts = { ...hidden } - if (own?.state) { - counts[own.state] += 1 + const [reviews, setReviews] = useState([]) + const states: PrStates = { + open: { ...hidden.open }, + draft: { ...hidden.draft }, + merged: { ...hidden.merged }, + closed: { ...hidden.closed } } - const shown = STATE_ORDER.filter((state) => counts[state] > 0) - if (shown.length === 0) { + const hasHidden = STATE_ORDER.some((key) => hidden[key].count > 0) + const showOwn = !ownRepresentsDescendant || !hasHidden + if (showOwn) { + add(states, own) + } + const shown = STATE_ORDER.filter((key) => states[key].count > 0) + if (!own && shown.length === 0) { return null } - return ( - + const icons = ( + <> {shown.map((state) => ( - {createElement(getReviewStateIcon(state) ?? PullRequestIcon, { - className: 'size-3 shrink-0' - })} - {counts[state] > 1 ? ( + + {states[state].count > 1 ? ( - {counts[state]} + {states[state].count} ) : null} ))} - + {showOwn && own && !own.state ? ( + + ) : null} + + ) + const buttonClass = + 'inline-flex shrink-0 items-center gap-1 rounded-sm p-0.5 hover:bg-sidebar-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring' + if (!hasHidden) { + return ( + + ) + } + return ( + { + if (open) { + const state = useAppStore.getState() + const next: ReviewTarget[] = [] + for (const identity of lineageIdentities(hiddenDescendants)) { + const worktree = worktreeForIdentity(state, identity) + const review = worktree && ownReview(state, worktree) + if (worktree && review) { + next.push({ worktree, review }) + } + } + setReviews(next) + } + }} + > + + + + event.stopPropagation()} align="end"> + {showOwn && own ? ( + + + {getReviewLabel(own)} #{own.number}: {own.title} + + ) : null} + {reviews.map(({ worktree, review }) => ( + { + activateAndRevealWorkspace(worktree.id, { executionHostId: worktree.hostId }) + if (review.url) { + void openWorkspaceBrowserTab({ + workspaceId: worktree.id, + url: review.url, + intent: { kind: 'url' } + }).catch((error: unknown) => + toast.error(error instanceof Error ? error.message : String(error)) + ) + } + }} + > + + + {worktree.displayName} · {getReviewLabel(review)} #{review.number}: {review.title} + + + ))} + + ) } diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-invalidation.ts b/src/renderer/src/components/sidebar/worktree-card-slot-invalidation.ts new file mode 100644 index 00000000000..007b5d34e09 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-slot-invalidation.ts @@ -0,0 +1,106 @@ +import type { AppState } from '@/store/types' +import { isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence' +import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status/contracts/agent-status-freshness' +import { getStructuredAgentSessionTabsByPaneKey } from '@/components/native-chat/structured-agent-session-tabs' +import { parsePaneKey } from '../../../../shared/workspace-layout/stable-pane-id' +import { getTabIdToWorktreeId } from './workspace-card/activity/worktree-agent-row-selectors' +import { + worktreeAgentProjectionInputs, + worktreeAgentStructureInputs +} from './workspace-card/activity/worktree-agent-rows-projection' + +export type SlotSnapshot = { + inputs: readonly unknown[] + structure: readonly unknown[] + live: AppState['agentStatusByPaneKey'] + acknowledgements: AppState['acknowledgedAgentsByPaneKey'] + epoch: number + at: number +} +let current: SlotSnapshot | undefined +export function slotSnapshot(state: AppState): SlotSnapshot { + const inputs = worktreeAgentProjectionInputs(state) + if (!current || !inputs.every((value, index) => value === current!.inputs[index])) { + current = { + inputs, + structure: worktreeAgentStructureInputs(state), + live: state.agentStatusByPaneKey, + acknowledgements: state.acknowledgedAgentsByPaneKey, + epoch: state.agentStatusEpoch, + at: current?.epoch === state.agentStatusEpoch ? current.at : Date.now() + } + } + return current +} +const changes = new WeakMap | null>>() + +export function changedSlotWorkspaces( + state: AppState, + before: SlotSnapshot, + after: SlotSnapshot +): Set | null { + const cached = changes.get(before)?.get(after) + if (cached !== undefined) { + return cached + } + let result: Set | null = null + if (after.structure.every((value, index) => value === before.structure[index])) { + result = new Set() + const tabOwners = getTabIdToWorktreeId(state.tabsByWorktree) + const includePane = (paneKey: string): void => { + const parsed = parsePaneKey(paneKey) + const tabOwner = parsed && tabOwners.get(parsed.tabId) + const represented = getStructuredAgentSessionTabsByPaneKey(state.unifiedTabsByWorktree).get( + paneKey + ) + if (tabOwner) { + result!.add(tabOwner) + } + // Structured sessions may have no backing terminal tab. + if (represented) { + result!.add(represented.worktreeId) + } + for (const entry of [ + before.live[paneKey], + after.live[paneKey], + state.retainedAgentsByPaneKey[paneKey]?.entry + ]) { + if (entry?.worktreeId) { + result!.add(entry.worktreeId) + } + } + } + if (before.live !== after.live || before.epoch !== after.epoch) { + for (const paneKey of new Set([...Object.keys(before.live), ...Object.keys(after.live)])) { + const previous = before.live[paneKey] + const next = after.live[paneKey] + if ( + previous !== next || + (next && + before.epoch !== after.epoch && + isExplicitAgentStatusFresh(next, before.at, AGENT_STATUS_STALE_AFTER_MS) !== + isExplicitAgentStatusFresh(next, after.at, AGENT_STATUS_STALE_AFTER_MS)) + ) { + includePane(paneKey) + } + } + } + if (before.acknowledgements !== after.acknowledgements) { + for (const paneKey of new Set([ + ...Object.keys(before.acknowledgements), + ...Object.keys(after.acknowledgements) + ])) { + if (before.acknowledgements[paneKey] !== after.acknowledgements[paneKey]) { + includePane(paneKey) + } + } + } + } + let next = changes.get(before) + if (!next) { + next = new WeakMap() + changes.set(before, next) + } + next.set(after, result) + return result +} diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-range.ts b/src/renderer/src/components/sidebar/worktree-card-slot-range.ts new file mode 100644 index 00000000000..be0b2374c92 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-slot-range.ts @@ -0,0 +1,117 @@ +import type { AppState } from '@/store/types' +import type { Worktree } from '../../../../shared/worktree/types' +import type { HostScopedWorktreeKey } from '../../../../shared/worktree/host-qualified-identity' +import { getWorktreeIdFromHostIdentity } from '../../../../shared/worktree/host-qualified-identity' +import type { WorktreeLineageTree } from '../../../../shared/sidebar-list/worktree-lineage-range' +import type { SlotTarget } from './worktree-card-slot-targets' +import { + toLineageSlotRecord, + mergeRecord, + type LineageSlotRecord +} from './worktree-card-slot-summary' +import { + slotSnapshot, + changedSlotWorkspaces, + type SlotSnapshot +} from './worktree-card-slot-invalidation' + +export type WorkspaceSlot = { + targets: SlotTarget[] + record: LineageSlotRecord + latestAt: number | null +} +export function createLineageSlotReader( + ownSlot: (state: AppState, identity: HostScopedWorktreeKey, fallback?: Worktree) => WorkspaceSlot +) { + type SlotAggregate = { record: LineageSlotRecord; latestAt: number | null } + type TreeSlots = { + snapshot: SlotSnapshot + own: WorkspaceSlot[] + size: number + segments: SlotAggregate[] + indicesById: Map + } + const treeSlots = new WeakMap() + const emptyAggregate = (): SlotAggregate => ({ record: toLineageSlotRecord([]), latestAt: null }) + function combineSlots(left: SlotAggregate, right: SlotAggregate): SlotAggregate { + const record = { ...left.record } + mergeRecord(record, right.record) + return { record, latestAt: Math.max(left.latestAt ?? 0, right.latestAt ?? 0) || null } + } + function updateTreeSlot(slots: TreeSlots, index: number, own: WorkspaceSlot): void { + slots.own[index] = own + let node = slots.size + index + slots.segments[node] = own + while (node > 1) { + node = Math.floor(node / 2) + slots.segments[node] = combineSlots(slots.segments[node * 2]!, slots.segments[node * 2 + 1]!) + } + } + function slotsForTree(state: AppState, tree: WorktreeLineageTree): TreeSlots { + const snapshot = slotSnapshot(state) + const cached = treeSlots.get(tree) + if (cached?.snapshot === snapshot) { + return cached + } + const changed = cached && changedSlotWorkspaces(state, cached.snapshot, snapshot) + if (cached && changed) { + for (const id of changed) { + for (const index of cached.indicesById.get(id) ?? []) { + updateTreeSlot( + cached, + index, + ownSlot(state, tree.identities[index]!, tree.worktrees[index]) + ) + } + } + cached.snapshot = snapshot + return cached + } + const own = tree.identities.map((identity, index) => + ownSlot(state, identity, tree.worktrees[index]) + ) + const size = 2 ** Math.ceil(Math.log2(Math.max(own.length, 1))) + const segments = Array.from({ length: size * 2 }, emptyAggregate) + own.forEach((slot, index) => { + segments[size + index] = slot + }) + for (let node = size - 1; node > 0; node--) { + segments[node] = combineSlots(segments[node * 2]!, segments[node * 2 + 1]!) + } + const indicesById = new Map() + tree.identities.forEach((identity, index) => { + const id = getWorktreeIdFromHostIdentity(identity) + const indices = indicesById.get(id) ?? [] + indices.push(index) + indicesById.set(id, indices) + }) + const result = { snapshot, own, size, segments, indicesById } + treeSlots.set(tree, result) + return result + } + + /** Range queries and localized updates merge O(log N) records, without reprojecting siblings. */ + function descendantSlot( + state: AppState, + tree: WorktreeLineageTree, + index: number + ): SlotAggregate { + const slots = slotsForTree(state, tree) + let start = slots.size + index + 1 + let end = slots.size + tree.ends[index]! + let result = emptyAggregate() + while (start < end) { + if (start % 2) { + result = combineSlots(result, slots.segments[start++]!) + } + if (end % 2) { + result = combineSlots(result, slots.segments[--end]!) + } + start = Math.floor(start / 2) + end = Math.floor(end / 2) + } + return result + } + + return { slotsForTree, descendantSlot } +} diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-rollup.test.ts b/src/renderer/src/components/sidebar/worktree-card-slot-rollup.test.ts index 9e078949936..9af540c5645 100644 --- a/src/renderer/src/components/sidebar/worktree-card-slot-rollup.test.ts +++ b/src/renderer/src/components/sidebar/worktree-card-slot-rollup.test.ts @@ -1,7 +1,9 @@ +import { buildWorktreeOccurrencePlan } from '../../../../shared/worktree-occurrence-plan' +import { buildWorktreeLineageRanges } from '../../../../shared/sidebar-list/worktree-lineage-range' // @vitest-environment happy-dom import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' -import { makeTab } from '@/store/slices/store-test-helpers' +import { makeTab, makeWorktree } from '@/store/slices/store-test-helpers' import { makePaneKey } from '../../../../shared/workspace-layout/stable-pane-id' import type { AgentStatusEntry } from '../../../../shared/agent-status/contracts/agent-status-types' import { composeWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity' @@ -123,3 +125,50 @@ it('expires hidden working evidence on the same freshness tick as agent rows', ( useAppStore.setState({ agentStatusEpoch: 2 }) expect(selectCollapsedLineageSlot(useAppStore.getState(), [identity])).toBeNull() }) + +it('updates hidden subtree attention after status changes, acknowledgement, and freshness expiry', () => { + const parent = makeWorktree({ id: 'parent', repoId: 'repo1', hostId: 'local', lastActivityAt: 0 }) + const child = makeWorktree({ id: 'child', repoId: 'repo1', hostId: 'local', lastActivityAt: 0 }) + const plan = buildWorktreeOccurrencePlan({ + rows: [parent, child], + identityOf: (row) => composeWorktreeHostIdentity(row.hostId, row.id), + childrenByParentIdentity: new Map([[composeWorktreeHostIdentity('local', 'parent'), [child]]]), + isPinned: () => false, + duplicatePinned: false, + sectionsOf: ({ natural }) => [{ key: 'all', items: natural }] + }) + const source = [...buildWorktreeLineageRanges(plan).values()][0]! + useAppStore.setState({ + worktreesByRepo: { repo1: [parent, child] }, + tabsByWorktree: { child: [makeTab({ id: 'agent', worktreeId: 'child', title: '' })] }, + agentStatusByPaneKey: { [pane('agent')]: entry('agent') } + }) + expect(selectCollapsedLineageSlot(useAppStore.getState(), source)?.kind).toBe('working') + useAppStore.setState({ + agentStatusByPaneKey: { [pane('agent')]: entry('agent', { state: 'blocked' }) }, + agentStatusEpoch: 2 + }) + expect(selectCollapsedLineageSlot(useAppStore.getState(), source)).toEqual({ + kind: 'needs', + count: 1 + }) + useAppStore.setState({ + agentStatusByPaneKey: { [pane('agent')]: entry('agent', { state: 'done' }) }, + agentStatusEpoch: 3 + }) + expect(selectCollapsedLineageSlot(useAppStore.getState(), source)).toEqual({ + kind: 'unread', + at: 1_900_000, + manual: false + }) + useAppStore.setState({ acknowledgedAgentsByPaneKey: { [pane('agent')]: 1_900_000 } }) + expect(selectCollapsedLineageSlot(useAppStore.getState(), source)).toBeNull() + useAppStore.setState({ + agentStatusByPaneKey: { [pane('agent')]: entry('agent') }, + agentStatusEpoch: 4 + }) + expect(selectCollapsedLineageSlot(useAppStore.getState(), source)?.kind).toBe('working') + vi.setSystemTime(20_000_000) + useAppStore.setState({ agentStatusEpoch: 5 }) + expect(selectCollapsedLineageSlot(useAppStore.getState(), source)).toBeNull() +}) diff --git a/src/renderer/src/components/sidebar/worktree-card-slot-rollup.ts b/src/renderer/src/components/sidebar/worktree-card-slot-rollup.ts index 4a31e919201..5b778bfb0c8 100644 --- a/src/renderer/src/components/sidebar/worktree-card-slot-rollup.ts +++ b/src/renderer/src/components/sidebar/worktree-card-slot-rollup.ts @@ -1,9 +1,10 @@ +import { createLineageSlotReader, type WorkspaceSlot } from './worktree-card-slot-range' +import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData' import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' import { toLineageSlotRecord, fromLineageSlotRecord, - mergeRecord, - type LineageSlotRecord + mergeRecord } from './worktree-card-slot-summary' import { useCallback, useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' @@ -20,8 +21,7 @@ import { import { isLineageRange, lineageIdentities, - type WorktreeLineageSource, - type WorktreeLineageTree + type WorktreeLineageSource } from '../../../../shared/sidebar-list/worktree-lineage-range' import { createWorktreeAgentRowsSelector, @@ -52,16 +52,19 @@ export function worktreeForIdentity( ).find((row) => getWorktreeHostIdentity(row) === identity) } -type WorkspaceSlot = { targets: SlotTarget[]; record: LineageSlotRecord; latestAt: number | null } const ownSelectors = new Map< HostScopedWorktreeKey, ReturnType >() -let ownSlotInputs: readonly unknown[] = [] const ownSlots = new Map< HostScopedWorktreeKey, - { worktree: Worktree | undefined; slot: WorkspaceSlot } + { + worktree: Worktree | undefined + slot: WorkspaceSlot + agents: DashboardAgentRow[] + unread: boolean[] + } >() function ownSlot( @@ -69,16 +72,8 @@ function ownSlot( identity: HostScopedWorktreeKey, fallback?: Worktree ): WorkspaceSlot { - const inputs = worktreeAgentProjectionInputs(state) - if (!inputs.every((value, index) => value === ownSlotInputs[index])) { - ownSlotInputs = inputs - ownSlots.clear() - } const cached = ownSlots.get(identity) const worktree = worktreeForIdentity(state, identity) ?? fallback ?? cached?.worktree - if (cached && cached.worktree === worktree) { - return cached.slot - } let selector = ownSelectors.get(identity) if (!selector) { selector = createWorktreeAgentRowsSelector( @@ -91,7 +86,17 @@ function ownSlot( } } const agents = selector(state) - const signals = buildSlotAgentSignals(agents, selectAgentRowsUnread(state, agents)) + const unread = selectAgentRowsUnread(state, agents) + if ( + cached && + cached.worktree === worktree && + cached.agents === agents && + unread.length === cached.unread.length && + unread.every((value, index) => value === cached.unread[index]) + ) { + return cached.slot + } + const signals = buildSlotAgentSignals(agents, unread) const targets = slotTargetsFromAgentLines( getWorktreeIdFromHostIdentity(identity), signals, @@ -111,41 +116,15 @@ function ownSlot( record: toLineageSlotRecord(slotExceptionsFor(targets)), latestAt: at > 0 ? at : null } - ownSlots.set(identity, { worktree, slot: result }) + ownSlots.delete(identity) + ownSlots.set(identity, { worktree, slot: result, agents, unread }) + if (ownSlots.size > 2048) { + ownSlots.delete(ownSlots.keys().next().value!) + } return result } -type TreeSlots = { - inputs: readonly unknown[] - own: WorkspaceSlot[] - descendants: LineageSlotRecord[] - latest: (number | null)[] -} -const treeSlots = new WeakMap() -function slotsForTree(state: AppState, tree: WorktreeLineageTree): TreeSlots { - const inputs = worktreeAgentProjectionInputs(state) - const cached = treeSlots.get(tree) - if (cached && inputs.every((value, index) => value === cached.inputs[index])) { - return cached - } - const own = tree.identities.map((identity, index) => - ownSlot(state, identity, tree.worktrees[index]) - ) - const descendants = own.map(() => toLineageSlotRecord([])) - const latest: (number | null)[] = own.map(() => null) - for (let index = own.length - 1; index >= 0; index--) { - const parent = tree.parents[index]! - if (parent >= 0) { - mergeRecord(descendants[parent]!, own[index]!.record) - mergeRecord(descendants[parent]!, descendants[index]!) - latest[parent] = - Math.max(latest[parent] ?? 0, own[index]!.latestAt ?? 0, latest[index] ?? 0) || null - } - } - const result = { inputs, own, descendants, latest } - treeSlots.set(tree, result) - return result -} +const { slotsForTree, descendantSlot } = createLineageSlotReader(ownSlot) export function collectLineageSlotTargets( state: AppState, @@ -167,8 +146,7 @@ export function selectCollapsedLineageSlot( source: WorktreeLineageSource ): SlotSignal | null { return isLineageRange(source) - ? (fromLineageSlotRecord(slotsForTree(state, source.tree).descendants[source.index]!)[0] ?? - null) + ? (fromLineageSlotRecord(descendantSlot(state, source.tree, source.index).record)[0] ?? null) : slotExceptionFor(collectLineageSlotTargets(state, source)) } @@ -183,7 +161,7 @@ export function useCollapsedLineageSlots(source: WorktreeLineageSource): SlotExc } inputs = next record = isLineageRange(source) - ? slotsForTree(state, source.tree).descendants[source.index]! + ? descendantSlot(state, source.tree, source.index).record : toLineageSlotRecord(slotExceptionsFor(collectLineageSlotTargets(state, source))) return record } @@ -197,7 +175,7 @@ export function selectLineageLatestAt( source: WorktreeLineageSource ): number | null { if (isLineageRange(source)) { - return slotsForTree(state, source.tree).latest[source.index]! + return descendantSlot(state, source.tree, source.index).latestAt } let at = 0 for (const identity of lineageIdentities(source)) { @@ -233,7 +211,7 @@ function childSlot( } const slots = slotsForTree(state, range.tree) const record = { ...slots.own[range.index]!.record } - mergeRecord(record, slots.descendants[range.index]!) + mergeRecord(record, descendantSlot(state, range.tree, range.index).record) return fromLineageSlotRecord(record)[0] ?? null } diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/worktree-card-disclosure-rows.test.ts b/src/renderer/src/components/sidebar/worktree-list/listing/worktree-card-disclosure-rows.test.ts index 97a6d050689..21594525544 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/worktree-card-disclosure-rows.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/worktree-card-disclosure-rows.test.ts @@ -1,3 +1,9 @@ +import type { Row } from '../../../../../../shared/sidebar-list/row-types' +import { buildWorktreeOccurrencePlan } from '../../../../../../shared/worktree-occurrence-plan' +import { appendWorktreeRows } from '../../../../../../shared/sidebar-list/row-builders' +import { folderWorkspaceToWorktree } from '../../../../../../shared/folder-workspaces/folder-workspace-worktree' +import type { FolderWorkspace } from '../../../../../../shared/folder-workspaces/folder-workspace-types' +import type { ProjectGroup } from '../../../../../../shared/project-group-types' import type { HostSectionRow } from '../../../../../../shared/sidebar-list/host-section-rows' // @vitest-environment happy-dom import { expect, it } from 'vitest' @@ -88,3 +94,88 @@ it('projects folded rows before navigation and reveals the selected host’s nes selectLineageChildCounts(state, [composeWorktreeHostIdentity('local', 'child')], parentRange) ).toEqual({ needs: 0, failed: 0, unread: 1, working: 0 }) }) + +it('folds folder-backed roots and retains hidden attention and host-qualified reveal paths', () => { + const folder: FolderWorkspace = { + id: 'folder-1', + projectGroupId: 'project', + name: 'Release', + folderPath: '/release', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + createdAt: 0, + updatedAt: 0 + } + const projectGroup: ProjectGroup = { + id: 'project', + name: 'Project', + parentPath: '/release', + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 0, + updatedAt: 0 + } + const root = folderWorkspaceToWorktree(folder) + const child = makeWorktree({ + id: 'nested', + repoId: 'repo1', + hostId: 'ssh:box', + isUnread: true, + lastActivityAt: 45 + }) + const identity = composeWorktreeHostIdentity('ssh:box', 'nested') + const plan = buildWorktreeOccurrencePlan({ + rows: [root, child], + identityOf: (row) => composeWorktreeHostIdentity(row.hostId, row.id), + childrenByParentIdentity: new Map([ + [composeWorktreeHostIdentity(root.hostId, root.id), [child]] + ]), + isPinned: () => false, + duplicatePinned: false, + sectionsOf: ({ natural }) => [{ key: 'all', items: natural }] + }) + const rows: Row[] = [] + appendWorktreeRows(rows, () => TEST_REPO, plan, { + sectionKey: 'all', + groupDepth: 0, + collapsedGroups: new Set(), + folderPairByIdentity: new Map([ + [composeWorktreeHostIdentity(root.hostId, root.id), { folderWorkspace: folder, projectGroup }] + ]) + }) + const ranges = buildWorktreeLineageRanges(plan) + const selection = { active: null, reveal: null, revealRow: undefined, sendTarget: undefined } + const folded = projectCardDisclosureRows(rows, ranges, new Map(), selection) + expect(folded.map((row) => row.type)).toEqual(['folder-workspace']) + const row = folded[0]! + if (row.type !== 'folder-workspace' || !row.lineageDescendantIdentities) { + throw new Error('Missing folder range') + } + expect(row.lineageChildIdentities).toEqual([identity]) + expect( + selectCollapsedLineageSlot( + { ...useAppStore.getState(), worktreesByRepo: { repo1: [child] } }, + row.lineageDescendantIdentities + ) + ).toEqual({ kind: 'unread', at: 45, manual: false }) + expect( + projectCardDisclosureRows(rows, ranges, new Map(), { + ...selection, + reveal: { worktreeId: 'nested', executionHostId: 'ssh:box', behavior: 'auto' } + }).map((row) => row.type) + ).toEqual(['folder-workspace', 'item']) + expect( + projectCardDisclosureRows(rows, ranges, new Map(), { + ...selection, + reveal: { worktreeId: 'nested', executionHostId: 'local', behavior: 'auto' } + }).map((row) => row.type) + ).toEqual(['folder-workspace']) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/worktree-card-disclosure-rows.ts b/src/renderer/src/components/sidebar/worktree-list/listing/worktree-card-disclosure-rows.ts index 857a303179d..b13d940a385 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/worktree-card-disclosure-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/worktree-card-disclosure-rows.ts @@ -1,3 +1,4 @@ +import { folderWorkspaceToWorktree } from '../../../../../../shared/folder-workspaces/folder-workspace-worktree' import { useEffect, useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' @@ -30,22 +31,29 @@ export function projectCardDisclosureRows( const result: HostSectionRow[] = [] let hiddenBelow: { section: string; depth: number } | undefined for (const row of rows) { - if (row.type !== 'item') { + if (row.type !== 'item' && row.type !== 'folder-workspace') { hiddenBelow = undefined result.push(row) continue } - if (hiddenBelow && row.sectionKey === hiddenBelow.section && row.depth > hiddenBelow.depth) { + const section = row.type === 'item' ? row.sectionKey : row.occurrence?.sectionKey + if (hiddenBelow && section === hiddenBelow.section && row.depth > hiddenBelow.depth) { continue } hiddenBelow = undefined - const source = ranges.get(row.rowKey) - const identity = getWorktreeHostIdentity(row.worktree) + const source = ranges.get(row.type === 'item' ? row.rowKey : (row.occurrence?.key ?? row.key)) + const identity = getWorktreeHostIdentity( + row.type === 'item' ? row.worktree : folderWorkspaceToWorktree(row.folderWorkspace) + ) const open = resolveWorktreeCardOpen(identity, source, cards.get(identity), selection) - if (!open) { - hiddenBelow = { section: row.sectionKey, depth: row.depth } + if (!open && section) { + hiddenBelow = { section, depth: row.depth } } - result.push({ ...row, lineageDescendantIdentities: source }) + result.push({ + ...row, + lineageDescendantIdentities: source, + lineageDescendantsHidden: !open || row.lineageCollapsed === true + }) } return result } diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/folder-row.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/folder-row.tsx index 832dd9b2d32..3d7f9e56ca5 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/folder-row.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/rows/folder-row.tsx @@ -171,6 +171,8 @@ export function renderFolderWorkspaceOption(args: { hostContextLabel={row.hostContextLabel} lineageChildCount={row.lineageChildCount} lineageChildWorktreeIds={row.lineageChildWorktreeIds} + lineageChildIdentities={row.lineageChildIdentities} + lineageDescendantIdentities={row.lineageDescendantIdentities} lineageCollapsed={row.lineageCollapsed} lineageChildren={lineage?.children} lineageChildrenStyle={ diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/use-visible-review-refresh.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/use-visible-review-refresh.ts index e78ccc189ef..9fd7868c3f5 100644 --- a/src/renderer/src/components/sidebar/worktree-list/viewport/use-visible-review-refresh.ts +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/use-visible-review-refresh.ts @@ -5,7 +5,10 @@ import { useAppStore } from '@/store' import type { Worktree } from '../../../../../../shared/worktree/types' import type { WorktreeGroupBy } from '../../../../../../shared/sidebar-list/row-types' import type { RenderRow } from '../listing/render-row' -import type { WorktreeItemRow } from '../listing/renderable-rows' +import { lineageIdentities } from '../../../../../../shared/sidebar-list/worktree-lineage-range' +import { getWorktreeIdFromHostIdentity } from '../../../../../../shared/worktree/host-qualified-identity' +import { folderWorkspaceToWorktree } from '../../../../../../shared/folder-workspaces/folder-workspace-worktree' +import type { WorktreeItemRow, FolderWorkspaceItemRow } from '../listing/renderable-rows' import { getMountedWorktreeOptionsById } from '../rows/option-dom' export function installWorktreeVisibleRefreshVisibilityListener(onChange: () => void): () => void { @@ -43,18 +46,34 @@ export function visibleReviewCardIds(args: { viewportHeight: number }): string[] { const bottom = args.viewportTop + args.viewportHeight - return args.virtualItems - .filter((item) => item.start < bottom && item.end > args.viewportTop) - .map((item) => args.renderRows[item.index]) - .flatMap((row): WorktreeItemRow[] => (row?.type === 'item' ? [row] : [])) - .filter( - (row) => - (row.repo?.kind ?? 'git') === 'git' && - !row.worktree.isBare && - !row.worktree.isArchived && - Boolean(row.worktree.branch) - ) - .map((row) => row.worktree.id) + const ids = new Set() + for (const item of args.virtualItems) { + if (item.start >= bottom || item.end <= args.viewportTop) { + continue + } + const row = args.renderRows[item.index] + const cards: (WorktreeItemRow | FolderWorkspaceItemRow)[] = + row?.type === 'item' || row?.type === 'folder-workspace' ? [row] : [] + for (const card of cards) { + const worktree = + card.type === 'item' ? card.worktree : folderWorkspaceToWorktree(card.folderWorkspace) + if ( + card.type === 'item' && + (card.repo?.kind ?? 'git') === 'git' && + !worktree.isBare && + !worktree.isArchived && + worktree.branch + ) { + ids.add(worktree.id) + } + if (card.lineageDescendantsHidden && card.lineageDescendantIdentities) { + for (const identity of lineageIdentities(card.lineageDescendantIdentities)) { + ids.add(getWorktreeIdFromHostIdentity(identity)) + } + } + } + } + return [...ids] } export function useVisiblePrRefreshReporting(args: { diff --git a/src/renderer/src/store/github/refresh/stale-worktree-refresh-actions.ts b/src/renderer/src/store/github/refresh/stale-worktree-refresh-actions.ts index e81e8404733..66182c4b081 100644 --- a/src/renderer/src/store/github/refresh/stale-worktree-refresh-actions.ts +++ b/src/renderer/src/store/github/refresh/stale-worktree-refresh-actions.ts @@ -1,7 +1,7 @@ import { getWorktreeCardStyle, isExperimentalWorktreeCardStyle -} from '../../../../shared/worktree/card-style' +} from '../../../../../shared/worktree/card-style' import type { StateCreator } from 'zustand' import type { AppState } from '../../types' import type { GitHubSlice } from '../github-slice-contract' diff --git a/src/renderer/src/web/preload-api/web-runtime-session.ts b/src/renderer/src/web/preload-api/web-runtime-session.ts index 011a558a687..3206f95e762 100644 --- a/src/renderer/src/web/preload-api/web-runtime-session.ts +++ b/src/renderer/src/web/preload-api/web-runtime-session.ts @@ -28,6 +28,7 @@ export const webRuntimeState: { worktreeVisibilityDefaultsRuntimeValue: WorktreeVisibilityDefaults | null /** The paired host that advertised `worktreeCardStyle`; older hosts reject the unknown key. */ worktreeCardStyleRuntimeEnvironmentId: string | null + worktreeCardDetailsRowRuntimeEnvironmentId: string | null activeClient: WebRuntimeClient | null activeClientEnvironmentId: string | null cachedDetectedWorktrees: { loadedAt: number; worktrees: Worktree[] } | null @@ -38,6 +39,7 @@ export const webRuntimeState: { worktreeVisibilityDefaultsRuntimeEnvironmentId: null, worktreeVisibilityDefaultsRuntimeValue: null, worktreeCardStyleRuntimeEnvironmentId: null, + worktreeCardDetailsRowRuntimeEnvironmentId: null, activeClient: null, activeClientEnvironmentId: null, cachedDetectedWorktrees: null diff --git a/src/renderer/src/web/preload-api/web-worktree-card-style-sync.ts b/src/renderer/src/web/preload-api/web-worktree-card-style-sync.ts index 9912edab871..0938ce25ce6 100644 --- a/src/renderer/src/web/preload-api/web-worktree-card-style-sync.ts +++ b/src/renderer/src/web/preload-api/web-worktree-card-style-sync.ts @@ -4,7 +4,7 @@ import { webRuntimeState } from './web-runtime-session' type CardStyleSettings = Pick< Partial, - 'experimentalNewWorktreeCardStyle' | 'worktreeCardStyle' + 'experimentalNewWorktreeCardStyle' | 'worktreeCardStyle' | 'worktreeCardDetailsRow' > /** Mirror the host's card style, and remember whether this host accepts the three-way field. */ @@ -14,6 +14,8 @@ export function readHostWorktreeCardStyle( environmentStillActive: boolean ): CardStyleSettings { if (environmentStillActive) { + webRuntimeState.worktreeCardDetailsRowRuntimeEnvironmentId = + typeof host.worktreeCardDetailsRow === 'boolean' ? environmentId : null webRuntimeState.worktreeCardStyleRuntimeEnvironmentId = isWorktreeCardStyle( host.worktreeCardStyle ) @@ -21,6 +23,7 @@ export function readHostWorktreeCardStyle( : null } return { + worktreeCardDetailsRow: host.worktreeCardDetailsRow === true, ...(typeof host.experimentalNewWorktreeCardStyle === 'boolean' ? { experimentalNewWorktreeCardStyle: host.experimentalNewWorktreeCardStyle } : {}), @@ -36,6 +39,10 @@ export function hostWorktreeCardStyleUpdate( environmentId: string ): CardStyleSettings { return { + ...(typeof updates.worktreeCardDetailsRow === 'boolean' && + webRuntimeState.worktreeCardDetailsRowRuntimeEnvironmentId === environmentId + ? { worktreeCardDetailsRow: updates.worktreeCardDetailsRow } + : {}), ...(typeof updates.experimentalNewWorktreeCardStyle === 'boolean' ? { experimentalNewWorktreeCardStyle: updates.experimentalNewWorktreeCardStyle } : {}), diff --git a/src/renderer/src/web/web-preload-api-settings.test.ts b/src/renderer/src/web/web-preload-api-settings.test.ts index 538340df077..5784716184e 100644 --- a/src/renderer/src/web/web-preload-api-settings.test.ts +++ b/src/renderer/src/web/web-preload-api-settings.test.ts @@ -14,6 +14,45 @@ describe('web settings preload API', () => { vi.unstubAllGlobals() }) + it('reads the paired host details row preference and writes changes back to that host', async () => { + const runtimeCalls: { method: string; params: unknown }[] = [] + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string, params?: unknown): Promise> { + runtimeCalls.push({ method, params }) + return Promise.resolve({ + id: 'settings', + ok: true, + result: { + settings: { + worktreeCardStyle: 'experimental-2', + worktreeCardDetailsRow: method === 'settings.update' + } + }, + _meta: { runtimeId: 'runtime-1' } + }) + } + close(): void {} + } + })) + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + globals.storage.setItem( + 'orca.web.settings.v1', + JSON.stringify({ worktreeCardDetailsRow: true }) + ) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + expect((await globals.window.api.settings.get()).worktreeCardDetailsRow).toBe(false) + expect( + (await globals.window.api.settings.set({ worktreeCardDetailsRow: true })) + .worktreeCardDetailsRow + ).toBe(true) + expect(runtimeCalls.find((call) => call.method === 'settings.update')?.params).toMatchObject({ + worktreeCardDetailsRow: true + }) + }) + it('arms the OSC 52 notice when ui.get is the read that runs the migration', async () => { // Seed after module load to model another tab’s storage update; first ui.get must migrate settings before snapshotting UI or it erases the one-shot notice arm. const globals = installBrowserGlobals('Linux') diff --git a/src/shared/rpc-contract/client-ui/client-settings-params.ts b/src/shared/rpc-contract/client-ui/client-settings-params.ts index ecb446f20a6..83f8107db2a 100644 --- a/src/shared/rpc-contract/client-ui/client-settings-params.ts +++ b/src/shared/rpc-contract/client-ui/client-settings-params.ts @@ -114,6 +114,7 @@ export const SettingsUpdate = z .optional(), experimentalNewWorktreeCardStyle: z.boolean().optional(), worktreeCardStyle: z.enum(['classic', 'experimental-1', 'experimental-2']).optional(), + worktreeCardDetailsRow: z.boolean().optional(), /** No UI: the host-side switch for connectors, so a headless server can be turned on. */ experimentalConnectors: z.boolean().optional(), agentStatusHooksEnabled: z.boolean().optional(), diff --git a/src/shared/sidebar-list/row-types.ts b/src/shared/sidebar-list/row-types.ts index d9a84ed29f2..b54dc6f15c3 100644 --- a/src/shared/sidebar-list/row-types.ts +++ b/src/shared/sidebar-list/row-types.ts @@ -71,6 +71,7 @@ export type WorktreeRow = { lineageCollapsed?: boolean /** Every workspace nested under this one, collapsed or not; host-qualified. */ lineageDescendantIdentities?: WorktreeLineageSource + lineageDescendantsHidden?: boolean hostContextLabel?: string showProjectName?: boolean } @@ -119,6 +120,10 @@ export type PendingCreationRow = { } export type FolderWorkspaceRow = { + occurrence?: WorktreeOccurrence + lineageChildIdentities?: readonly HostScopedWorktreeKey[] + lineageDescendantIdentities?: WorktreeLineageSource + lineageDescendantsHidden?: boolean type: 'folder-workspace' key: string folderWorkspace: FolderWorkspace diff --git a/src/shared/sidebar-list/sections/row-builders.ts b/src/shared/sidebar-list/sections/row-builders.ts index 1b236a0b18e..72363713bc7 100644 --- a/src/shared/sidebar-list/sections/row-builders.ts +++ b/src/shared/sidebar-list/sections/row-builders.ts @@ -8,6 +8,7 @@ import { getFolderWorkspaceHostIdentity } from '../../folder-workspaces/folder-w import { getWorktreeLineageGroupKey } from './group-keys' import { getExpandedWorktreeOccurrences, + type WorktreeOccurrence, type WorktreeOccurrencePlan } from '../../worktree-occurrence-plan' import type { NoticeHostContext } from '../hosts/host-labels' @@ -105,6 +106,7 @@ export function appendWorktreeRows( buildFolderWorkspaceRow(folderPair, options.groupDepth, occurrence, { collapsedGroups: options.collapsedGroups, childWorktreeIds: childIdsByParent.get(occurrence.key), + childIdentities: childIdentitiesByParent.get(occurrence.key), hostContextLabel: options.hostContextLabelByWorktreeIdentity?.get( getWorktreeHostIdentity(worktree) ) @@ -147,6 +149,7 @@ export function buildFolderWorkspaceRow( options?: { collapsedGroups: ReadonlySet childWorktreeIds?: readonly string[] + childIdentities?: readonly HostScopedWorktreeKey[] hostContextLabel?: string } ): FolderWorkspaceRow { @@ -154,6 +157,8 @@ export function buildFolderWorkspaceRow( occurrence && occurrence.childCount > 0 ? getWorktreeLineageGroupKey(occurrence.row) : undefined return { type: 'folder-workspace', + occurrence, + lineageChildIdentities: options?.childIdentities, key: `folder-workspace:${getFolderWorkspaceHostIdentity(pair.folderWorkspace)}`, folderWorkspace: pair.folderWorkspace, projectGroup: pair.projectGroup,