mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(mobile): use desktop's sort, colour, count, agent-row and delete rules in the workspace list (#33995)
* fix(mobile): use desktop's sort, colour, count and agent-row rules in the workspace list Recent ignores terminal output, repo colours fall back to desktop's neutral, Home counts skip archived rows, folder workspaces trail each lane, and a stale agent row on a live terminal reads "No update in …" like desktop. * fix(mobile): keep Delete disabled on a primary checkout, as desktop does * fix(mobile): keep the task text on a silent agent row * fix(mobile): show a silent agent row's time since its last update * test(mobile): drop agent-row tests that replay the shared verdict * refactor(mobile): check host PTY liveness once and keep the delete guard on the row flag * test(mobile): re-record the home catalog goldens for the list's active count
This commit is contained in:
@@ -4,12 +4,12 @@
|
||||
"family": "worktree.home-catalog",
|
||||
"namedDeltas": [],
|
||||
"values": {
|
||||
"0809e2178d5a": {
|
||||
"07d517ec3da6": {
|
||||
"name": "info",
|
||||
"ordinal": 3,
|
||||
"value": {
|
||||
"host-1": {
|
||||
"activeCount": 1,
|
||||
"activeCount": 2,
|
||||
"countsProvenAt": 1767225600000,
|
||||
"hostId": "host-1",
|
||||
"lastActiveWorktree": {
|
||||
@@ -141,20 +141,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"39b7354b00f4": {
|
||||
"host-1": {
|
||||
"activeCount": 1,
|
||||
"countsProvenAt": 1767225600000,
|
||||
"hostId": "host-1",
|
||||
"lastActiveWorktree": {
|
||||
"displayName": "One",
|
||||
"repo": "Repo",
|
||||
"status": "working",
|
||||
"worktreeId": "w-1"
|
||||
},
|
||||
"totalWorktrees": 2
|
||||
}
|
||||
},
|
||||
"3b32328e5154": {
|
||||
"name": "worktree.ps#1",
|
||||
"ordinal": 1,
|
||||
@@ -272,6 +258,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"6624fb97ea31": {
|
||||
"host-1": {
|
||||
"activeCount": 2,
|
||||
"countsProvenAt": 1767225600000,
|
||||
"hostId": "host-1",
|
||||
"lastActiveWorktree": {
|
||||
"displayName": "One",
|
||||
"repo": "Repo",
|
||||
"status": "working",
|
||||
"worktreeId": "w-1"
|
||||
},
|
||||
"totalWorktrees": 2
|
||||
}
|
||||
},
|
||||
"9270aeb7d9c6": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
@@ -543,8 +543,8 @@
|
||||
"settlements": {
|
||||
"load": "eb79a9b3682a"
|
||||
},
|
||||
"state": "39b7354b00f4",
|
||||
"effects": ["0809e2178d5a"]
|
||||
"state": "6624fb97ea31",
|
||||
"effects": ["07d517ec3da6"]
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
"family": "worktree.home-catalog",
|
||||
"namedDeltas": [],
|
||||
"values": {
|
||||
"0809e2178d5a": {
|
||||
"07d517ec3da6": {
|
||||
"name": "info",
|
||||
"ordinal": 3,
|
||||
"value": {
|
||||
"host-1": {
|
||||
"activeCount": 1,
|
||||
"activeCount": 2,
|
||||
"countsProvenAt": 1767225600000,
|
||||
"hostId": "host-1",
|
||||
"lastActiveWorktree": {
|
||||
@@ -22,20 +22,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"39b7354b00f4": {
|
||||
"host-1": {
|
||||
"activeCount": 1,
|
||||
"countsProvenAt": 1767225600000,
|
||||
"hostId": "host-1",
|
||||
"lastActiveWorktree": {
|
||||
"displayName": "One",
|
||||
"repo": "Repo",
|
||||
"status": "working",
|
||||
"worktreeId": "w-1"
|
||||
},
|
||||
"totalWorktrees": 2
|
||||
}
|
||||
},
|
||||
"3b32328e5154": {
|
||||
"name": "worktree.ps#1",
|
||||
"ordinal": 1,
|
||||
@@ -110,6 +96,20 @@
|
||||
"startedAt": 0
|
||||
}
|
||||
},
|
||||
"6624fb97ea31": {
|
||||
"host-1": {
|
||||
"activeCount": 2,
|
||||
"countsProvenAt": 1767225600000,
|
||||
"hostId": "host-1",
|
||||
"lastActiveWorktree": {
|
||||
"displayName": "One",
|
||||
"repo": "Repo",
|
||||
"status": "working",
|
||||
"worktreeId": "w-1"
|
||||
},
|
||||
"totalWorktrees": 2
|
||||
}
|
||||
},
|
||||
"9270aeb7d9c6": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
@@ -151,8 +151,8 @@
|
||||
"settlements": {
|
||||
"load": "eb79a9b3682a"
|
||||
},
|
||||
"state": "39b7354b00f4",
|
||||
"effects": ["0809e2178d5a"]
|
||||
"state": "6624fb97ea31",
|
||||
"effects": ["07d517ec3da6"]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -6,7 +6,10 @@ import { useWorkingRingRotation } from './use-working-ring-rotation'
|
||||
|
||||
// Match desktop per-agent vocabulary: working yellow spinner, done emerald, attention red, interrupted muted, unconfirmed amber, idle neutral.
|
||||
// Separate from AgentSpinner’s five-state worktree rollup.
|
||||
const DOT_COLORS: Record<Exclude<AgentDotState, 'working' | 'monitoring'>, string> = {
|
||||
const DOT_COLORS: Record<
|
||||
Exclude<AgentDotState, 'working' | 'monitoring' | 'unverifiable'>,
|
||||
string
|
||||
> = {
|
||||
done: '#10b981',
|
||||
blocked: '#ef4444',
|
||||
waiting: '#ef4444',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { agentNoUpdateLabel } from '../../../src/shared/agent-row-decay-state'
|
||||
import { memo } from 'react'
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
|
||||
@@ -48,7 +49,13 @@ function WorktreeAgentRowComponent({
|
||||
)
|
||||
const childText = childRow ? mobileAgentChildRowText(childRow, now) : null
|
||||
const timeAt = childRow ? childRow.firstObservedAt : agentRowTimeAt(agent)
|
||||
const ts = childRow && timeAt <= 0 ? '' : formatTimeAgo(timeAt, now)
|
||||
// Like desktop, a silent row reports how long since its last update, not when its turn began.
|
||||
const ts =
|
||||
childRow && timeAt <= 0
|
||||
? ''
|
||||
: !childRow && dotState === 'unverifiable'
|
||||
? agentNoUpdateLabel(agent, now)
|
||||
: formatTimeAgo(timeAt, now)
|
||||
|
||||
const content = (
|
||||
<View style={[styles.row, { paddingLeft: depth * INDENT_PER_DEPTH }]}>
|
||||
|
||||
@@ -301,6 +301,24 @@ describe('memoized worktree rows', () => {
|
||||
expect(agentStateDotRender).toHaveBeenLastCalledWith({ state: 'monitoring' })
|
||||
})
|
||||
|
||||
it('shows how long a silent agent row has gone without an update, keeping its task', async () => {
|
||||
const minute = 60_000
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(WorktreeAgentRow, {
|
||||
agent: agent({ stateStartedAt: 0, updatedAt: 200 * minute, hasLivePty: true }),
|
||||
depth: 0,
|
||||
now: 240 * minute,
|
||||
unvisited: false
|
||||
})
|
||||
)
|
||||
})
|
||||
const texts = renderer!.root
|
||||
.findAll((node) => String(node.type) === 'Text')
|
||||
.map((node) => node.props.children)
|
||||
expect(texts).toEqual(expect.arrayContaining(['Fix the list', 'No update in 40m']))
|
||||
})
|
||||
|
||||
it('passes workspace monitoring mode to the status indicator', async () => {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ExecutionHostId } from '../../../src/shared/execution-host'
|
||||
import type { Repo as SharedRepo } from '../../../src/shared/repo-types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { SetupHookTrust } from '../tasks/setup-hook-trust'
|
||||
import { repoColor } from '../worktree/repo-color'
|
||||
import { resolveRepoBadgeColor } from '../../../src/shared/repo-badge-color'
|
||||
|
||||
export type MobileWorkspaceRepo = Pick<SharedRepo, 'id' | 'displayName' | 'path'> &
|
||||
Partial<
|
||||
@@ -52,5 +52,5 @@ export type SetupHookDetails = {
|
||||
}
|
||||
|
||||
export function getMobileWorkspaceRepoBadgeColor(repo: MobileWorkspaceRepo | null): string {
|
||||
return repo?.badgeColor || repoColor(repo?.displayName ?? 'repository')
|
||||
return resolveRepoBadgeColor(repo?.badgeColor)
|
||||
}
|
||||
|
||||
@@ -80,6 +80,17 @@ describe('the workspace long-press sheet', () => {
|
||||
expect(await actionLabels({})).toContain('Sleep')
|
||||
})
|
||||
|
||||
it("keeps Delete disabled on a primary checkout with the desktop's reason", async () => {
|
||||
const { sheet } = await renderSheet({ isMainWorktree: true })
|
||||
const remove = sheet.props.actions.find(
|
||||
(action: { label: string }) => action.label === 'Delete'
|
||||
)
|
||||
expect(remove).toMatchObject({
|
||||
disabled: true,
|
||||
hint: "Primary worktree — can't be deleted. Remove the project instead."
|
||||
})
|
||||
})
|
||||
|
||||
it("does not offer Sleep for a server's workspace, which the desktop would sleep by its own id", async () => {
|
||||
expect(await actionLabels({ worktreeId: 'runtime:vm-wt', hostId: 'runtime:vm' })).toEqual([
|
||||
'Pin',
|
||||
|
||||
@@ -124,7 +124,14 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr
|
||||
{
|
||||
label: 'Delete',
|
||||
destructive: true,
|
||||
onPress: () => state.setConfirmDelete(actionTarget)
|
||||
onPress: () => state.setConfirmDelete(actionTarget),
|
||||
// Like desktop, git can't remove a primary checkout; older hosts omit the flag.
|
||||
...(actionTarget.isMainWorktree === true
|
||||
? {
|
||||
disabled: true,
|
||||
hint: "Primary worktree — can't be deleted. Remove the project instead."
|
||||
}
|
||||
: {})
|
||||
}
|
||||
]
|
||||
: []
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
HostWorkspaceListStates
|
||||
} from '../worktree/host-workspace-list-states'
|
||||
import { getWorktreeDotStatus } from '../worktree/workspace-list-ordering'
|
||||
import { repoColor } from '../worktree/repo-color'
|
||||
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'
|
||||
@@ -248,7 +248,7 @@ export function HostWorkspaceList({ controller }: { controller: HostScreenContro
|
||||
isReadOnly={isReadOnly}
|
||||
now={now}
|
||||
status={getWorktreeDotStatus(item)}
|
||||
repoColor={repoLookById.get(item.repoId)?.color ?? repoColor(item.repo)}
|
||||
repoColor={repoLookById.get(item.repoId)?.color ?? DEFAULT_REPO_BADGE_COLOR}
|
||||
repoIcon={repoLookById.get(item.repoId)?.icon ?? null}
|
||||
hideRepo={state.view.groupBy === 'repo' && !item.showProjectName}
|
||||
cardDetails={cardDetails}
|
||||
|
||||
@@ -86,8 +86,7 @@ export function renderMobileTasksCreateDrawer(model: ConnectionPresentationModel
|
||||
selectedCreateTarget
|
||||
? {
|
||||
backgroundColor: getRepoBadgeColor(
|
||||
selectedCreateTarget as RepoSummary,
|
||||
(selectedCreateTarget as RepoSummary).displayName
|
||||
'badgeColor' in selectedCreateTarget ? selectedCreateTarget : undefined
|
||||
)
|
||||
}
|
||||
: undefined
|
||||
|
||||
@@ -132,10 +132,7 @@ export function renderMobileTasksGitHubProjectList(model: ConnectionPresentation
|
||||
style={[
|
||||
styles.repoDot,
|
||||
{
|
||||
backgroundColor: getRepoBadgeColor(
|
||||
repo ?? undefined,
|
||||
row.content.repository ?? 'project'
|
||||
)
|
||||
backgroundColor: getRepoBadgeColor(repo ?? undefined)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -68,10 +68,7 @@ export function renderMobileTasksProviderControls(model: ConnectionPresentationM
|
||||
style={[
|
||||
styles.segmentRepoDot,
|
||||
{
|
||||
backgroundColor: getRepoBadgeColor(
|
||||
repoPickerSelectedRepo,
|
||||
repoPickerSelectedRepo.displayName
|
||||
)
|
||||
backgroundColor: getRepoBadgeColor(repoPickerSelectedRepo)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -138,10 +138,7 @@ export function renderMobileTasksRepoPicker(model: ConnectionPresentationModel)
|
||||
<View style={styles.actionSeparator} />
|
||||
<Pressable style={styles.repoPickerRow} onPress={() => toggleRepoSelection(repo.id)}>
|
||||
<View
|
||||
style={[
|
||||
styles.pickerRepoDot,
|
||||
{ backgroundColor: getRepoBadgeColor(repo, repo.displayName) }
|
||||
]}
|
||||
style={[styles.pickerRepoDot, { backgroundColor: getRepoBadgeColor(repo) }]}
|
||||
/>
|
||||
<View style={styles.repoPickerTextWrap}>
|
||||
<Text style={styles.repoPickerTitle} numberOfLines={1}>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveRepoBadgeColor } from '../../../src/shared/repo-badge-color'
|
||||
import { getRepoExecutionHostId, parseExecutionHostId } from '../../../src/shared/execution-host'
|
||||
import {
|
||||
getHostContextLabel,
|
||||
@@ -38,8 +39,8 @@ export function getTaskRepoDetail(
|
||||
return `${getHostContextLabel(hostId, sources)} · ${repo.path}`
|
||||
}
|
||||
|
||||
export function getRepoBadgeColor(repo: RepoSummary | undefined, fallbackName: string): string {
|
||||
return repo?.badgeColor || repoColor(repo?.displayName ?? fallbackName)
|
||||
export function getRepoBadgeColor(repo: RepoSummary | undefined): string {
|
||||
return resolveRepoBadgeColor(repo?.badgeColor)
|
||||
}
|
||||
|
||||
export function setupSourceLabel(source: string | null | undefined): string {
|
||||
@@ -61,7 +62,7 @@ export function taskRepositoryMeta(
|
||||
return {
|
||||
key: item.source.repoId,
|
||||
label: repo?.displayName ?? item.source.repoName,
|
||||
color: getRepoBadgeColor(repo, item.source.repoName)
|
||||
color: getRepoBadgeColor(repo)
|
||||
}
|
||||
}
|
||||
if (item.provider === 'gitlabTodo') {
|
||||
|
||||
@@ -79,10 +79,7 @@ export function renderMobileTasksWorkspaceCreateDrawer(model: ConnectionPresenta
|
||||
style={[
|
||||
styles.pickerRepoDot,
|
||||
{
|
||||
backgroundColor: getRepoBadgeColor(
|
||||
workspaceCreateTargetRepo,
|
||||
workspaceCreateTargetRepo.displayName
|
||||
)
|
||||
backgroundColor: getRepoBadgeColor(workspaceCreateTargetRepo)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -51,12 +51,7 @@ export function useMobileTasksPickerProjection(model: DetailCommentRenderersMode
|
||||
label: repo.displayName,
|
||||
subtitle: getTaskRepoDetail(repo, repoHostLabels),
|
||||
renderIcon: () => (
|
||||
<View
|
||||
style={[
|
||||
styles.pickerRepoDot,
|
||||
{ backgroundColor: getRepoBadgeColor(repo, repo.displayName) }
|
||||
]}
|
||||
/>
|
||||
<View style={[styles.pickerRepoDot, { backgroundColor: getRepoBadgeColor(repo) }]} />
|
||||
)
|
||||
}))
|
||||
: linearTeams.map((team) => ({
|
||||
@@ -137,12 +132,7 @@ export function useMobileTasksPickerProjection(model: DetailCommentRenderersMode
|
||||
label: repo.displayName,
|
||||
subtitle: getTaskRepoDetail(repo, repoHostLabels),
|
||||
renderIcon: () => (
|
||||
<View
|
||||
style={[
|
||||
styles.pickerRepoDot,
|
||||
{ backgroundColor: getRepoBadgeColor(repo, repo.displayName) }
|
||||
]}
|
||||
/>
|
||||
<View style={[styles.pickerRepoDot, { backgroundColor: getRepoBadgeColor(repo) }]} />
|
||||
)
|
||||
})),
|
||||
[repoHostLabels, workspaceRepos]
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
|
||||
import {
|
||||
agentMainAgentVerdict,
|
||||
agentVerdictDisplayMark
|
||||
} from '../../../src/shared/agent-main-agent-verdict'
|
||||
import { AGENT_TURN_OUTCOMES } from '../../../src/shared/agent-turn-outcome'
|
||||
import { isAgentStatusFresh } from '../../../src/shared/agent-status-freshness'
|
||||
import {
|
||||
agentDisplayLabel,
|
||||
agentDotState,
|
||||
agentIdentityLabel,
|
||||
agentRowTimeAt,
|
||||
agentRowVerdict,
|
||||
agentRowVerdictMark,
|
||||
formatTimeAgo
|
||||
} from './agent-row-display'
|
||||
|
||||
@@ -53,85 +46,18 @@ describe('agentDotState', () => {
|
||||
expect(agentDotState(row({ state: 'unknown-state' as never }), 0)).toBe('idle')
|
||||
})
|
||||
|
||||
it("reports the verdict of a done row: failed, or a user's Stop (also an old host's flag) as interrupted", () => {
|
||||
expect(agentDotState(row({ state: 'done', interrupted: true }), 0)).toBe('interrupted')
|
||||
expect(agentDotState(row({ state: 'done', ...mainAgentDone('failure') }), 0)).toBe('failed')
|
||||
expect(
|
||||
agentDotState(row({ state: 'done', ...mainAgentDone('cancellation'), interrupted: true }), 0)
|
||||
).toBe('interrupted')
|
||||
expect(agentDotState(row({ state: 'done', ...mainAgentDone('success') }), 0)).toBe('done')
|
||||
expect(agentDotState(row({ state: 'done', ...mainAgentDone('superseded') }), 0)).toBe(
|
||||
'interrupted'
|
||||
)
|
||||
})
|
||||
|
||||
it('reads a crash-cut turn as failed and an unproven end as unconfirmed', () => {
|
||||
expect(agentDotState(row({ state: 'done', ...mainAgentDone('interruption') }), 0)).toBe(
|
||||
'failed'
|
||||
)
|
||||
expect(agentDisplayLabel(row({ state: 'done', ...mainAgentDone('interruption') }), 0)).toBe(
|
||||
'Failed'
|
||||
)
|
||||
expect(agentDotState(row({ state: 'done', ...mainAgentDone('unconfirmed') }), 0)).toBe(
|
||||
'unconfirmed'
|
||||
)
|
||||
it('lets the shared verdict mark win over the row state, label included', () => {
|
||||
expect(agentDotState(row({ state: 'working', ...mainAgentDone('failure') }), 0)).toBe('failed')
|
||||
expect(agentDisplayLabel(row({ state: 'done', ...mainAgentDone('unconfirmed') }), 0)).toBe(
|
||||
'Couldn’t confirm'
|
||||
)
|
||||
})
|
||||
|
||||
// Unparsed rows may carry a future outcome; keep the done fallback.
|
||||
it('reads a done row carrying an outcome it cannot name as done', () => {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: an arm from a newer host, which the unparsed wire row can carry.
|
||||
const future = mainAgentDone('from-a-newer-host' as Outcome)
|
||||
expect(agentDotState(row({ state: 'done', ...future }), 0)).toBe('done')
|
||||
})
|
||||
|
||||
it('shows a main agent that failed while its subagents still run as failed', () => {
|
||||
expect(agentDotState(row({ state: 'working', ...mainAgentDone('failure') }), 0)).toBe('failed')
|
||||
expect(agentDotState(row({ state: 'waiting', ...mainAgentDone('failure') }), 0)).toBe('failed')
|
||||
expect(agentDotState(row({ state: 'working', ...mainAgentDone('interruption') }), 0)).toBe(
|
||||
'failed'
|
||||
)
|
||||
expect(agentDotState(row({ state: 'working', ...mainAgentDone('success') }), 0)).toBe('working')
|
||||
expect(
|
||||
agentDotState(
|
||||
row({ state: 'working', ...mainAgentDone('cancellation'), interrupted: true }),
|
||||
0
|
||||
)
|
||||
).toBe('working')
|
||||
})
|
||||
|
||||
// App code cannot import the desktop accessor; pin the mirror against it.
|
||||
it('agrees with the desktop verdict accessor on every row', () => {
|
||||
const states = ['working', 'blocked', 'waiting', 'done'] as const
|
||||
const mainAgents = [
|
||||
undefined,
|
||||
...states.flatMap((state) =>
|
||||
[undefined, ...AGENT_TURN_OUTCOMES].map((outcome) => ({
|
||||
state,
|
||||
...(outcome ? { outcome } : {}),
|
||||
stateStartedAt: 0
|
||||
}))
|
||||
)
|
||||
]
|
||||
for (const state of states) {
|
||||
for (const mainAgent of mainAgents) {
|
||||
for (const interrupted of [false, true]) {
|
||||
const agentRow = { state, interrupted, ...(mainAgent ? { mainAgent } : {}) }
|
||||
expect(agentRowVerdict(agentRow), JSON.stringify(agentRow)).toBe(
|
||||
agentMainAgentVerdict(agentRow)
|
||||
)
|
||||
expect(agentRowVerdictMark(agentRow), JSON.stringify(agentRow)).toBe(
|
||||
agentVerdictDisplayMark(agentRow)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('decays a stale active state to idle, matching desktop', () => {
|
||||
const stale = 1_800_001
|
||||
expect(agentDotState(row({ state: 'working', updatedAt: 0, hasLivePty: true }), stale)).toBe(
|
||||
'unverifiable'
|
||||
)
|
||||
expect(agentDotState(row({ state: 'working', updatedAt: 0 }), stale)).toBe('idle')
|
||||
expect(agentDotState(row({ state: 'blocked', updatedAt: 0 }), stale)).toBe('idle')
|
||||
expect(agentDotState(row({ state: 'waiting', updatedAt: 0 }), stale)).toBe('idle')
|
||||
@@ -208,14 +134,11 @@ describe('agentDisplayLabel', () => {
|
||||
'Idle'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentIdentityLabel', () => {
|
||||
it('maps known agent types and falls back to initials', () => {
|
||||
expect(agentIdentityLabel('claude')).toBe('CL')
|
||||
expect(agentIdentityLabel('codex')).toBe('CX')
|
||||
expect(agentIdentityLabel('mystery')).toBe('MY')
|
||||
expect(agentIdentityLabel(null)).toBe('')
|
||||
it('keeps the task text on a silent row and names the silence when there is none', () => {
|
||||
const silent = row({ state: 'working', updatedAt: 0, hasLivePty: true })
|
||||
expect(agentDisplayLabel({ ...silent, prompt: 'build' }, 40 * 60_000)).toBe('build')
|
||||
expect(agentDisplayLabel(silent, 40 * 60_000)).toBe('No recent update')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
|
||||
import type { AgentTurnOutcome } from '../../../src/shared/agent-turn-outcome'
|
||||
import { isAgentStatusFresh } from '../../../src/shared/agent-status-freshness'
|
||||
import { agentVerdictDisplayMark } from '../../../src/shared/agent-main-agent-verdict'
|
||||
import {
|
||||
formatCompactDuration,
|
||||
resolveDecayedAgentRowState
|
||||
} from '../../../src/shared/agent-row-decay-state'
|
||||
|
||||
// Mirror desktop AgentStateDot vocabulary.
|
||||
export type AgentDotState =
|
||||
@@ -10,6 +14,7 @@ export type AgentDotState =
|
||||
| 'waiting'
|
||||
| 'done'
|
||||
| 'idle'
|
||||
| 'unverifiable'
|
||||
| 'interrupted'
|
||||
| 'failed'
|
||||
| 'unconfirmed'
|
||||
@@ -19,71 +24,27 @@ type AgentRowVerdictSource = Pick<RuntimeWorktreeAgentRow, 'state' | 'mainAgent'
|
||||
interrupted?: boolean
|
||||
}
|
||||
|
||||
// Mirror AGENT_TURN_OUTCOMES; unparsed future host arms yield no verdict.
|
||||
const AGENT_TURN_OUTCOMES: readonly AgentTurnOutcome[] = [
|
||||
'success',
|
||||
'failure',
|
||||
'cancellation',
|
||||
'superseded',
|
||||
'interruption',
|
||||
'unconfirmed'
|
||||
]
|
||||
|
||||
// Mirror desktop verdict accessors; mainAgent remains distinct while subagents keep the row working.
|
||||
export function agentRowVerdict(row: AgentRowVerdictSource): AgentTurnOutcome | null {
|
||||
if (row.mainAgent && row.mainAgent.state !== 'done') {
|
||||
return null
|
||||
}
|
||||
const outcome = row.mainAgent?.outcome
|
||||
if (outcome !== undefined) {
|
||||
return AGENT_TURN_OUTCOMES.find((known) => known === outcome) ?? null
|
||||
}
|
||||
return row.state === 'done' && row.interrupted ? 'cancellation' : null
|
||||
}
|
||||
|
||||
// Faults outrank live state; Stop, superseded and unproven endings mark only rows already done.
|
||||
export function agentRowVerdictMark(
|
||||
row: AgentRowVerdictSource
|
||||
): 'failed' | 'interrupted' | 'unconfirmed' | null {
|
||||
switch (agentRowVerdict(row)) {
|
||||
case 'failure':
|
||||
case 'interruption':
|
||||
return 'failed'
|
||||
case 'cancellation':
|
||||
case 'superseded':
|
||||
return row.state === 'done' ? 'interrupted' : null
|
||||
case 'unconfirmed':
|
||||
return row.state === 'done' ? 'unconfirmed' : null
|
||||
case 'success':
|
||||
case null:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function agentDotState(
|
||||
row: AgentRowVerdictSource &
|
||||
Pick<RuntimeWorktreeAgentRow, 'workingMode'> &
|
||||
Pick<RuntimeWorktreeAgentRow, 'workingMode' | 'hasLivePty'> &
|
||||
Parameters<typeof isAgentStatusFresh>[0],
|
||||
now: number
|
||||
): AgentDotState {
|
||||
const mark = agentRowVerdictMark(row)
|
||||
const mark = agentVerdictDisplayMark(row)
|
||||
if (mark) {
|
||||
return mark
|
||||
}
|
||||
switch (row.state) {
|
||||
case 'blocked':
|
||||
case 'waiting':
|
||||
// Decay stale active states like desktop so agents exiting without a report cannot remain active forever.
|
||||
return isAgentStatusFresh(row, now) ? row.state : 'idle'
|
||||
case 'working':
|
||||
if (!isAgentStatusFresh(row, now)) {
|
||||
return 'idle'
|
||||
}
|
||||
return row.workingMode === 'monitoring' ? 'monitoring' : 'working'
|
||||
case 'done':
|
||||
return 'done'
|
||||
if (row.state === 'done') {
|
||||
return 'done'
|
||||
}
|
||||
return 'idle'
|
||||
// Decay stale active states like desktop: a live terminal can't prove the agent ended, so it reads unverifiable.
|
||||
if (!isAgentStatusFresh(row, now)) {
|
||||
return resolveDecayedAgentRowState(row, row.hasLivePty === true)
|
||||
}
|
||||
if (row.state === 'working') {
|
||||
return row.workingMode === 'monitoring' ? 'monitoring' : 'working'
|
||||
}
|
||||
return row.state === 'blocked' || row.state === 'waiting' ? row.state : 'idle'
|
||||
}
|
||||
|
||||
// Mirrors desktop agentStateLabel.
|
||||
@@ -107,6 +68,8 @@ export function agentStateLabel(state: AgentDotState): string {
|
||||
return 'Done'
|
||||
case 'idle':
|
||||
return 'Idle'
|
||||
case 'unverifiable':
|
||||
return 'No recent update'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,30 +92,11 @@ export function agentDisplayLabel(row: RuntimeWorktreeAgentRow, now: number): st
|
||||
return agentStateLabel(agentDotState(row, now))
|
||||
}
|
||||
|
||||
export function agentIdentityLabel(agentType: string | null): string {
|
||||
if (!agentType) {
|
||||
return ''
|
||||
}
|
||||
const normalized = agentType.toLowerCase()
|
||||
const known: Record<string, string> = {
|
||||
claude: 'CL',
|
||||
codex: 'CX',
|
||||
gemini: 'GM',
|
||||
cursor: 'CR',
|
||||
copilot: 'CP',
|
||||
amp: 'AM',
|
||||
aider: 'AI',
|
||||
opencode: 'OC',
|
||||
'mimo-code': 'MC'
|
||||
}
|
||||
return known[normalized] ?? normalized.slice(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
// Mirror desktop lastEnteredDoneAt: date main-agent failures independently while subagents remain active.
|
||||
export function agentRowTimeAt(
|
||||
row: Pick<RuntimeWorktreeAgentRow, 'state' | 'interrupted' | 'mainAgent' | 'stateStartedAt'>
|
||||
): number {
|
||||
if (row.state !== 'done' && row.mainAgent && agentRowVerdictMark(row) === 'failed') {
|
||||
if (row.state !== 'done' && row.mainAgent && agentVerdictDisplayMark(row) === 'failed') {
|
||||
return row.mainAgent.stateStartedAt
|
||||
}
|
||||
return row.stateStartedAt
|
||||
@@ -160,17 +104,5 @@ export function agentRowTimeAt(
|
||||
|
||||
export function formatTimeAgo(ts: number, now: number): string {
|
||||
const delta = now - ts
|
||||
if (delta < 60_000) {
|
||||
return 'just now'
|
||||
}
|
||||
const minutes = Math.floor(delta / 60_000)
|
||||
if (minutes < 60) {
|
||||
return `${minutes}m`
|
||||
}
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) {
|
||||
return `${hours}h`
|
||||
}
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days}d`
|
||||
return delta < 60_000 ? 'just now' : formatCompactDuration(delta)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { withFolderWorkspacesLast } from './folder-workspace-lane-order'
|
||||
import type { Worktree } from './workspace-list-types'
|
||||
|
||||
function row(worktreeId: string, overrides: Partial<Worktree> = {}): Worktree {
|
||||
return {
|
||||
workspaceKind: 'git',
|
||||
worktreeId,
|
||||
repoId: 'repo-1',
|
||||
repo: 'orca',
|
||||
branch: '',
|
||||
displayName: worktreeId,
|
||||
path: `/tmp/${worktreeId}`,
|
||||
liveTerminalCount: 0,
|
||||
hasAttachedPty: false,
|
||||
unread: false,
|
||||
isPinned: false,
|
||||
linkedPR: null,
|
||||
agents: [],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('withFolderWorkspacesLast', () => {
|
||||
it('lists folder workspaces after the worktrees, by user rank then name, like desktop', () => {
|
||||
const folder = { workspaceKind: 'folder-workspace' as const }
|
||||
const rows = [
|
||||
row('folder:low', { ...folder, displayName: 'b', sortOrder: 1 }),
|
||||
row('git'),
|
||||
row('folder:tie', { ...folder, displayName: 'a', sortOrder: 1 }),
|
||||
row('folder:high', { ...folder, displayName: 'c', sortOrder: 5 })
|
||||
]
|
||||
expect(withFolderWorkspacesLast(rows).map((w) => w.worktreeId)).toEqual([
|
||||
'git',
|
||||
'folder:high',
|
||||
'folder:tie',
|
||||
'folder:low'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { compareFolderWorkspacesForDisplay } from '../../../src/shared/sidebar-list/folder-workspace-lanes'
|
||||
import type { Worktree } from './workspace-list-types'
|
||||
|
||||
function folderDisplayOrder(w: Worktree) {
|
||||
return {
|
||||
id: w.worktreeId,
|
||||
name: w.displayName,
|
||||
sortOrder: w.sortOrder ?? 0,
|
||||
manualOrder: w.manualOrder,
|
||||
executionHostId: w.hostId
|
||||
}
|
||||
}
|
||||
|
||||
// Like desktop, a lane lists its folder workspaces after its worktrees, by user rank then name.
|
||||
export function withFolderWorkspacesLast(worktrees: readonly Worktree[]): readonly Worktree[] {
|
||||
const rows: Worktree[] = []
|
||||
const folders: { row: Worktree; order: ReturnType<typeof folderDisplayOrder> }[] = []
|
||||
for (const row of worktrees) {
|
||||
if (row.workspaceKind === 'folder-workspace') {
|
||||
folders.push({ row, order: folderDisplayOrder(row) })
|
||||
} else {
|
||||
rows.push(row)
|
||||
}
|
||||
}
|
||||
if (folders.length === 0) {
|
||||
return worktrees
|
||||
}
|
||||
folders.sort((a, b) => compareFolderWorkspacesForDisplay(a.order, b.order))
|
||||
return [...rows, ...folders.map((folder) => folder.row)]
|
||||
}
|
||||
@@ -59,7 +59,7 @@ function catalogResponse(count: number, active: number): RpcResponse {
|
||||
branch: `branch-${index}`,
|
||||
displayName: `Workspace ${index}`,
|
||||
liveTerminalCount: 0,
|
||||
status: index < active ? ('working' as const) : ('done' as const)
|
||||
status: index < active ? ('working' as const) : ('inactive' as const)
|
||||
}))
|
||||
return { ok: true, result: { worktrees } } as RpcResponse
|
||||
}
|
||||
@@ -109,6 +109,24 @@ describe('fetchHomeHostWorktreeInfo', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('counts only the rows the list shows, with its activity rule', async () => {
|
||||
const store = infoStore()
|
||||
const host = fakeSession()
|
||||
const row = { repo: 'orca', branch: 'b', displayName: 'w', liveTerminalCount: 0 }
|
||||
const worktrees = [
|
||||
{ ...row, worktreeId: 'archived', isArchived: true, status: 'working' },
|
||||
{ ...row, worktreeId: 'tab-only', status: 'inactive', hasHostSidebarActivity: true },
|
||||
{ ...row, worktreeId: 'idle', status: 'inactive', hasHostSidebarActivity: false }
|
||||
]
|
||||
|
||||
const response: RpcResponse = { id: 'catalog', ok: true, result: { worktrees } }
|
||||
const loaded = fetchHomeHostWorktreeInfo(host.client, 'host-1', store.setInfo, notDisposed)
|
||||
host.settle(response)
|
||||
await loaded
|
||||
|
||||
expect(store.current['host-1']).toMatchObject({ totalWorktrees: 2, activeCount: 1 })
|
||||
})
|
||||
|
||||
it('stamps when the host proved the counts', async () => {
|
||||
const store = infoStore()
|
||||
const host = fakeSession()
|
||||
|
||||
@@ -7,10 +7,10 @@ import {
|
||||
type HostWorktreeInfo
|
||||
} from './home-worktree-info'
|
||||
import { pickResumeWorktree } from './resume-worktree'
|
||||
import { isWorktreeActive } from './workspace-list-sections'
|
||||
import { worktreeCatalogRead } from './worktree-catalog-operations'
|
||||
import { WORKTREE_PS_FULL_LIMIT } from './worktree-catalog-snapshot-client'
|
||||
|
||||
const ACTIVE_STATUSES = new Set(['working', 'active', 'permission'])
|
||||
// Cutover may stay connected, so retry reads without waiting for the connect gate; cap migration-loop retries.
|
||||
const CUTOVER_RETRY_LIMIT = 2
|
||||
|
||||
@@ -47,15 +47,16 @@ export function fetchHomeHostWorktreeInfo(
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `worktrees` is a salvaged member, so the `?? []` is what makes it an array; the rows stay opaque because three screens project a row differently. This card reads `status` and the resume pick, both through their own guards, and the `worktree-home-catalog` golden records the row it is given as `{worktreeId, displayName, repo, status}`.
|
||||
const worktrees = (catalog.value.worktrees ?? []) as HomeWorktreeSummary[]
|
||||
setCachedWorktrees(hostId, worktrees, { proven: true })
|
||||
const active = worktrees.filter((w) => w.status && ACTIVE_STATUSES.has(w.status))
|
||||
// Count what the list shows: archived rows are hidden, and active is the sleeping filter's rule.
|
||||
const listed = worktrees.filter((w) => w.isArchived !== true)
|
||||
// Mirror the desktop's focused workspace (see pickResumeWorktree).
|
||||
const lastActive = pickResumeWorktree(worktrees)
|
||||
setInfo((prev) => ({
|
||||
...prev,
|
||||
[hostId]: {
|
||||
hostId,
|
||||
totalWorktrees: worktrees.length,
|
||||
activeCount: active.length,
|
||||
totalWorktrees: listed.length,
|
||||
activeCount: listed.filter(isWorktreeActive).length,
|
||||
lastActiveWorktree: lastActive,
|
||||
countsProvenAt: Date.now()
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ export type HomeWorktreeSummary = {
|
||||
displayName: string
|
||||
liveTerminalCount: number
|
||||
status?: 'working' | 'active' | 'permission' | 'done' | 'inactive'
|
||||
hasHostSidebarActivity?: boolean
|
||||
unread?: boolean
|
||||
isArchived?: boolean
|
||||
isActive?: boolean
|
||||
lastOutputAt?: number
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
const REPO_COLOR_PALETTE = [
|
||||
'#f97316',
|
||||
'#8b5cf6',
|
||||
'#06b6d4',
|
||||
'#ec4899',
|
||||
'#84cc16',
|
||||
'#f59e0b',
|
||||
'#6366f1'
|
||||
]
|
||||
|
||||
export function repoColor(name: string): string {
|
||||
let hash = 0
|
||||
for (let i = 0; i < name.length; i += 1) {
|
||||
hash = (hash * 31 + name.charCodeAt(i)) | 0
|
||||
}
|
||||
return REPO_COLOR_PALETTE[Math.abs(hash) % REPO_COLOR_PALETTE.length]!
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getRepoExecutionHostId } from '../../../src/shared/execution-host'
|
||||
import { repoColor } from './repo-color'
|
||||
import { resolveRepoBadgeColor } from '../../../src/shared/repo-badge-color'
|
||||
import { buildSectionRepoMap, type SectionRepo, type Worktree } from './workspace-list-sections'
|
||||
|
||||
export type WorkspaceSectionRepo = {
|
||||
@@ -10,8 +10,8 @@ export type WorkspaceSectionRepo = {
|
||||
color: string
|
||||
}
|
||||
|
||||
export function sectionRepoColor(repo: Pick<SectionRepo, 'badgeColor' | 'displayName'>): string {
|
||||
return (typeof repo.badgeColor === 'string' && repo.badgeColor) || repoColor(repo.displayName)
|
||||
export function sectionRepoColor(repo: Pick<SectionRepo, 'badgeColor'>): string {
|
||||
return resolveRepoBadgeColor(repo.badgeColor)
|
||||
}
|
||||
|
||||
// List catalog repos per host; before catalogs arrive, derive candidates from workspace rows.
|
||||
|
||||
@@ -57,13 +57,9 @@ export function sortWorktrees(
|
||||
})
|
||||
)
|
||||
}
|
||||
// Headless hosts stamp lastActivityAt only at creation, so terminal output must count toward recency.
|
||||
// Match desktop's effectiveRecentActivity: terminal output never counts as activity.
|
||||
const recentActivity = (w: Worktree): number =>
|
||||
recentActivityWithCreateGrace(
|
||||
Math.max(finiteOr(w.lastActivityAt, 0), finiteOr(w.lastOutputAt, 0)),
|
||||
w.createdAt,
|
||||
now
|
||||
)
|
||||
recentActivityWithCreateGrace(finiteOr(w.lastActivityAt, 0), w.createdAt, now)
|
||||
const compareRecent = (a: Worktree, b: Worktree): number =>
|
||||
recentActivity(b) - recentActivity(a) ||
|
||||
compareSidebarText(a.displayName, b.displayName) ||
|
||||
|
||||
@@ -377,42 +377,24 @@ describe('buildSections', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('counts live terminal output as Recent activity for headless serve hosts', () => {
|
||||
const streamingOnServe = worktree({
|
||||
it('ranks Recent by desktop activity, not terminal output', () => {
|
||||
const streaming = worktree({
|
||||
worktreeId: 'streaming',
|
||||
displayName: 'streaming',
|
||||
lastActivityAt: 100,
|
||||
lastOutputAt: 1_000
|
||||
})
|
||||
const touchedOnDesktop = worktree({
|
||||
const touched = worktree({
|
||||
worktreeId: 'touched',
|
||||
displayName: 'touched',
|
||||
lastActivityAt: 200,
|
||||
lastOutputAt: 1
|
||||
})
|
||||
|
||||
expect(
|
||||
sortWorktrees([touchedOnDesktop, streamingOnServe], 'recent').map((item) => item.worktreeId)
|
||||
).toEqual(['streaming', 'touched'])
|
||||
})
|
||||
|
||||
it('keeps desktop activity ranking in Recent when it is newest', () => {
|
||||
const newerActivity = worktree({
|
||||
worktreeId: 'newer-activity',
|
||||
displayName: 'newer',
|
||||
lastActivityAt: 2_000,
|
||||
lastOutputAt: 1
|
||||
})
|
||||
const quietOlder = worktree({
|
||||
worktreeId: 'quiet-older',
|
||||
displayName: 'quiet',
|
||||
lastActivityAt: 100,
|
||||
lastOutputAt: 150
|
||||
})
|
||||
|
||||
expect(
|
||||
sortWorktrees([quietOlder, newerActivity], 'recent').map((item) => item.worktreeId)
|
||||
).toEqual(['newer-activity', 'quiet-older'])
|
||||
expect(sortWorktrees([streaming, touched], 'recent').map((item) => item.worktreeId)).toEqual([
|
||||
'touched',
|
||||
'streaming'
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to agent attention order in Smart when no desktop ranks exist', () => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { orderMainWorktreeFirst } from '../../../src/shared/sidebar-list/section-order'
|
||||
import { withFolderWorkspacesLast } from './folder-workspace-lane-order'
|
||||
import { prGroupOfPsLinkedPR } from '../../../src/shared/sidebar-list/workspace-review-facts'
|
||||
import {
|
||||
buildWorktreeOccurrencePlan,
|
||||
@@ -96,7 +98,11 @@ function makeSection(
|
||||
}
|
||||
|
||||
// Idle prompts have no recent output; live terminals or unread output still count as active.
|
||||
export function isWorktreeActive(w: Worktree): boolean {
|
||||
export function isWorktreeActive(
|
||||
w: Pick<Worktree, 'hasHostSidebarActivity' | 'status' | 'liveTerminalCount'> & {
|
||||
unread?: boolean
|
||||
}
|
||||
): boolean {
|
||||
if (w.hasHostSidebarActivity !== undefined) {
|
||||
return w.hasHostSidebarActivity
|
||||
}
|
||||
@@ -140,14 +146,6 @@ function phoneRowFacts(
|
||||
}
|
||||
}
|
||||
|
||||
function orderMainWorktreeFirst(worktrees: Worktree[]): Worktree[] {
|
||||
const mainWorktrees = worktrees.filter((worktree) => worktree.isMainWorktree)
|
||||
if (mainWorktrees.length === 0) {
|
||||
return worktrees
|
||||
}
|
||||
return [...mainWorktrees, ...worktrees.filter((worktree) => !worktree.isMainWorktree)]
|
||||
}
|
||||
|
||||
export function filterWorktrees(
|
||||
worktrees: Worktree[],
|
||||
filters: FilterState,
|
||||
@@ -209,7 +207,9 @@ export function buildMobileWorkspacePlan(
|
||||
}
|
||||
if (groupMode === 'none') {
|
||||
if (canonicalGroupWorktrees.length > 0) {
|
||||
sections.push(makeSection('all', 'All', canonicalGroupWorktrees))
|
||||
sections.push(
|
||||
makeSection('all', 'All', withFolderWorkspacesLast(canonicalGroupWorktrees))
|
||||
)
|
||||
}
|
||||
} else if (groupMode === 'repo') {
|
||||
const representedRepoIds = new Set(worktrees.map((w) => w.repoId))
|
||||
@@ -257,7 +257,13 @@ export function buildMobileWorkspacePlan(
|
||||
for (const status of renderableWorkspaceStatuses) {
|
||||
const items = byStatus.get(status.id)
|
||||
if (items && items.length > 0) {
|
||||
sections.push(makeSection(getWorkspaceStatusGroupKey(status.id), status.label, items))
|
||||
sections.push(
|
||||
makeSection(
|
||||
getWorkspaceStatusGroupKey(status.id),
|
||||
status.label,
|
||||
withFolderWorkspacesLast(items)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (groupMode === 'pr-status') {
|
||||
@@ -274,7 +280,13 @@ export function buildMobileWorkspacePlan(
|
||||
for (const groupKey of PR_GROUP_ORDER) {
|
||||
const items = byGroup.get(groupKey)
|
||||
if (items && items.length > 0) {
|
||||
sections.push(makeSection(getPRLaneKey(groupKey), PR_GROUP_LABELS[groupKey], items))
|
||||
sections.push(
|
||||
makeSection(
|
||||
getPRLaneKey(groupKey),
|
||||
PR_GROUP_LABELS[groupKey],
|
||||
withFolderWorkspacesLast(items)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,13 +75,13 @@ describe('sortWorktrees with host stamps', () => {
|
||||
worktreeId: 'busy',
|
||||
displayName: 'B',
|
||||
lastActivityAt: 5,
|
||||
lastOutputAt: 99
|
||||
createdAt: 0
|
||||
})
|
||||
const all = [stamped, quiet, busy]
|
||||
const order = (rows: Worktree[]) =>
|
||||
sortWorktrees(rows, 'recent', 0, hostSortsByStamps(all)).map((item) => item.worktreeId)
|
||||
expect(order([quiet, busy])).toEqual(['quiet', 'busy'])
|
||||
// Without the host's answer the old algorithm reads output recency.
|
||||
// Without the host's answer the phone applies the new-workspace grace.
|
||||
expect(sortWorktrees([quiet, busy], 'recent', 0).map((item) => item.worktreeId)).toEqual([
|
||||
'busy',
|
||||
'quiet'
|
||||
|
||||
@@ -56,6 +56,7 @@ export function attachRuntimeWorktreeAgentRows(args: {
|
||||
...(source.children !== undefined ? { children: source.children } : {}),
|
||||
...(source.subagents !== undefined ? { subagents: source.subagents } : {}),
|
||||
...(source.structuredHost === 'owned' ? { structuredHostOwned: true as const } : {}),
|
||||
...(source.hasLivePty ? { hasLivePty: true as const } : {}),
|
||||
...(source.programStatus ? { programStatus: source.programStatus } : {})
|
||||
}
|
||||
if (source.children !== undefined || source.subagents !== undefined) {
|
||||
|
||||
@@ -12,6 +12,8 @@ export type RuntimeWorktreeAgentSource = Pick<AgentStatusIpcPayload, 'subagents'
|
||||
paneKey: string
|
||||
ptyId?: string
|
||||
tabId?: string
|
||||
/** A connected PTY on this host backs the row. */
|
||||
hasLivePty?: true
|
||||
worktreeId?: string
|
||||
connectionId: string | null
|
||||
state: ParsedAgentStatusPayload['state']
|
||||
|
||||
@@ -50,6 +50,15 @@ describe('worktree agent source admission', () => {
|
||||
expect(sources.get(paneKey)?.worktreeId).toBe('remote-worktree')
|
||||
})
|
||||
|
||||
it('marks only rows a connected PTY backs as live', () => {
|
||||
expect(collectRuntimeWorktreeAgentSources(connected).get(paneKey)?.hasLivePty).toBe(true)
|
||||
const remote = { ...hookRow, connectionId: 'ssh-connection' }
|
||||
expect(
|
||||
collectRuntimeWorktreeAgentSources({ ...base, hookSnapshots: [remote] }).get(paneKey)
|
||||
?.hasLivePty
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejoins the row to the connected PTY behind its terminal handle', () => {
|
||||
expect(collectRuntimeWorktreeAgentSources(connected).get(paneKey)?.ptyId).toBe('pty')
|
||||
// The handle is the last rescue once a controller incarnation nulls the pane binding.
|
||||
|
||||
@@ -89,21 +89,23 @@ export function collectRuntimeWorktreePtyAgentSources(args: {
|
||||
parsePaneKey(source.paneKey)?.tabId ??
|
||||
parseLegacyNumericPaneKey(source.paneKey)?.tabId
|
||||
const mirroredWorktreeId = tabId ? args.mirroredWorktreeIdByTabId.get(tabId) : undefined
|
||||
const hasLivePty =
|
||||
// Resolved only from a connected PTY's handle, so its presence is the liveness evidence.
|
||||
source.ptyId !== undefined ||
|
||||
args.connectedPtyEvidence.paneKeys.has(source.paneKey) ||
|
||||
(tabId !== undefined && args.connectedPtyEvidence.tabIds.has(tabId))
|
||||
// Structured rows have no PTY; their host removes them on close, so roster membership proves liveness.
|
||||
if (
|
||||
source.structuredHost === undefined &&
|
||||
tabId !== undefined &&
|
||||
mirroredWorktreeId === undefined &&
|
||||
isLocalHookConnectionId(source.connectionId) &&
|
||||
!args.connectedPtyEvidence.tabIds.has(tabId) &&
|
||||
!args.connectedPtyEvidence.paneKeys.has(source.paneKey) &&
|
||||
// Resolved only from a connected PTY's handle, so its presence is the liveness evidence.
|
||||
source.ptyId === undefined
|
||||
!hasLivePty
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const worktreeId = mirroredWorktreeId ?? source.worktreeId
|
||||
sources.push({ ...source, tabId, worktreeId })
|
||||
sources.push({ ...source, tabId, worktreeId, ...(hasLivePty ? { hasLivePty: true } : {}) })
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
+1
-12
@@ -78,18 +78,7 @@ export const ORPHAN_WORKTREE_ID = '__orphan__'
|
||||
// Why: synthetic local workspace; persistence pruning must classify it without the repo catalog.
|
||||
export const FLOATING_TERMINAL_WORKTREE_ID = 'global-floating-terminal'
|
||||
|
||||
export const REPO_COLORS = [
|
||||
'#737373', // neutral
|
||||
'#ef4444', // red
|
||||
'#f97316', // orange
|
||||
'#eab308', // yellow
|
||||
'#22c55e', // green
|
||||
'#14b8a6', // teal
|
||||
'#8b5cf6', // purple
|
||||
'#ec4899' // pink
|
||||
] as const
|
||||
|
||||
export const DEFAULT_REPO_BADGE_COLOR = REPO_COLORS[0]
|
||||
export { DEFAULT_REPO_BADGE_COLOR, REPO_COLORS } from './repo-badge-color'
|
||||
|
||||
/** Expose the stock root so callers can distinguish an untouched default from a user-selected directory. */
|
||||
export function getDefaultWorkspaceDir(homeDir: string): string {
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
import { DEFAULT_REPO_BADGE_COLOR, REPO_COLORS } from './constants'
|
||||
export const REPO_COLORS = [
|
||||
'#737373', // neutral
|
||||
'#ef4444', // red
|
||||
'#f97316', // orange
|
||||
'#eab308', // yellow
|
||||
'#22c55e', // green
|
||||
'#14b8a6', // teal
|
||||
'#8b5cf6', // purple
|
||||
'#ec4899' // pink
|
||||
] as const
|
||||
|
||||
export const DEFAULT_REPO_BADGE_COLOR = REPO_COLORS[0]
|
||||
|
||||
const HEX_COLOR_PATTERN = /^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/
|
||||
|
||||
|
||||
@@ -48,6 +48,8 @@ export type RuntimeWorktreeAgentRow = {
|
||||
restoredUnconfirmed?: boolean
|
||||
/** A structured host still owns the provider child, so age alone cannot stale it. Old hosts omit this. */
|
||||
structuredHostOwned?: true
|
||||
/** A connected PTY backs the row, so a stale row reads "No update in …" rather than idle. Old hosts omit this. */
|
||||
hasLivePty?: true
|
||||
/** The program's own OSC 7501 report; never decides `state`. Optional: old hosts never send it. */
|
||||
programStatus?: ProgramStatusFacet
|
||||
}
|
||||
|
||||
@@ -47,10 +47,15 @@ export function getFolderWorkspaceLaneKey(
|
||||
}
|
||||
}
|
||||
|
||||
type FolderWorkspaceDisplayOrder = Pick<
|
||||
FolderWorkspace,
|
||||
'id' | 'name' | 'sortOrder' | 'manualOrder' | 'executionHostId'
|
||||
>
|
||||
|
||||
/** Match project-group ordering: user rank, then name. */
|
||||
export function compareFolderWorkspacesForDisplay(
|
||||
left: FolderWorkspace,
|
||||
right: FolderWorkspace
|
||||
left: FolderWorkspaceDisplayOrder,
|
||||
right: FolderWorkspaceDisplayOrder
|
||||
): number {
|
||||
const leftOrder = left.manualOrder ?? left.sortOrder
|
||||
const rightOrder = right.manualOrder ?? right.sortOrder
|
||||
|
||||
@@ -53,7 +53,9 @@ export function getRenderedNaturalAnchorRepoIds({
|
||||
return renderedRepoIds
|
||||
}
|
||||
|
||||
export function orderMainWorktreeFirst(worktrees: Worktree[]): Worktree[] {
|
||||
export function orderMainWorktreeFirst<T extends { isMainWorktree?: boolean }>(
|
||||
worktrees: T[]
|
||||
): T[] {
|
||||
const mainWorktrees = worktrees.filter((worktree) => worktree.isMainWorktree)
|
||||
if (mainWorktrees.length === 0) {
|
||||
return worktrees
|
||||
|
||||
Reference in New Issue
Block a user