mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 10:27:33 +00:00
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:
@@ -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}>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user