mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 01:47:24 +00:00
perf(mobile): trim Home and host list re-renders (#39076)
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { ChevronRight } from 'lucide-react-native'
|
||||
import { useState } from 'react'
|
||||
import { memo, useMemo, useState } from 'react'
|
||||
import { Pressable, Text, View } from 'react-native'
|
||||
import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'
|
||||
import type { WorktreeStatus } from '../../../src/shared/worktree-status-rollup'
|
||||
@@ -9,6 +9,7 @@ import { useReducedMotionEnabled } from '../platform/use-reduced-motion'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
|
||||
import { useHostDisplay } from '../transport/use-host-display'
|
||||
import { areStructurallyEqual } from '../worktree/structural-equality'
|
||||
import type { HostCatalogEntry } from '../transport/types'
|
||||
import type { HomeJumpBackInRow } from '../worktree/home-jump-back-in'
|
||||
import { agentStateLabel, type AgentDotState } from '../worktree/agent-row-display'
|
||||
@@ -30,7 +31,8 @@ const TINTED_STATUSES: ReadonlySet<WorktreeStatus> = new Set(['permission', 'fai
|
||||
// Enough to see what needs you without the list pushing Desktops off the first screen.
|
||||
const AGENT_ROWS_COLLAPSED = 5
|
||||
|
||||
export function MobileHomeJumpBackIn(props: {
|
||||
// Usage, stats and connection ticks re-render Home; the section redraws only when its rows change.
|
||||
export const MobileHomeJumpBackIn = memo(function MobileHomeJumpBackIn(props: {
|
||||
rows: readonly HomeJumpBackInRow[]
|
||||
hosts: readonly HostCatalogEntry[]
|
||||
first: boolean
|
||||
@@ -40,6 +42,11 @@ export function MobileHomeJumpBackIn(props: {
|
||||
useMobileColorScheme()
|
||||
const reducedMotion = useReducedMotionEnabled() === true
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
// Rows glide to their new place when an agent's state reorders the list.
|
||||
const layout = useMemo(
|
||||
() => (reducedMotion ? undefined : LinearTransition.duration(220).easing(HOME_EASE_OUT)),
|
||||
[reducedMotion]
|
||||
)
|
||||
if (props.rows.length === 0) {
|
||||
return null
|
||||
}
|
||||
@@ -48,8 +55,6 @@ export function MobileHomeJumpBackIn(props: {
|
||||
const collapsible = props.rows.length > AGENT_ROWS_COLLAPSED
|
||||
const shown = expanded || !collapsible ? props.rows : props.rows.slice(0, AGENT_ROWS_COLLAPSED)
|
||||
const activeCount = props.rows.filter((row) => !row.lastOpened).length
|
||||
// Rows glide to their new place when an agent's state reorders the list.
|
||||
const layout = reducedMotion ? undefined : LinearTransition.duration(220).easing(HOME_EASE_OUT)
|
||||
const entering = reducedMotion ? undefined : FadeIn.duration(180)
|
||||
return (
|
||||
<>
|
||||
@@ -99,9 +104,10 @@ export function MobileHomeJumpBackIn(props: {
|
||||
</Animated.View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
function JumpBackInRow(props: {
|
||||
// Each refresh rebuilds every row object; compare by value so only changed rows redraw.
|
||||
const JumpBackInRow = memo(function JumpBackInRow(props: {
|
||||
row: HomeJumpBackInRow
|
||||
host: HostCatalogEntry | null
|
||||
nameDesktop: boolean
|
||||
@@ -162,4 +168,4 @@ function JumpBackInRow(props: {
|
||||
</View>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
}, areStructurallyEqual)
|
||||
|
||||
@@ -83,10 +83,12 @@ export function MobileHomeScreen() {
|
||||
}, [selectTab, tab])
|
||||
)
|
||||
|
||||
// Stable handlers keep the memoized Feed sections and desktop rows from redrawing on every Home update.
|
||||
const { router, setHostCatalog } = data
|
||||
const openWorktree = useCallback(
|
||||
(row: HomeJumpBackInRow) => {
|
||||
if (isResumeTargetConfirmedMissing(row, getProvenCachedWorktrees(row.hostId))) {
|
||||
data.router.push(hostRouteWithNotice(row.hostId, 'worktree-missing'))
|
||||
router.push(hostRouteWithNotice(row.hostId, 'worktree-missing'))
|
||||
return
|
||||
}
|
||||
openMobileSession({
|
||||
@@ -96,33 +98,39 @@ export function MobileHomeScreen() {
|
||||
paneKey: row.worktree.paneKey
|
||||
})
|
||||
},
|
||||
[data.router, openMobileSession]
|
||||
[router, openMobileSession]
|
||||
)
|
||||
|
||||
function openHost(host: HostCatalogEntry): void {
|
||||
if (host.credentialStatus === 'missing') {
|
||||
data.router.push('/pair-scan')
|
||||
} else if (host.credentialStatus === 'temporarily-unavailable') {
|
||||
void loadHostCatalog()
|
||||
.then(data.setHostCatalog)
|
||||
.catch(() =>
|
||||
Alert.alert(
|
||||
t('home.screen.pairingCheckFailed', 'Could not check pairing'),
|
||||
t('home.screen.tryAgain', 'Please try again.')
|
||||
const openHost = useCallback(
|
||||
(host: HostCatalogEntry) => {
|
||||
if (host.credentialStatus === 'missing') {
|
||||
router.push('/pair-scan')
|
||||
} else if (host.credentialStatus === 'temporarily-unavailable') {
|
||||
void loadHostCatalog()
|
||||
.then(setHostCatalog)
|
||||
.catch(() =>
|
||||
Alert.alert(
|
||||
t('home.screen.pairingCheckFailed', 'Could not check pairing'),
|
||||
t('home.screen.tryAgain', 'Please try again.')
|
||||
)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
data.router.push(`/h/${host.id}`)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
router.push(`/h/${host.id}`)
|
||||
}
|
||||
},
|
||||
[router, setHostCatalog, t]
|
||||
)
|
||||
|
||||
function openHostActions(host: HostCatalogEntry): void {
|
||||
const openHostActions = useCallback((host: HostCatalogEntry) => {
|
||||
triggerMediumImpact()
|
||||
if (host.profile) {
|
||||
setActionTarget(host.profile)
|
||||
} else {
|
||||
setConfirmRemove(host)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const openTasks = useCallback((hostId: string) => openMobileTasks(hostId), [openMobileTasks])
|
||||
|
||||
async function handleRemove(): Promise<void> {
|
||||
if (!confirmRemove) {
|
||||
@@ -142,7 +150,7 @@ export function MobileHomeScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
const pairDesktop = () => data.router.push('/pair-scan')
|
||||
const pairDesktop = useCallback(() => router.push('/pair-scan'), [router])
|
||||
const actionTargetPath = actionTarget ? data.hostConnections[actionTarget.id]?.path : undefined
|
||||
const actionTargetConnected =
|
||||
actionTarget != null && data.hostStates[actionTarget.id] === 'connected'
|
||||
@@ -188,12 +196,9 @@ export function MobileHomeScreen() {
|
||||
bottomInset={contentBottom}
|
||||
onOpenWorktree={openWorktree}
|
||||
onOpenHost={openHost}
|
||||
onOpenHostActions={(host) => {
|
||||
triggerMediumImpact()
|
||||
openHostActions(host)
|
||||
}}
|
||||
onOpenHostActions={openHostActions}
|
||||
onPairDesktop={pairDesktop}
|
||||
onOpenTasks={(hostId) => openMobileTasks(hostId)}
|
||||
onOpenTasks={openTasks}
|
||||
/>
|
||||
) : id === 'usage' ? (
|
||||
<MobileHomeUsageTab
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, type ReactElement } from 'react'
|
||||
import { memo, useCallback, useEffect, useMemo, type ReactElement } from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
@@ -26,10 +26,11 @@ import {
|
||||
HostWorkspaceListStates
|
||||
} from '../worktree/host-workspace-list-states'
|
||||
import { getWorktreeDotStatus } from '../worktree/workspace-list-ordering'
|
||||
import type { Section, WorkspaceListItem } from '../worktree/workspace-list-types'
|
||||
import { DEFAULT_REPO_BADGE_COLOR } from '../../../src/shared/repo-badge-color'
|
||||
import { getWorktreeCardDetails } from '../../../src/shared/worktree/card-properties'
|
||||
import { hostScreenStyles as styles } from './host-screen-styles'
|
||||
import { buildRepoLookById } from './host-repo-looks'
|
||||
import { buildRepoLookById, type RepoLook } from './host-repo-looks'
|
||||
import { HostPendingCreationRow } from './host-pending-creation-row'
|
||||
import { useSlowPendingKeys } from './use-slow-pending-keys'
|
||||
import { projectGroupHeaderIndent } from '../worktree/project-group-indent'
|
||||
@@ -93,6 +94,74 @@ export function HostWorkspaceList({
|
||||
() => getWorktreeCardDetails(worktreeCardProperties, { compactWorktreeCards, newCardStyle }),
|
||||
[worktreeCardProperties, compactWorktreeCards, newCardStyle]
|
||||
)
|
||||
const sectionCounts = useMemo(
|
||||
() => new Map(rawSections.map((section) => [section.key, section.data.length])),
|
||||
[rawSections]
|
||||
)
|
||||
const { collapsedGroups } = controller
|
||||
const { groupBy, agentActivityDisplayMode } = state.view
|
||||
const { toggleCollapsed, toggleWorktreeLineage } = settings
|
||||
const { setActionTarget } = state
|
||||
const { openWorktreeSession } = actions
|
||||
const statusLive = connState === 'connected'
|
||||
|
||||
const renderSectionHeader = useCallback(
|
||||
({ section }: { section: Section }) => {
|
||||
const collapseKey = section.collapseKey ?? section.key
|
||||
return (
|
||||
<HostSectionHeader
|
||||
title={section.title}
|
||||
icon={section.icon}
|
||||
isProjectGroup={section.isProjectGroup}
|
||||
projectGroupDepth={section.projectGroupDepth}
|
||||
pendingCreations={section.pendingCreations}
|
||||
collapseKey={collapseKey}
|
||||
collapsed={collapsedGroups.has(collapseKey)}
|
||||
count={section.count ?? sectionCounts.get(section.key) ?? 0}
|
||||
pending={pendingCollapseKeys.has(collapseKey)}
|
||||
groupByRepo={groupBy === 'repo'}
|
||||
repoLook={groupBy === 'repo' && section.repo ? repoLookById.get(section.repo.id) : null}
|
||||
onToggle={toggleCollapsed}
|
||||
/>
|
||||
)
|
||||
},
|
||||
[collapsedGroups, sectionCounts, pendingCollapseKeys, groupBy, repoLookById, toggleCollapsed]
|
||||
)
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item: { row: item, view } }: { item: WorkspaceListItem }) => (
|
||||
<WorktreeListRow
|
||||
item={item}
|
||||
view={view}
|
||||
isReadOnly={isReadOnly}
|
||||
// Only inline agents show times, so other rows skip the 30 s clock.
|
||||
now={cardDetails.inlineAgents && item.agents?.length ? now : 0}
|
||||
status={getWorktreeDotStatus(item)}
|
||||
repoColor={repoLookById.get(item.repoId)?.color ?? DEFAULT_REPO_BADGE_COLOR}
|
||||
repoIcon={repoLookById.get(item.repoId)?.icon ?? null}
|
||||
hideRepo={groupBy === 'repo' && !view.showProjectName}
|
||||
cardDetails={cardDetails}
|
||||
agentActivityDisplayMode={agentActivityDisplayMode}
|
||||
onPress={openWorktreeSession}
|
||||
onAgentPress={openWorktreeSession}
|
||||
statusLive={statusLive && !view.hostContextHealthLabel}
|
||||
onLongPress={item.workspaceKind === 'folder-workspace' ? undefined : setActionTarget}
|
||||
onToggleLineage={toggleWorktreeLineage}
|
||||
/>
|
||||
),
|
||||
[
|
||||
isReadOnly,
|
||||
cardDetails,
|
||||
now,
|
||||
repoLookById,
|
||||
groupBy,
|
||||
agentActivityDisplayMode,
|
||||
openWorktreeSession,
|
||||
statusLive,
|
||||
setActionTarget,
|
||||
toggleWorktreeLineage
|
||||
]
|
||||
)
|
||||
|
||||
// Group headers can exist before any row loads; the state then sits under them, not above the title.
|
||||
const listState =
|
||||
@@ -199,72 +268,7 @@ export function HostWorkspaceList({
|
||||
isWideLayout &&
|
||||
!embedded && { maxWidth: contentMaxWidth, width: '100%', alignSelf: 'center' }
|
||||
]}
|
||||
renderSectionHeader={({ section }) => {
|
||||
const pendingCreations = section.pendingCreations?.map((creation) => (
|
||||
<HostPendingCreationRow key={creation.key} creation={creation} />
|
||||
))
|
||||
if (!section.title) {
|
||||
return pendingCreations ? <>{pendingCreations}</> : null
|
||||
}
|
||||
const collapseKey = section.collapseKey ?? section.key
|
||||
const isCollapsed = controller.collapsedGroups.has(collapseKey)
|
||||
const count =
|
||||
section.count ?? rawSections.find((s) => s.key === section.key)?.data.length ?? 0
|
||||
const repoSectionLook =
|
||||
state.view.groupBy === 'repo' && section.repo
|
||||
? repoLookById.get(section.repo.id)
|
||||
: null
|
||||
const repoSectionColor = repoSectionLook?.color ?? null
|
||||
const repoSectionIcon = repoSectionLook?.icon ?? null
|
||||
return (
|
||||
<>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.sectionHeader,
|
||||
section.projectGroupDepth
|
||||
? {
|
||||
paddingLeft:
|
||||
WORKTREE_ROW_LAYOUT.indicatorInset +
|
||||
projectGroupHeaderIndent(section.projectGroupDepth)
|
||||
}
|
||||
: null,
|
||||
pressed && { backgroundColor: colors.translucentFill }
|
||||
]}
|
||||
onPress={() => settings.toggleCollapsed(collapseKey)}
|
||||
>
|
||||
<View style={styles.sectionChevron}>
|
||||
<DisclosureChevron expanded={!isCollapsed} collapsedDeg={0} expandedDeg={90}>
|
||||
<ChevronRight size={12} color={colors.textMuted} />
|
||||
</DisclosureChevron>
|
||||
</View>
|
||||
{section.icon === 'pin' && (
|
||||
<Pin size={12} color={colors.textMuted} style={styles.sectionIcon} />
|
||||
)}
|
||||
{state.view.groupBy === 'repo' && section.isProjectGroup ? (
|
||||
<FolderTree size={12} color={colors.textPrimary} style={styles.sectionIcon} />
|
||||
) : state.view.groupBy === 'repo' ? (
|
||||
<View style={styles.sectionRepoIcon}>
|
||||
<MobileRepoIcon
|
||||
repoIcon={repoSectionIcon}
|
||||
size={14}
|
||||
color={repoSectionColor ?? colors.textSecondary}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
<Text style={styles.sectionTitle}>{section.title}</Text>
|
||||
<Text style={styles.sectionCount}>{count}</Text>
|
||||
{pendingCollapseKeys.has(collapseKey) && (
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={colors.textMuted}
|
||||
style={styles.sectionPendingSpinner}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
{pendingCreations}
|
||||
</>
|
||||
)
|
||||
}}
|
||||
renderSectionHeader={renderSectionHeader}
|
||||
ItemSeparatorComponent={ListSeparator}
|
||||
// Why (#8498): manual pull-to-refresh forces a fresh snapshot after a stale-cache reconnect.
|
||||
refreshControl={
|
||||
@@ -275,33 +279,85 @@ export function HostWorkspaceList({
|
||||
colors={[colors.textSecondary]}
|
||||
/>
|
||||
}
|
||||
renderItem={({ item: { row: item, view } }) => (
|
||||
<WorktreeListRow
|
||||
item={item}
|
||||
view={view}
|
||||
isReadOnly={isReadOnly}
|
||||
now={now}
|
||||
status={getWorktreeDotStatus(item)}
|
||||
repoColor={repoLookById.get(item.repoId)?.color ?? DEFAULT_REPO_BADGE_COLOR}
|
||||
repoIcon={repoLookById.get(item.repoId)?.icon ?? null}
|
||||
hideRepo={state.view.groupBy === 'repo' && !view.showProjectName}
|
||||
cardDetails={cardDetails}
|
||||
agentActivityDisplayMode={state.view.agentActivityDisplayMode}
|
||||
onPress={actions.openWorktreeSession}
|
||||
onAgentPress={actions.openWorktreeSession}
|
||||
statusLive={connState === 'connected' && !view.hostContextHealthLabel}
|
||||
onLongPress={
|
||||
item.workspaceKind === 'folder-workspace' ? undefined : state.setActionTarget
|
||||
}
|
||||
onToggleLineage={settings.toggleWorktreeLineage}
|
||||
/>
|
||||
)}
|
||||
renderItem={renderItem}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Takes the header's own fields, not the section, so a poll that rebuilds sections skips unchanged headers.
|
||||
const HostSectionHeader = memo(function HostSectionHeader(props: {
|
||||
title: string
|
||||
icon: Section['icon']
|
||||
isProjectGroup: boolean | undefined
|
||||
projectGroupDepth: number | undefined
|
||||
pendingCreations: Section['pendingCreations']
|
||||
collapseKey: string
|
||||
collapsed: boolean
|
||||
count: number
|
||||
pending: boolean
|
||||
groupByRepo: boolean
|
||||
repoLook: RepoLook | null | undefined
|
||||
onToggle: (collapseKey: string) => void
|
||||
}) {
|
||||
useMobileColorScheme()
|
||||
const pendingCreations = props.pendingCreations?.map((creation) => (
|
||||
<HostPendingCreationRow key={creation.key} creation={creation} />
|
||||
))
|
||||
if (!props.title) {
|
||||
return pendingCreations ? <>{pendingCreations}</> : null
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.sectionHeader,
|
||||
props.projectGroupDepth
|
||||
? {
|
||||
paddingLeft:
|
||||
WORKTREE_ROW_LAYOUT.indicatorInset +
|
||||
projectGroupHeaderIndent(props.projectGroupDepth)
|
||||
}
|
||||
: null,
|
||||
pressed && { backgroundColor: colors.translucentFill }
|
||||
]}
|
||||
onPress={() => props.onToggle(props.collapseKey)}
|
||||
>
|
||||
<View style={styles.sectionChevron}>
|
||||
<DisclosureChevron expanded={!props.collapsed} collapsedDeg={0} expandedDeg={90}>
|
||||
<ChevronRight size={12} color={colors.textMuted} />
|
||||
</DisclosureChevron>
|
||||
</View>
|
||||
{props.icon === 'pin' && (
|
||||
<Pin size={12} color={colors.textMuted} style={styles.sectionIcon} />
|
||||
)}
|
||||
{props.groupByRepo && props.isProjectGroup ? (
|
||||
<FolderTree size={12} color={colors.textPrimary} style={styles.sectionIcon} />
|
||||
) : props.groupByRepo ? (
|
||||
<View style={styles.sectionRepoIcon}>
|
||||
<MobileRepoIcon
|
||||
repoIcon={props.repoLook?.icon ?? null}
|
||||
size={14}
|
||||
color={props.repoLook?.color ?? colors.textSecondary}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
<Text style={styles.sectionTitle}>{props.title}</Text>
|
||||
<Text style={styles.sectionCount}>{props.count}</Text>
|
||||
{props.pending && (
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={colors.textMuted}
|
||||
style={styles.sectionPendingSpinner}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
{pendingCreations}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
function ListSeparator() {
|
||||
useMobileColorScheme()
|
||||
return <View style={styles.separator} />
|
||||
|
||||
Reference in New Issue
Block a user