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:
Kelvin Amoaba
2026-10-27 04:25:59 +00:00
committed by GitHub
parent 425425e200
commit 7e5f9a48df
38 changed files with 310 additions and 343 deletions
@@ -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"]
}
}
]
+4 -1
View File
@@ -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',
+8 -1
View File
@@ -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]
+9 -86
View File
@@ -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')
})
})
+22 -90
View File
@@ -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
}
-17
View File
@@ -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', () => {
+24 -12
View File
@@ -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
View File
@@ -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 {
+12 -1
View File
@@ -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})$/
+2
View File
@@ -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
+3 -1
View File
@@ -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