From 7e5f9a48dfeaa1c3f4b57594e0f9d36d10eed692 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Tue, 27 Oct 2026 04:25:59 +0000 Subject: [PATCH] fix(mobile): use desktop's sort, colour, count, agent-row and delete rules in the workspace list (#33995) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- ...x-worktree.home-catalog-worktree.ps-1.json | 36 +++--- .../goldens/worktree-home-catalog.json | 36 +++--- mobile/src/components/AgentStateDot.tsx | 5 +- mobile/src/components/WorktreeAgentRow.tsx | 9 +- mobile/src/components/WorktreeListRow.test.ts | 18 +++ .../components/new-worktree-modal-types.ts | 4 +- .../host-screen/host-screen-overlays.test.tsx | 11 ++ .../src/host-screen/host-screen-overlays.tsx | 9 +- .../src/host-screen/host-workspace-list.tsx | 4 +- .../src/tasks/mobile-tasks-create-drawers.tsx | 3 +- .../mobile-tasks-github-project-list.tsx | 5 +- .../tasks/mobile-tasks-provider-controls.tsx | 5 +- .../tasks/mobile-tasks-provider-pickers.tsx | 5 +- .../mobile-tasks-repository-presentation.ts | 7 +- .../mobile-tasks-workspace-create-drawer.tsx | 5 +- .../use-mobile-tasks-picker-projection.tsx | 14 +-- mobile/src/worktree/agent-row-display.test.ts | 95 ++------------- mobile/src/worktree/agent-row-display.ts | 112 ++++-------------- .../folder-workspace-lane-order.test.ts | 40 +++++++ .../worktree/folder-workspace-lane-order.ts | 30 +++++ .../worktree/home-host-worktree-fetch.test.ts | 20 +++- .../src/worktree/home-host-worktree-fetch.ts | 9 +- mobile/src/worktree/home-worktree-info.ts | 3 + mobile/src/worktree/repo-color.ts | 17 --- mobile/src/worktree/workspace-filter-repos.ts | 6 +- .../src/worktree/workspace-list-ordering.ts | 8 +- .../worktree/workspace-list-sections.test.ts | 32 ++--- .../src/worktree/workspace-list-sections.ts | 36 ++++-- .../workspace-list-stamp-order.test.ts | 4 +- .../runtime/runtime-worktree-agent-rows.ts | 1 + .../runtime/runtime-worktree-agent-source.ts | 2 + .../runtime-worktree-agent-sources.test.ts | 9 ++ .../runtime-worktree-pty-agent-sources.ts | 12 +- src/shared/constants.ts | 13 +- src/shared/repo-badge-color.ts | 13 +- src/shared/runtime-worktree-contracts.ts | 2 + .../sidebar-list/folder-workspace-lanes.ts | 9 +- src/shared/sidebar-list/section-order.ts | 4 +- 38 files changed, 310 insertions(+), 343 deletions(-) create mode 100644 mobile/src/worktree/folder-workspace-lane-order.test.ts create mode 100644 mobile/src/worktree/folder-workspace-lane-order.ts delete mode 100644 mobile/src/worktree/repo-color.ts diff --git a/mobile/rpc-foundation/goldens/matrix-worktree.home-catalog-worktree.ps-1.json b/mobile/rpc-foundation/goldens/matrix-worktree.home-catalog-worktree.ps-1.json index ddf664f22a1..7540346568f 100644 --- a/mobile/rpc-foundation/goldens/matrix-worktree.home-catalog-worktree.ps-1.json +++ b/mobile/rpc-foundation/goldens/matrix-worktree.home-catalog-worktree.ps-1.json @@ -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"] } }, { diff --git a/mobile/rpc-foundation/goldens/worktree-home-catalog.json b/mobile/rpc-foundation/goldens/worktree-home-catalog.json index 438e69273f1..ac88f9261fd 100644 --- a/mobile/rpc-foundation/goldens/worktree-home-catalog.json +++ b/mobile/rpc-foundation/goldens/worktree-home-catalog.json @@ -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"] } } ] diff --git a/mobile/src/components/AgentStateDot.tsx b/mobile/src/components/AgentStateDot.tsx index 3bdced27522..12b09e1fa63 100644 --- a/mobile/src/components/AgentStateDot.tsx +++ b/mobile/src/components/AgentStateDot.tsx @@ -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, string> = { +const DOT_COLORS: Record< + Exclude, + string +> = { done: '#10b981', blocked: '#ef4444', waiting: '#ef4444', diff --git a/mobile/src/components/WorktreeAgentRow.tsx b/mobile/src/components/WorktreeAgentRow.tsx index 111838a4b35..4ad5b4018a3 100644 --- a/mobile/src/components/WorktreeAgentRow.tsx +++ b/mobile/src/components/WorktreeAgentRow.tsx @@ -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 = ( diff --git a/mobile/src/components/WorktreeListRow.test.ts b/mobile/src/components/WorktreeListRow.test.ts index d0d3f3ed7c3..e3972ab36fe 100644 --- a/mobile/src/components/WorktreeListRow.test.ts +++ b/mobile/src/components/WorktreeListRow.test.ts @@ -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( diff --git a/mobile/src/components/new-worktree-modal-types.ts b/mobile/src/components/new-worktree-modal-types.ts index 1bc89181938..e6a67981ad6 100644 --- a/mobile/src/components/new-worktree-modal-types.ts +++ b/mobile/src/components/new-worktree-modal-types.ts @@ -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 & 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) } diff --git a/mobile/src/host-screen/host-screen-overlays.test.tsx b/mobile/src/host-screen/host-screen-overlays.test.tsx index 463daf03720..7e01e1edce7 100644 --- a/mobile/src/host-screen/host-screen-overlays.test.tsx +++ b/mobile/src/host-screen/host-screen-overlays.test.tsx @@ -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', diff --git a/mobile/src/host-screen/host-screen-overlays.tsx b/mobile/src/host-screen/host-screen-overlays.tsx index f6f5fb05c06..bdcad771172 100644 --- a/mobile/src/host-screen/host-screen-overlays.tsx +++ b/mobile/src/host-screen/host-screen-overlays.tsx @@ -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." + } + : {}) } ] : [] diff --git a/mobile/src/host-screen/host-workspace-list.tsx b/mobile/src/host-screen/host-workspace-list.tsx index 99b77d66dba..076f652654d 100644 --- a/mobile/src/host-screen/host-workspace-list.tsx +++ b/mobile/src/host-screen/host-workspace-list.tsx @@ -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} diff --git a/mobile/src/tasks/mobile-tasks-create-drawers.tsx b/mobile/src/tasks/mobile-tasks-create-drawers.tsx index 1962ede97a6..c345e4c6820 100644 --- a/mobile/src/tasks/mobile-tasks-create-drawers.tsx +++ b/mobile/src/tasks/mobile-tasks-create-drawers.tsx @@ -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 diff --git a/mobile/src/tasks/mobile-tasks-github-project-list.tsx b/mobile/src/tasks/mobile-tasks-github-project-list.tsx index ac170f1b680..bf3ea262fbe 100644 --- a/mobile/src/tasks/mobile-tasks-github-project-list.tsx +++ b/mobile/src/tasks/mobile-tasks-github-project-list.tsx @@ -132,10 +132,7 @@ export function renderMobileTasksGitHubProjectList(model: ConnectionPresentation style={[ styles.repoDot, { - backgroundColor: getRepoBadgeColor( - repo ?? undefined, - row.content.repository ?? 'project' - ) + backgroundColor: getRepoBadgeColor(repo ?? undefined) } ]} /> diff --git a/mobile/src/tasks/mobile-tasks-provider-controls.tsx b/mobile/src/tasks/mobile-tasks-provider-controls.tsx index b72d7eebf2b..a5a8f0e0d8a 100644 --- a/mobile/src/tasks/mobile-tasks-provider-controls.tsx +++ b/mobile/src/tasks/mobile-tasks-provider-controls.tsx @@ -68,10 +68,7 @@ export function renderMobileTasksProviderControls(model: ConnectionPresentationM style={[ styles.segmentRepoDot, { - backgroundColor: getRepoBadgeColor( - repoPickerSelectedRepo, - repoPickerSelectedRepo.displayName - ) + backgroundColor: getRepoBadgeColor(repoPickerSelectedRepo) } ]} /> diff --git a/mobile/src/tasks/mobile-tasks-provider-pickers.tsx b/mobile/src/tasks/mobile-tasks-provider-pickers.tsx index 9e3e53248bd..e1f95b9d957 100644 --- a/mobile/src/tasks/mobile-tasks-provider-pickers.tsx +++ b/mobile/src/tasks/mobile-tasks-provider-pickers.tsx @@ -138,10 +138,7 @@ export function renderMobileTasksRepoPicker(model: ConnectionPresentationModel) toggleRepoSelection(repo.id)}> diff --git a/mobile/src/tasks/mobile-tasks-repository-presentation.ts b/mobile/src/tasks/mobile-tasks-repository-presentation.ts index a582ced65ae..28f84e31471 100644 --- a/mobile/src/tasks/mobile-tasks-repository-presentation.ts +++ b/mobile/src/tasks/mobile-tasks-repository-presentation.ts @@ -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') { diff --git a/mobile/src/tasks/mobile-tasks-workspace-create-drawer.tsx b/mobile/src/tasks/mobile-tasks-workspace-create-drawer.tsx index c7fc84b3675..522110f2609 100644 --- a/mobile/src/tasks/mobile-tasks-workspace-create-drawer.tsx +++ b/mobile/src/tasks/mobile-tasks-workspace-create-drawer.tsx @@ -79,10 +79,7 @@ export function renderMobileTasksWorkspaceCreateDrawer(model: ConnectionPresenta style={[ styles.pickerRepoDot, { - backgroundColor: getRepoBadgeColor( - workspaceCreateTargetRepo, - workspaceCreateTargetRepo.displayName - ) + backgroundColor: getRepoBadgeColor(workspaceCreateTargetRepo) } ]} /> diff --git a/mobile/src/tasks/use-mobile-tasks-picker-projection.tsx b/mobile/src/tasks/use-mobile-tasks-picker-projection.tsx index 60259c71f92..c89541940c3 100644 --- a/mobile/src/tasks/use-mobile-tasks-picker-projection.tsx +++ b/mobile/src/tasks/use-mobile-tasks-picker-projection.tsx @@ -51,12 +51,7 @@ export function useMobileTasksPickerProjection(model: DetailCommentRenderersMode label: repo.displayName, subtitle: getTaskRepoDetail(repo, repoHostLabels), renderIcon: () => ( - + ) })) : linearTeams.map((team) => ({ @@ -137,12 +132,7 @@ export function useMobileTasksPickerProjection(model: DetailCommentRenderersMode label: repo.displayName, subtitle: getTaskRepoDetail(repo, repoHostLabels), renderIcon: () => ( - + ) })), [repoHostLabels, workspaceRepos] diff --git a/mobile/src/worktree/agent-row-display.test.ts b/mobile/src/worktree/agent-row-display.test.ts index 064b4614b78..0b20726ec77 100644 --- a/mobile/src/worktree/agent-row-display.test.ts +++ b/mobile/src/worktree/agent-row-display.test.ts @@ -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') }) }) diff --git a/mobile/src/worktree/agent-row-display.ts b/mobile/src/worktree/agent-row-display.ts index 2637aa87396..19aab6c69aa 100644 --- a/mobile/src/worktree/agent-row-display.ts +++ b/mobile/src/worktree/agent-row-display.ts @@ -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 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 & + Pick & Parameters[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 = { - 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 ): 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) } diff --git a/mobile/src/worktree/folder-workspace-lane-order.test.ts b/mobile/src/worktree/folder-workspace-lane-order.test.ts new file mode 100644 index 00000000000..04be432983d --- /dev/null +++ b/mobile/src/worktree/folder-workspace-lane-order.test.ts @@ -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 { + 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' + ]) + }) +}) diff --git a/mobile/src/worktree/folder-workspace-lane-order.ts b/mobile/src/worktree/folder-workspace-lane-order.ts new file mode 100644 index 00000000000..df3d6502e0a --- /dev/null +++ b/mobile/src/worktree/folder-workspace-lane-order.ts @@ -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 }[] = [] + 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)] +} diff --git a/mobile/src/worktree/home-host-worktree-fetch.test.ts b/mobile/src/worktree/home-host-worktree-fetch.test.ts index 9f5234440a7..9e64f29bbf7 100644 --- a/mobile/src/worktree/home-host-worktree-fetch.test.ts +++ b/mobile/src/worktree/home-host-worktree-fetch.test.ts @@ -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() diff --git a/mobile/src/worktree/home-host-worktree-fetch.ts b/mobile/src/worktree/home-host-worktree-fetch.ts index 6822770fa48..314bf31e5b7 100644 --- a/mobile/src/worktree/home-host-worktree-fetch.ts +++ b/mobile/src/worktree/home-host-worktree-fetch.ts @@ -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() } diff --git a/mobile/src/worktree/home-worktree-info.ts b/mobile/src/worktree/home-worktree-info.ts index 1342ca7e195..bf83612b306 100644 --- a/mobile/src/worktree/home-worktree-info.ts +++ b/mobile/src/worktree/home-worktree-info.ts @@ -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 } diff --git a/mobile/src/worktree/repo-color.ts b/mobile/src/worktree/repo-color.ts deleted file mode 100644 index a0dd61c3937..00000000000 --- a/mobile/src/worktree/repo-color.ts +++ /dev/null @@ -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]! -} diff --git a/mobile/src/worktree/workspace-filter-repos.ts b/mobile/src/worktree/workspace-filter-repos.ts index 1c2629cd0f7..6ce5a9da17e 100644 --- a/mobile/src/worktree/workspace-filter-repos.ts +++ b/mobile/src/worktree/workspace-filter-repos.ts @@ -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): string { - return (typeof repo.badgeColor === 'string' && repo.badgeColor) || repoColor(repo.displayName) +export function sectionRepoColor(repo: Pick): string { + return resolveRepoBadgeColor(repo.badgeColor) } // List catalog repos per host; before catalogs arrive, derive candidates from workspace rows. diff --git a/mobile/src/worktree/workspace-list-ordering.ts b/mobile/src/worktree/workspace-list-ordering.ts index 6f3cd9ca975..e9c054b1ed3 100644 --- a/mobile/src/worktree/workspace-list-ordering.ts +++ b/mobile/src/worktree/workspace-list-ordering.ts @@ -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) || diff --git a/mobile/src/worktree/workspace-list-sections.test.ts b/mobile/src/worktree/workspace-list-sections.test.ts index c81adfcd4c2..03009c67e50 100644 --- a/mobile/src/worktree/workspace-list-sections.test.ts +++ b/mobile/src/worktree/workspace-list-sections.test.ts @@ -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', () => { diff --git a/mobile/src/worktree/workspace-list-sections.ts b/mobile/src/worktree/workspace-list-sections.ts index ea5f74324af..ec8677c7aa1 100644 --- a/mobile/src/worktree/workspace-list-sections.ts +++ b/mobile/src/worktree/workspace-list-sections.ts @@ -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 & { + 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) + ) + ) } } } diff --git a/mobile/src/worktree/workspace-list-stamp-order.test.ts b/mobile/src/worktree/workspace-list-stamp-order.test.ts index 77c0667dfd2..869dcdca92a 100644 --- a/mobile/src/worktree/workspace-list-stamp-order.test.ts +++ b/mobile/src/worktree/workspace-list-stamp-order.test.ts @@ -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' diff --git a/src/main/runtime/runtime-worktree-agent-rows.ts b/src/main/runtime/runtime-worktree-agent-rows.ts index ce1de144fe9..505d46b9af6 100644 --- a/src/main/runtime/runtime-worktree-agent-rows.ts +++ b/src/main/runtime/runtime-worktree-agent-rows.ts @@ -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) { diff --git a/src/main/runtime/runtime-worktree-agent-source.ts b/src/main/runtime/runtime-worktree-agent-source.ts index d38d713d87e..4671de31147 100644 --- a/src/main/runtime/runtime-worktree-agent-source.ts +++ b/src/main/runtime/runtime-worktree-agent-source.ts @@ -12,6 +12,8 @@ export type RuntimeWorktreeAgentSource = Pick { 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. diff --git a/src/main/runtime/runtime-worktree-pty-agent-sources.ts b/src/main/runtime/runtime-worktree-pty-agent-sources.ts index 7b4dddc4b2a..78696baefdc 100644 --- a/src/main/runtime/runtime-worktree-pty-agent-sources.ts +++ b/src/main/runtime/runtime-worktree-pty-agent-sources.ts @@ -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 } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index d5fee486df9..9fcb514dab2 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -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 { diff --git a/src/shared/repo-badge-color.ts b/src/shared/repo-badge-color.ts index 9b578603d01..5b2748154ff 100644 --- a/src/shared/repo-badge-color.ts +++ b/src/shared/repo-badge-color.ts @@ -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})$/ diff --git a/src/shared/runtime-worktree-contracts.ts b/src/shared/runtime-worktree-contracts.ts index 83904dd09a9..1d73c315138 100644 --- a/src/shared/runtime-worktree-contracts.ts +++ b/src/shared/runtime-worktree-contracts.ts @@ -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 } diff --git a/src/shared/sidebar-list/folder-workspace-lanes.ts b/src/shared/sidebar-list/folder-workspace-lanes.ts index 626de14d757..e4e23c7fd71 100644 --- a/src/shared/sidebar-list/folder-workspace-lanes.ts +++ b/src/shared/sidebar-list/folder-workspace-lanes.ts @@ -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 diff --git a/src/shared/sidebar-list/section-order.ts b/src/shared/sidebar-list/section-order.ts index d9444dac586..6bdbeff8918 100644 --- a/src/shared/sidebar-list/section-order.ts +++ b/src/shared/sidebar-list/section-order.ts @@ -53,7 +53,9 @@ export function getRenderedNaturalAnchorRepoIds({ return renderedRepoIds } -export function orderMainWorktreeFirst(worktrees: Worktree[]): Worktree[] { +export function orderMainWorktreeFirst( + worktrees: T[] +): T[] { const mainWorktrees = worktrees.filter((worktree) => worktree.isMainWorktree) if (mainWorktrees.length === 0) { return worktrees