From 18fa4f8a6debbfa3ff035080e3235caa527c3d39 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:30:09 +0000 Subject: [PATCH] feat(mobile): title Jump back in rows with the conversation name (#38194) Rows named only the workspace, so with several agents in one workspace they didn't say which conversation needed you. The newest live top-level agent in the row's dot state now gives the title, the workspace moves to the subtitle, and tapping the row opens that conversation's tab. Older desktops send no names and keep the workspace title. --- mobile/src/home/MobileHomeJumpBackIn.tsx | 14 +++- mobile/src/home/MobileHomeScreen.tsx | 3 +- .../src/worktree/home-host-worktree-fetch.ts | 2 +- mobile/src/worktree/home-jump-back-in.test.ts | 73 ++++++++++++++++++- mobile/src/worktree/home-jump-back-in.ts | 48 +++++++++++- mobile/src/worktree/home-worktree-info.ts | 9 ++- 6 files changed, 137 insertions(+), 12 deletions(-) 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