mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 09:57:48 +00:00
wip
This commit is contained in:
@@ -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 ? (
|
||||
|
||||
+11
-5
@@ -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
|
||||
]
|
||||
}
|
||||
|
||||
+7
-4
@@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+91
@@ -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'])
|
||||
})
|
||||
|
||||
+15
-7
@@ -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={
|
||||
|
||||
+32
-13
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user