diff --git a/mobile/src/home/MobileHomeJumpBackIn.tsx b/mobile/src/home/MobileHomeJumpBackIn.tsx index 0f17228bad0..90abf65404b 100644 --- a/mobile/src/home/MobileHomeJumpBackIn.tsx +++ b/mobile/src/home/MobileHomeJumpBackIn.tsx @@ -71,7 +71,9 @@ function JumpBackInRow(props: { useMobileColorScheme() const { row } = props const desktopName = useHostDisplay(props.host).title - const name = row.worktree.displayName || row.worktree.repo + const workspace = row.worktree.displayName || row.worktree.repo + const conversation = row.worktree.conversationName + const title = conversation ?? workspace const labelState = LABEL_STATE[row.worktree.dotStatus] const status = row.lastOpened ? row.actionable @@ -82,6 +84,10 @@ function JumpBackInRow(props: { : '' const where = props.nameDesktop ? desktopName : row.worktree.repo const showWhere = Boolean(where) && (row.actionable || !row.lastOpened) + // A named conversation takes the title and moves the workspace to the subtitle (#38007). + const details = [...new Set([conversation ? workspace : null, showWhere ? where : null])].filter( + Boolean + ) const statusColor = !row.lastOpened && TINTED_STATUSES.has(row.worktree.dotStatus) ? agentStatusColors()[row.worktree.dotStatus] @@ -94,7 +100,7 @@ function JumpBackInRow(props: { return ( [styles.row, pressed && styles.rowPressed]} @@ -105,11 +111,11 @@ function JumpBackInRow(props: { - {name} + {title} {status} - {showWhere ? ` · ${where}` : ''} + {details.map((detail) => ` · ${detail}`).join('')} diff --git a/mobile/src/home/MobileHomeScreen.tsx b/mobile/src/home/MobileHomeScreen.tsx index 5ec97de9080..5851657764e 100644 --- a/mobile/src/home/MobileHomeScreen.tsx +++ b/mobile/src/home/MobileHomeScreen.tsx @@ -64,7 +64,8 @@ export function MobileHomeScreen() { openMobileSession({ hostId: row.hostId, worktreeId: row.worktree.worktreeId, - name: row.worktree.displayName || row.worktree.repo + name: row.worktree.displayName || row.worktree.repo, + paneKey: row.worktree.paneKey }) }, [data.router, openMobileSession] diff --git a/mobile/src/worktree/home-host-worktree-fetch.ts b/mobile/src/worktree/home-host-worktree-fetch.ts index 841098f576a..80a05679af4 100644 --- a/mobile/src/worktree/home-host-worktree-fetch.ts +++ b/mobile/src/worktree/home-host-worktree-fetch.ts @@ -58,7 +58,7 @@ export function fetchHomeHostWorktreeInfo( markUnavailable() return } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the rows stay opaque because three screens project a row differently. Home reads `status`, `sidebarStatus` 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}`. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the rows stay opaque because three screens project a row differently. Home reads `status`, `sidebarStatus` and the resume pick through their own guards, plus the host-typed `agents` that name Jump back in rows, and the `worktree-home-catalog` golden records the row it is given as `{worktreeId, displayName, repo, status}`. const worktrees = rows as HomeWorktreeSummary[] setCachedWorktrees(hostId, worktrees, { proven: true }) // Count what the list shows: archived rows are hidden, and active is the sleeping filter's rule. diff --git a/mobile/src/worktree/home-jump-back-in.test.ts b/mobile/src/worktree/home-jump-back-in.test.ts index 9f4dafd5142..1b83a5269a4 100644 --- a/mobile/src/worktree/home-jump-back-in.test.ts +++ b/mobile/src/worktree/home-jump-back-in.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types' import { selectHomeAttentionWorktrees, selectHomeJumpBackInRows } from './home-jump-back-in' import type { HomeWorktreeSummary, HostWorktreeInfo } from './home-worktree-info' @@ -15,6 +16,25 @@ function row(id: string, fields: Partial): HomeWorktreeSumm } } +function agent(paneKey: string, fields: Partial): RuntimeWorktreeAgentRow { + return { + paneKey, + parentPaneKey: null, + state: 'working', + agentType: 'claude', + prompt: '', + taskTitle: null, + displayName: null, + lastAssistantMessage: null, + toolName: null, + toolInput: null, + interrupted: false, + stateStartedAt: NOW, + updatedAt: NOW, + ...fields + } +} + function info(hostId: string, rows: HomeWorktreeSummary[]): HostWorktreeInfo { return { hostId, @@ -27,6 +47,10 @@ function info(hostId: string, rows: HomeWorktreeSummary[]): HostWorktreeInfo { } describe('home jump back in', () => { + afterEach(() => { + vi.useRealTimers() + }) + it('puts agents waiting on the user first, then unseen finished work, then running work', () => { const rows = selectHomeAttentionWorktrees([ row('running', { sidebarStatus: 'working', lastOutputAt: NOW }), @@ -83,4 +107,51 @@ describe('home jump back in', () => { ['resume', false] ]) }) + + it('names a row after the newest live top-level conversation in the dot state, else none', () => { + vi.useFakeTimers({ now: NOW }) + const rows = selectHomeAttentionWorktrees([ + row('blocked', { + sidebarStatus: 'permission', + agents: [ + agent('older', { + state: 'waiting', + conversationName: 'Older question', + updatedAt: NOW - 9 + }), + agent('newer', { state: 'blocked', conversationName: 'Fix login', updatedAt: NOW - 5 }), + agent('running', { state: 'working', conversationName: 'Refactor', updatedAt: NOW }), + agent('worker', { + parentPaneKey: 'running', + state: 'waiting', + conversationName: 'Worker', + updatedAt: NOW + }) + ] + }), + row('unnamed', { + sidebarStatus: 'working', + agents: [agent('a', { state: 'working' })] + }), + row('stale', { + sidebarStatus: 'working', + agents: [ + agent('s', { + state: 'working', + conversationName: 'Old task', + updatedAt: NOW - 31 * 60_000 + }) + ] + }), + row('old-host', { sidebarStatus: 'done', unread: true }) + ]) + expect( + rows.map((worktree) => [worktree.worktreeId, worktree.conversationName, worktree.paneKey]) + ).toEqual([ + ['blocked', 'Fix login', 'newer'], + ['old-host', undefined, undefined], + ['unnamed', undefined, undefined], + ['stale', undefined, undefined] + ]) + }) }) diff --git a/mobile/src/worktree/home-jump-back-in.ts b/mobile/src/worktree/home-jump-back-in.ts index 9f2a503d36b..8fba16d5afb 100644 --- a/mobile/src/worktree/home-jump-back-in.ts +++ b/mobile/src/worktree/home-jump-back-in.ts @@ -1,4 +1,11 @@ -import type { WorktreeStatus } from '../../../src/shared/worktree-status-rollup' +import { isAgentStatusFresh } from '../../../src/shared/agent-status/contracts/agent-status-freshness' +import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types' +import { + applyAgentPaneActivityFlags, + createAgentPaneActivityFlags, + rankWorktreeStatus, + type WorktreeStatus +} from '../../../src/shared/worktree-status-rollup' import type { ConnectionState } from '../transport/types' import type { HomeResumeCard } from './home-resume-card' import type { @@ -6,6 +13,7 @@ import type { HomeWorktreeSummary, HostWorktreeInfo } from './home-worktree-info' +import { buildAgentRowLineageTree } from './agent-row-lineage' import { getWorktreeDotStatus } from './workspace-list-ordering' const ATTENTION_ROWS_PER_HOST = 6 @@ -32,11 +40,39 @@ function byRankThenRecent( return a.rank - b.rank || (b.lastOutputAt ?? 0) - (a.lastOutputAt ?? 0) } +function agentDotStatus(agent: RuntimeWorktreeAgentRow): WorktreeStatus { + const flags = createAgentPaneActivityFlags() + applyAgentPaneActivityFlags(flags, agent) + return rankWorktreeStatus('inactive', flags) +} + +// The newest top-level agent the host counted in the dot's state names the row; a nameless one keeps the workspace title. +function conversationBehindDot( + agents: readonly RuntimeWorktreeAgentRow[] | undefined, + dotStatus: WorktreeStatus, + now: number +): Pick { + let newest: RuntimeWorktreeAgentRow | undefined + for (const agent of buildAgentRowLineageTree(agents ?? []).rootRows) { + if ( + isAgentStatusFresh(agent, now) && + agentDotStatus(agent) === dotStatus && + (!newest || agent.updatedAt > newest.updatedAt) + ) { + newest = agent + } + } + const conversationName = newest?.conversationName?.trim() + return newest && conversationName ? { conversationName, paneKey: newest.paneKey } : {} +} + /** The rows of one desktop's list that "Jump back in" may show, using the workspace list's dot status. */ export function selectHomeAttentionWorktrees( rows: readonly HomeWorktreeSummary[] ): HomeAttentionWorktree[] { - const ranked: (HomeAttentionWorktree & { rank: number })[] = [] + const now = Date.now() + const ranked: (HomeAttentionWorktree & Pick & { rank: number })[] = + [] for (const row of rows) { if (row.isArchived === true) { continue @@ -54,13 +90,17 @@ export function selectHomeAttentionWorktrees( branch: row.branch, displayName: row.displayName, lastOutputAt: typeof row.lastOutputAt === 'number' ? row.lastOutputAt : undefined, - workingMode: row.workingMode + workingMode: row.workingMode, + agents: row.agents }) } return ranked .sort(byRankThenRecent) .slice(0, ATTENTION_ROWS_PER_HOST) - .map(({ rank: _rank, ...worktree }) => worktree) + .map(({ rank: _rank, agents, ...worktree }) => ({ + ...worktree, + ...conversationBehindDot(agents, worktree.dotStatus, now) + })) } export type HomeJumpBackInRow = Readonly<{ diff --git a/mobile/src/worktree/home-worktree-info.ts b/mobile/src/worktree/home-worktree-info.ts index 130288f6296..22610f63e5a 100644 --- a/mobile/src/worktree/home-worktree-info.ts +++ b/mobile/src/worktree/home-worktree-info.ts @@ -1,4 +1,5 @@ import type { AgentWorkingMode } from '../../../src/shared/agent-status/contracts/agent-status-types' +import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types' import type { WorktreeStatus } from '../../../src/shared/worktree-status-rollup' export type HomeWorktreeSummary = { @@ -15,13 +16,19 @@ export type HomeWorktreeSummary = { lastOutputAt?: number sidebarStatus?: string workingMode?: AgentWorkingMode + agents?: RuntimeWorktreeAgentRow[] } /** A workspace whose agents Home surfaces under "Jump back in", with the list's dot status. */ export type HomeAttentionWorktree = Pick< HomeWorktreeSummary, 'worktreeId' | 'repo' | 'branch' | 'displayName' | 'lastOutputAt' | 'workingMode' -> & { dotStatus: WorktreeStatus } +> & { + dotStatus: WorktreeStatus + /** The conversation behind the dot and its pane; older hosts send no names. */ + conversationName?: string + paneKey?: string +} export type HostWorktreeInfo = { hostId: string