mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
* feat(mobile): show SSH host health on the workspace row host badge
Rows on an SSH host the desktop cannot reach looked healthy on the phone. The badge now
adds the desktop sidebar's own word ("devbox · Disconnected"), derived by the same shared
function from the connection status ssh.listTargetSummaries already sends; old desktops keep today's badge.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): name an unhealthy SSH host even in a single-host list
The desktop card shows SSH status on any host count, so the phone now reads target summaries
whenever a repo is on SSH and badges an unhealthy host alone too. Labels and health share one
target-to-host keying, and a hook-level test pins the reply-to-row wiring.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): clear stale SSH host badges and announce them without the dot
A catalog that no longer spans hosts or holds an SSH repo now clears the health and label maps,
so a host that left cannot keep badging rows. The badge gets a screen-reader label ("devbox,
Disconnected") instead of reading the middle dot.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): type the single-host health fixtures inline so the test tsc passes
Fixtures hoisted into consts widened their health maps to string, which the now-required
hostHealthById rejects under the tests-typecheck ratchet; passing them inline keeps the union.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): re-record the repo-metadata goldens for SSH host health
The settled metadata state now carries hostHealthById, and an SSH-only catalog now reads the
target summaries so a lone unhealthy host can be badged. Scoped re-record via rpc-recording.mts.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
293 lines
8.7 KiB
TypeScript
293 lines
8.7 KiB
TypeScript
import {
|
|
createElement,
|
|
Fragment,
|
|
useCallback,
|
|
useState,
|
|
type Dispatch,
|
|
type SetStateAction
|
|
} from 'react'
|
|
import { Text } from 'react-native'
|
|
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
|
|
import { WorktreeAgentRow } from './WorktreeAgentRow'
|
|
import { WorktreeListRow, type WorktreeListRowItem } from './WorktreeListRow'
|
|
|
|
const { agentSpinnerRender, agentStateDotRender } = vi.hoisted(() => ({
|
|
agentSpinnerRender: vi.fn(),
|
|
agentStateDotRender: vi.fn()
|
|
}))
|
|
|
|
vi.mock('react-native', () => ({
|
|
Pressable: 'Pressable',
|
|
StyleSheet: { create: <T>(styles: T) => styles },
|
|
Text: 'Text',
|
|
View: 'View'
|
|
}))
|
|
|
|
vi.mock('lucide-react-native', () => ({
|
|
Bell: 'Bell',
|
|
ChevronDown: 'ChevronDown',
|
|
ChevronRight: 'ChevronRight',
|
|
GitBranch: 'GitBranch',
|
|
GitPullRequest: 'GitPullRequest',
|
|
Monitor: 'Monitor',
|
|
Server: 'Server'
|
|
}))
|
|
|
|
vi.mock('../platform/haptics', () => ({ triggerMediumImpact: vi.fn() }))
|
|
vi.mock('./AgentSpinner', () => ({
|
|
AgentSpinner: (props: unknown) => {
|
|
agentSpinnerRender(props)
|
|
return null
|
|
}
|
|
}))
|
|
vi.mock('./AgentStateDot', () => ({
|
|
AgentStateDot: (props: unknown) => {
|
|
agentStateDotRender(props)
|
|
return null
|
|
}
|
|
}))
|
|
vi.mock('./MobileAgentIcon', () => ({ MobileAgentIcon: () => null }))
|
|
vi.mock('./MobileRepoIcon', () => ({ MobileRepoIcon: () => null }))
|
|
vi.mock('./WorktreeAgentList', () => ({ WorktreeAgentList: () => null }))
|
|
vi.mock('./WorktreeMetaGlyphs', () => ({
|
|
prStateColor: () => '#000000',
|
|
WorktreeMetaGlyphs: () => null
|
|
}))
|
|
|
|
type TestItem = WorktreeListRowItem & {
|
|
status: 'working' | 'active' | 'permission' | 'done' | 'inactive'
|
|
lastOutputAt: number
|
|
}
|
|
|
|
const stableRepoIcon = { type: 'emoji', emoji: 'o' } as const
|
|
let updateSibling: Dispatch<SetStateAction<number>> = () => undefined
|
|
|
|
function ListRowHarness({ item, now }: { item: TestItem; now: number }) {
|
|
const [sibling, setSibling] = useState(0)
|
|
updateSibling = setSibling
|
|
const onPress = useCallback(() => undefined, [])
|
|
const onLongPress = useCallback(() => undefined, [])
|
|
const onToggleLineage = useCallback(() => undefined, [])
|
|
|
|
// Sibling state changes re-render the harness without changing the row's props,
|
|
// exercising the row's React.memo bailout.
|
|
return createElement(
|
|
Fragment,
|
|
null,
|
|
createElement(Text, null, sibling),
|
|
createElement(WorktreeListRow, {
|
|
item,
|
|
isReadOnly: false,
|
|
now,
|
|
repoColor: '#000000',
|
|
repoIcon: stableRepoIcon,
|
|
hideRepo: false,
|
|
status: item.status,
|
|
onPress,
|
|
onLongPress,
|
|
onToggleLineage
|
|
})
|
|
)
|
|
}
|
|
|
|
function agent(overrides: Partial<RuntimeWorktreeAgentRow> = {}): RuntimeWorktreeAgentRow {
|
|
return {
|
|
paneKey: 'agent-1',
|
|
parentPaneKey: null,
|
|
state: 'working',
|
|
agentType: null,
|
|
prompt: 'Fix the list',
|
|
taskTitle: null,
|
|
displayName: null,
|
|
lastAssistantMessage: null,
|
|
toolName: null,
|
|
toolInput: null,
|
|
interrupted: false,
|
|
stateStartedAt: 1_000,
|
|
updatedAt: 1_000,
|
|
...overrides
|
|
}
|
|
}
|
|
|
|
const baseItem: TestItem = {
|
|
worktreeId: 'worktree-1',
|
|
repo: 'orca',
|
|
branch: 'feature/mobile-list',
|
|
displayName: 'mobile-list',
|
|
liveTerminalCount: 1,
|
|
preview: 'Waiting',
|
|
unread: false,
|
|
linkedPR: null,
|
|
agents: [agent()],
|
|
status: 'active',
|
|
lastOutputAt: 1_000
|
|
}
|
|
|
|
describe('memoized worktree rows', () => {
|
|
let renderer: ReactTestRenderer | null = null
|
|
|
|
beforeEach(() => {
|
|
agentSpinnerRender.mockClear()
|
|
agentStateDotRender.mockClear()
|
|
})
|
|
|
|
afterEach(() => {
|
|
act(() => renderer?.unmount())
|
|
renderer = null
|
|
})
|
|
|
|
it('skips an unrelated parent render but updates for live item fields and time', async () => {
|
|
await act(async () => {
|
|
renderer = create(createElement(ListRowHarness, { item: baseItem, now: 2_000 }))
|
|
})
|
|
expect(agentSpinnerRender).toHaveBeenCalledTimes(1)
|
|
|
|
await act(async () => updateSibling((value) => value + 1))
|
|
expect(agentSpinnerRender).toHaveBeenCalledTimes(1)
|
|
|
|
let expectedRenders = 1
|
|
const liveUpdates: TestItem[] = [
|
|
{ ...baseItem, preview: 'Running tests' },
|
|
{ ...baseItem, unread: true },
|
|
{ ...baseItem, lastOutputAt: 2_000 },
|
|
{ ...baseItem, agents: [agent({ state: 'waiting', updatedAt: 2_000 })] },
|
|
{ ...baseItem, status: 'working' }
|
|
]
|
|
for (const liveUpdate of liveUpdates) {
|
|
await act(async () =>
|
|
renderer!.update(createElement(ListRowHarness, { item: liveUpdate, now: 2_000 }))
|
|
)
|
|
expect(agentSpinnerRender).toHaveBeenCalledTimes(++expectedRenders)
|
|
await act(async () =>
|
|
renderer!.update(createElement(ListRowHarness, { item: baseItem, now: 2_000 }))
|
|
)
|
|
expect(agentSpinnerRender).toHaveBeenCalledTimes(++expectedRenders)
|
|
}
|
|
|
|
await act(async () =>
|
|
renderer!.update(createElement(ListRowHarness, { item: baseItem, now: 32_000 }))
|
|
)
|
|
expect(agentSpinnerRender).toHaveBeenCalledTimes(++expectedRenders)
|
|
})
|
|
|
|
it('memoizes agent rows without hiding agent updates', async () => {
|
|
const firstAgent = agent()
|
|
await act(async () => {
|
|
renderer = create(
|
|
createElement(WorktreeAgentRow, {
|
|
agent: firstAgent,
|
|
depth: 0,
|
|
now: 2_000,
|
|
unvisited: false
|
|
})
|
|
)
|
|
})
|
|
expect(agentStateDotRender).toHaveBeenCalledTimes(1)
|
|
|
|
await act(async () => {
|
|
renderer!.update(
|
|
createElement(WorktreeAgentRow, {
|
|
agent: firstAgent,
|
|
depth: 0,
|
|
now: 2_000,
|
|
unvisited: false
|
|
})
|
|
)
|
|
})
|
|
expect(agentStateDotRender).toHaveBeenCalledTimes(1)
|
|
|
|
await act(async () => {
|
|
renderer!.update(
|
|
createElement(WorktreeAgentRow, {
|
|
agent: agent({ state: 'working', workingMode: 'monitoring', updatedAt: 2_000 }),
|
|
depth: 0,
|
|
now: 2_000,
|
|
unvisited: false
|
|
})
|
|
)
|
|
})
|
|
expect(agentStateDotRender).toHaveBeenCalledTimes(2)
|
|
expect(agentStateDotRender).toHaveBeenLastCalledWith({ state: 'monitoring' })
|
|
})
|
|
|
|
it('passes workspace monitoring mode to the status indicator', async () => {
|
|
await act(async () => {
|
|
renderer = create(
|
|
createElement(ListRowHarness, {
|
|
item: { ...baseItem, status: 'working', workingMode: 'monitoring' },
|
|
now: 2_000
|
|
})
|
|
)
|
|
})
|
|
|
|
expect(agentSpinnerRender).toHaveBeenLastCalledWith({
|
|
status: 'working',
|
|
workingMode: 'monitoring'
|
|
})
|
|
})
|
|
|
|
it('names the host with a glyph that matches the host kind', async () => {
|
|
const textNodes = (): string[] =>
|
|
renderer!.root
|
|
.findAllByType('Text' as never)
|
|
.flatMap((node) => node.props.children)
|
|
.filter((child): child is string => typeof child === 'string')
|
|
|
|
await act(async () => {
|
|
renderer = create(
|
|
createElement(ListRowHarness, {
|
|
item: { ...baseItem, hostId: 'ssh:ssh-1', hostContextLabel: 'openclaw' },
|
|
now: 2_000
|
|
})
|
|
)
|
|
})
|
|
expect(textNodes()).toContain('openclaw')
|
|
expect(textNodes().some((text) => text.includes('Disconnected'))).toBe(false)
|
|
expect(renderer!.root.findAllByType('Server' as never)).toHaveLength(1)
|
|
|
|
await act(async () =>
|
|
renderer!.update(
|
|
createElement(ListRowHarness, {
|
|
item: {
|
|
...baseItem,
|
|
hostId: 'ssh:ssh-1',
|
|
hostContextLabel: 'openclaw',
|
|
hostContextHealthLabel: 'Disconnected'
|
|
},
|
|
now: 2_000
|
|
})
|
|
)
|
|
)
|
|
// Separate nodes: the name truncates while the health word stays whole.
|
|
expect(textNodes()).toEqual(expect.arrayContaining(['openclaw', '· Disconnected']))
|
|
const healthText = renderer!.root
|
|
.findAllByType('Text' as never)
|
|
.find((node) => node.props.children === '· Disconnected')
|
|
expect([healthText?.props.style].flat()).toContainEqual({ flexShrink: 0 })
|
|
const announcedBadges = renderer!.root.findAll(
|
|
(node) => node.props.accessibilityLabel === 'openclaw, Disconnected'
|
|
)
|
|
expect(announcedBadges).toHaveLength(1)
|
|
expect(renderer!.root.findAllByType('Monitor' as never)).toHaveLength(0)
|
|
|
|
await act(async () =>
|
|
renderer!.update(
|
|
createElement(ListRowHarness, {
|
|
item: { ...baseItem, hostContextLabel: 'Local Mac' },
|
|
now: 2_000
|
|
})
|
|
)
|
|
)
|
|
expect(textNodes()).toContain('Local Mac')
|
|
expect(renderer!.root.findAllByType('Monitor' as never)).toHaveLength(1)
|
|
|
|
await act(async () =>
|
|
renderer!.update(createElement(ListRowHarness, { item: baseItem, now: 2_000 }))
|
|
)
|
|
expect(textNodes()).not.toContain('Local Mac')
|
|
expect(renderer!.root.findAllByType('Monitor' as never)).toHaveLength(0)
|
|
})
|
|
})
|