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.
This commit is contained in:
Kelvin Amoaba
2026-11-04 19:30:09 +00:00
committed by GitHub
parent 0ffb87fc18
commit 18fa4f8a6d
6 changed files with 137 additions and 12 deletions
+10 -4
View File
@@ -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 (
<Pressable
accessibilityRole="button"
accessibilityLabel={[name, status, showWhere ? where : null, time].filter(Boolean).join(', ')}
accessibilityLabel={[title, status, ...details, time].filter(Boolean).join(', ')}
accessibilityState={{ disabled: !row.actionable }}
disabled={!row.actionable}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
@@ -105,11 +111,11 @@ function JumpBackInRow(props: {
</View>
<View style={[styles.text, !row.actionable && styles.rowDisabled]}>
<Text style={styles.title} numberOfLines={1}>
{name}
{title}
</Text>
<Text style={styles.subtitle} numberOfLines={1}>
<Text style={{ color: statusColor }}>{status}</Text>
{showWhere ? ` · ${where}` : ''}
{details.map((detail) => ` · ${detail}`).join('')}
</Text>
</View>
<View style={styles.trailing}>
+2 -1
View File
@@ -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]
@@ -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.
+72 -1
View File
@@ -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<HomeWorktreeSummary>): HomeWorktreeSumm
}
}
function agent(paneKey: string, fields: Partial<RuntimeWorktreeAgentRow>): 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]
])
})
})
+44 -4
View File
@@ -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<HomeAttentionWorktree, 'conversationName' | 'paneKey'> {
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<HomeWorktreeSummary, 'agents'> & { 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<{
+8 -1
View File
@@ -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