This commit is contained in:
Jinjing
2026-11-13 18:25:08 -08:00
parent 908577b963
commit 43ae84df59
26 changed files with 728 additions and 152 deletions
+8
View File
@@ -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
@@ -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'
@@ -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',
@@ -10,6 +10,7 @@ const WORKSPACE_VIEW_SETTINGS: readonly (keyof GlobalSettings)[] = [
'compactWorktreeCards',
'experimentalNewWorktreeCardStyle',
'worktreeCardStyle',
'worktreeCardDetailsRow',
'showPinnedWorktreesInGroups'
]
@@ -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']
@@ -318,7 +318,20 @@ export function WorktreeCardSlotContent({
/>
)}
<WorktreeCardPrRollup
own={card.prDisplay}
own={card.statusLaneReview}
ownRepresentsDescendant={card.statusPrDisplay != null}
onOpenOwn={(event) => {
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 ? (
@@ -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
]
}
@@ -71,6 +71,12 @@ export function getReviewStateTone(state: WorktreeCardPrDisplay['state']): strin
return 'text-muted-foreground opacity-70'
}
export function getReviewTone(
review: Pick<WorktreeCardPrDisplay, 'state' | 'status' | 'checksSummary'>
): 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))
})
}
@@ -150,7 +150,7 @@ export function WorktreeCardSurface({ card }: { card: WorktreeCardController }):
parentCardContent
)}
{lineageChildren ? (
{lineageChildren && (!slotLayout || cardOpen.open) ? (
<div
className={cn(
'space-y-1',
@@ -73,6 +73,7 @@ function iconForTab(state: AppState, tab: Tab, worktreeId: string): LeadingTabIc
case 'diff':
case 'conflict-review':
case 'check-details':
case 'git-graph':
return { kind: 'file', filePath: tab.entityId }
}
}
@@ -1,13 +1,10 @@
import React, { useMemo, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { useAppStore } from '@/store'
import type { AppState } from '@/store/types'
import { selectWorktreesByRepo } from '@/store/workspace-record-selectors'
import { getIndexedRepoMap } from '@/store/worktree-repo-index'
import { createHostScopedRepoOf } from '../../../../shared/sidebar-list/sidebar-feed-index'
import React, { createElement, useMemo } from 'react'
import { getReviewStateIcon } from '@/components/github/review-state-presentation'
import { cn } from '@/lib/utils'
import { useShallow } from 'zustand/react/shallow'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import type { AppState } from '@/store/types'
import {
isLineageRange,
lineageIdentities,
@@ -15,22 +12,58 @@ import {
type WorktreeLineageTree
} from '../../../../shared/sidebar-list/worktree-lineage-range'
import type { HostedReviewState } from '../../../../shared/source-control/hosted-review/hosted-review-contracts'
import type { Worktree } from '../../../../shared/worktree/types'
import { getWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity'
import { getProviderChecksOutcome } from '../../../../shared/source-control/checks/provider-check-summary'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { openWorkspaceBrowserTab } from '@/lib/browser/workspace/workspace-browser-tab-open'
import { activateAndRevealWorkspace } from '@/lib/workspace/activation/worktree-activation'
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import { selectWorktreeCardPrDisplay } from './workspace-card/review/worktree-card-pr-derivation'
import type { WorktreeCardPrDisplay } from './workspace-card/review/worktree-card-pr-display'
import { getReviewStateTone } from './workspace-card/review/worktree-review-presentation'
import { PullRequestIcon } from './workspace-card/identity/worktree-card-glyphs'
import {
getReviewLabel,
getReviewTone,
ReviewIcon
} from './workspace-card/review/worktree-review-presentation'
import { worktreeForIdentity } from './worktree-card-slot-rollup'
/** Most actionable first; every state with a PR gets its own icon so mixed states are never merged. */
const STATE_ORDER: readonly HostedReviewState[] = ['open', 'draft', 'merged', 'closed']
type ReviewTarget = { worktree: Worktree; review: WorktreeCardPrDisplay }
type PrSummary = { count: number; review: WorktreeCardPrDisplay | null }
type PrStates = Record<HostedReviewState, PrSummary>
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<HostedReviewState, number>
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<AppState['repos'], ReturnType<typeof createHostScopedRepoOf>>()
function ownPrCounts(
state: AppState,
worktree: NonNullable<ReturnType<typeof worktreeForIdentity>>
): 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<ReviewTarget[]>([])
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 (
<span className="inline-flex shrink-0 items-center gap-1" data-worktree-card-prs="">
const icons = (
<>
{shown.map((state) => (
<span
key={state}
// The tone sits on the wrapper so the corner count can fill with the same color.
className={cn('worktree-card-pr-state', getReviewStateTone(state))}
title={stateLabel(state, counts[state])}
className={cn('worktree-card-pr-state', getReviewTone(states[state].review!))}
title={stateLabel(state, states[state].count)}
data-pr-state={state}
>
{createElement(getReviewStateIcon(state) ?? PullRequestIcon, {
className: 'size-3 shrink-0'
})}
{counts[state] > 1 ? (
<ReviewIcon review={states[state].review!} className="size-3 shrink-0" />
{states[state].count > 1 ? (
<span className="worktree-card-pr-count" aria-hidden>
{counts[state]}
{states[state].count}
</span>
) : null}
</span>
))}
</span>
{showOwn && own && !own.state ? (
<ReviewIcon review={own} className="size-3 shrink-0" />
) : 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 (
<button
type="button"
className={buttonClass}
data-worktree-card-prs=""
aria-label={`${getReviewLabel(own!)} #${own!.number}: ${own!.title}`}
onPointerDown={(event) => event.stopPropagation()}
onClick={onOpenOwn}
>
{icons}
</button>
)
}
return (
<DropdownMenu
onOpenChange={(open) => {
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)
}
}}
>
<DropdownMenuTrigger asChild>
<button
type="button"
className={buttonClass}
data-worktree-card-prs=""
aria-label={translate('workspace.links.openItem', 'Open {{label}}', {
label: translate('workspace.links.reviews', 'Reviews')
})}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
{icons}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent onClick={(event) => event.stopPropagation()} align="end">
{showOwn && own ? (
<DropdownMenuItem onClick={onOpenOwn}>
<ReviewIcon review={own} className="size-3" />
{getReviewLabel(own)} #{own.number}: {own.title}
</DropdownMenuItem>
) : null}
{reviews.map(({ worktree, review }) => (
<DropdownMenuItem
key={`${getWorktreeHostIdentity(worktree)}:${review.provider}:${review.number}`}
onSelect={() => {
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))
)
}
}}
>
<ReviewIcon review={review} className="size-3" />
<span className="max-w-72 truncate">
{worktree.displayName} · {getReviewLabel(review)} #{review.number}: {review.title}
</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -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<SlotSnapshot, WeakMap<SlotSnapshot, Set<string> | null>>()
export function changedSlotWorkspaces(
state: AppState,
before: SlotSnapshot,
after: SlotSnapshot
): Set<string> | null {
const cached = changes.get(before)?.get(after)
if (cached !== undefined) {
return cached
}
let result: Set<string> | null = null
if (after.structure.every((value, index) => value === before.structure[index])) {
result = new Set<string>()
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
}
@@ -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<string, number[]>
}
const treeSlots = new WeakMap<WorktreeLineageTree, TreeSlots>()
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<string, number[]>()
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 }
}
@@ -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()
})
@@ -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<typeof createWorktreeAgentRowsSelector>
>()
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<WorktreeLineageTree, TreeSlots>()
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
}
@@ -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'])
})
@@ -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
}
@@ -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={
@@ -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<string>()
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: {
@@ -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'
@@ -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
@@ -4,7 +4,7 @@ import { webRuntimeState } from './web-runtime-session'
type CardStyleSettings = Pick<
Partial<GlobalSettings>,
'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 }
: {}),
@@ -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<RuntimeRpcResponse<unknown>> {
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')
@@ -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(),
+5
View File
@@ -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<Worktree>
lineageChildIdentities?: readonly HostScopedWorktreeKey[]
lineageDescendantIdentities?: WorktreeLineageSource
lineageDescendantsHidden?: boolean
type: 'folder-workspace'
key: string
folderWorkspace: FolderWorkspace
@@ -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<string>
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,