Show which desktop the mobile Tasks screen is for (#30105)

* feat(mobile): name the desktop under the Tasks title

Tasks can open on any connected desktop, so the header now shows which
one, in the same subtitle line the host screen uses under its title.

* fix(mobile): reserve the Tasks desktop line so the title does not jump

The subtitle line now holds its height before the host profile is read,
a failed read leaves it blank, and the tests cover a desktop switch
while a read is in flight.

* test(mobile): re-measure the page script sweep after the Tasks subtitle

The Tasks route now shares the host screen's styles, so it emits one
script fewer (77 to 76 at its prefix, 80 to 79 for the tree). Re-swept
every route prefix; the worst route step is now 11.
This commit is contained in:
Jinwoo Hong
2026-10-17 13:20:33 -07:00
committed by GitHub
parent b9fa92bbd0
commit cf6a5446a6
6 changed files with 181 additions and 12 deletions
@@ -430,10 +430,10 @@ describe('the Phase C budget', () => {
key
).toBeGreaterThanOrEqual(measured)
}
// 1 to 12 per route, which is why four per route was a bound rather than a fit and why the
// 1 to 11 per route, which is why four per route was a bound rather than a fit and why the
// envelope cannot be a line through the measurement either.
expect(Math.min(...MOBILE_WEB_APP_BUNDLE_ROUTE_SCRIPT_SPREAD)).toBe(1)
expect(Math.max(...MOBILE_WEB_APP_BUNDLE_ROUTE_SCRIPT_SPREAD)).toBe(12)
expect(Math.max(...MOBILE_WEB_APP_BUNDLE_ROUTE_SCRIPT_SPREAD)).toBe(11)
})
it('sits exactly one margin over the swept tree and grants the worst route beyond it', () => {
@@ -505,13 +505,13 @@ describe('the Phase C budget', () => {
it('fails the build when the derived ceiling passes what the phone will accept', async () => {
// The shell hands back null for a manifest over its own ceiling, so a derived ceiling above
// that ships a green build no device can open. At the 42 images the tree carries, the envelope
// plus 42 plus the document crosses 256 at 26 routes, which Phase C reaches. The crossing came
// plus 42 plus the document crosses 256 at 27 routes, which Phase C reaches. The crossing came
// in from 50 with the envelope: it grants the worst swept route to each one past the sweep,
// where `4r + 16` granted four, so re-measuring a tree whose routes share more moves it out.
expect(await readMobileWebBundleMaxAssets()).toBe(MOBILE_WEB_BUNDLE_MAX_ASSETS)
expect(assertAssetCeilingFitsShell(25, 42, MOBILE_WEB_BUNDLE_MAX_ASSETS)).toBe(247)
expect(() => assertAssetCeilingFitsShell(26, 42, MOBILE_WEB_BUNDLE_MAX_ASSETS)).toThrow(
/259 .*256/
expect(assertAssetCeilingFitsShell(26, 42, MOBILE_WEB_BUNDLE_MAX_ASSETS)).toBe(247)
expect(() => assertAssetCeilingFitsShell(27, 42, MOBILE_WEB_BUNDLE_MAX_ASSETS)).toThrow(
/258 .*256/
)
})
})
@@ -50,9 +50,9 @@ export const MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES = 9 * 1024 * 1024
* A chunk is emitted per distinct set of importers, not per route, so a route's marginal cost is
* what it fails to share rather than what it weighs. Re-measured on this head by building
* `routes.slice(0, n)` for every n, which is what the fence below is derived from rather than
* fitted to. The spread it shows is 1 to 12: `pr` adds one script, `tasks` adds twelve.
* fitted to. The spread it shows is 1 to 11: `pr` adds one script, `tasks` adds eleven.
* The root `./_layout.tsx` (the page's web sibling of the native root) sorts first; with it the
* swept tree reads 80 scripts at 15 routes, Accounts now drawing on the page.
* swept tree reads 79 scripts at 15 routes, Accounts now drawing on the page.
*
* This table is the fence's only input, so a route added to the tree stales it and the pins beside
* the fence fail until it is re-measured. That is the point: the bound is re-derived, never bumped.
@@ -71,8 +71,8 @@ export const MOBILE_WEB_APP_BUNDLE_SCRIPT_SWEEP = [
['./h/[hostId]/review/[worktreeId].tsx', 51],
['./h/[hostId]/session/[worktreeId].tsx', 61],
['./h/[hostId]/source-control/[worktreeId].tsx', 65],
['./h/[hostId]/tasks.tsx', 77],
['./h/_layout.tsx', 80]
['./h/[hostId]/tasks.tsx', 76],
['./h/_layout.tsx', 79]
]
const sweptScripts = MOBILE_WEB_APP_BUNDLE_SCRIPT_SWEEP.map(([, scripts]) => scripts)
@@ -26,6 +26,9 @@ export const mobileTasksChromeStyles = StyleSheet.create({
},
titleWrap: {
flex: 1,
minWidth: 0
},
titleLine: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
@@ -0,0 +1,118 @@
import { createElement, Fragment } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
const reactNative = vi.hoisted(() => ({
Pressable: 'Pressable',
StyleSheet: { create: <Styles,>(styles: Styles) => styles, hairlineWidth: 1 },
Text: 'Text',
View: 'View'
}))
vi.mock('react-native', () => reactNative)
vi.mock('./mobile-tasks-dependencies', async () => ({
...reactNative,
...(await import('../theme/mobile-theme')),
ChevronLeft: 'Icon',
Plus: 'Icon',
RefreshCw: 'Icon',
StatusDot: 'StatusDot'
}))
vi.mock('./mobile-tasks-legacy-styles', async () => ({
styles: (await import('./mobile-tasks-chrome-styles')).mobileTasksChromeStyles
}))
vi.mock('./mobile-tasks-provider-controls', () => ({
renderMobileTasksProviderControls: () => null
}))
vi.mock('./mobile-tasks-search-control', () => ({ renderMobileTasksSearchControl: () => null }))
const hostStore = vi.hoisted(() => {
const hosts = [
{ id: 'studio', name: 'Studio Mac' },
{ id: 'buildbox', name: 'Build Box' }
]
const pending: (() => void)[] = []
return {
/** When set, each read waits for `resolveAll` instead of answering at once. */
deferred: false,
pending,
loadHosts(): Promise<typeof hosts> {
if (!hostStore.deferred) {
return Promise.resolve(hosts)
}
return new Promise((resolve) => pending.push(() => resolve(hosts)))
}
}
})
vi.mock('../transport/host-store', () => ({ loadHosts: () => hostStore.loadHosts() }))
import { hostScreenStyles } from '../host-screen/host-screen-styles'
import { renderMobileTasksStatusBar } from './mobile-tasks-screen-chrome'
import type { ConnectionPresentationModel } from './use-mobile-tasks-connection-presentation'
let renderer: ReactTestRenderer | null = null
function StatusBar({ hostId }: { hostId: string }) {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the status bar reads only the fields set here.
const model = {
connState: 'connected',
hostId,
router: { back: () => {} },
taskUiReady: true
} as unknown as ConnectionPresentationModel
return createElement(Fragment, null, renderMobileTasksStatusBar(model))
}
async function renderStatusBar(hostId: string): Promise<ReactTestRenderer> {
await act(async () => {
renderer = create(createElement(StatusBar, { hostId }))
})
return renderer!
}
function subtitleOf(tree: ReactTestRenderer) {
return tree.root.find(
(node) => typeof node.type === 'string' && node.props.testID === 'mobile-tasks-host-subtitle'
)
}
describe('Tasks header desktop subtitle', () => {
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
hostStore.deferred = false
hostStore.pending.length = 0
})
it('names the desktop under the Tasks title in the host screen subtitle style', async () => {
const tree = await renderStatusBar('buildbox')
const subtitle = subtitleOf(tree)
expect(subtitle.props.children).toBe('Build Box')
expect(subtitle.props.style).toContain(hostScreenStyles.hostPlatformText)
expect(subtitle.props.numberOfLines).toBe(1)
expect(subtitle.parent!.parent!.findByProps({ children: 'Tasks' })).toBeTruthy()
})
it('reserves a blank line for a desktop that is not paired', async () => {
const tree = await renderStatusBar('gone')
expect(subtitleOf(tree).props.children).toBe('')
})
it('reserves the line before the read and never shows the previous desktop after a switch', async () => {
hostStore.deferred = true
const tree = await renderStatusBar('studio')
// Reserved and blank while the first read is in flight, so the title does not move later.
expect(subtitleOf(tree).props.children).toBe('')
await act(async () => {
tree.update(createElement(StatusBar, { hostId: 'buildbox' }))
})
expect(subtitleOf(tree).props.children).toBe('')
// Studio Mac's read answers after the switch; only Build Box's may land.
await act(async () => {
hostStore.pending.shift()!()
})
expect(subtitleOf(tree).props.children).toBe('')
await act(async () => {
hostStore.pending.shift()!()
})
expect(subtitleOf(tree).props.children).toBe('Build Box')
})
})
@@ -0,0 +1,43 @@
import { useEffect, useState } from 'react'
import { StyleSheet, Text } from 'react-native'
import { hostScreenStyles } from '../host-screen/host-screen-styles'
import { loadHosts } from '../transport/host-store'
import type { HostProfile } from '../transport/types'
import { useHostDisplay } from '../transport/use-host-display'
/** The desktop these tasks belong to, in the host screen's subtitle line. */
export function MobileTasksHostSubtitle({ hostId }: { hostId: string }) {
const [host, setHost] = useState<HostProfile | null>(null)
useEffect(() => {
let stale = false
setHost(null)
loadHosts().then(
(hosts) => {
if (!stale) {
setHost(hosts.find((profile) => profile.id === hostId) ?? null)
}
},
// An unreadable profile leaves the line blank; the screen itself reports connection trouble.
() => {}
)
return () => {
stale = true
}
}, [hostId])
const title = useHostDisplay(host).title
// The line is reserved before the read so the title does not jump; it stays blank for an
// unknown host, which would otherwise show the generic "Host".
return (
<Text
style={[hostScreenStyles.hostPlatformText, styles.reserved]}
numberOfLines={1}
testID="mobile-tasks-host-subtitle"
>
{host ? title : ''}
</Text>
)
}
const styles = StyleSheet.create({
reserved: { minHeight: hostScreenStyles.hostPlatformText.lineHeight }
})
@@ -9,6 +9,7 @@ import {
RefreshCw,
Plus
} from './mobile-tasks-dependencies'
import { MobileTasksHostSubtitle } from './mobile-tasks-host-subtitle'
import { styles } from './mobile-tasks-legacy-styles'
import { renderMobileTasksProviderControls } from './mobile-tasks-provider-controls'
import { renderMobileTasksSearchControl } from './mobile-tasks-search-control'
@@ -32,6 +33,7 @@ export function renderMobileTasksStatusBar(model: ConnectionPresentationModel) {
githubMode,
githubProjectLoading,
headerVerdict,
hostId,
linearConnected,
loading,
provider,
@@ -61,8 +63,11 @@ export function renderMobileTasksStatusBar(model: ConnectionPresentationModel) {
<ChevronLeft size={22} color={colors.textPrimary} />
</Pressable>
<View style={styles.titleWrap}>
<StatusDot state={connState} verdict={headerVerdict} />
<Text style={styles.title}>Tasks</Text>
<View style={styles.titleLine}>
<StatusDot state={connState} verdict={headerVerdict} />
<Text style={styles.title}>Tasks</Text>
</View>
<MobileTasksHostSubtitle hostId={hostId} />
</View>
<Pressable
style={styles.iconButton}