diff --git a/config/scripts/build-mobile-web-app-bundle.test.mjs b/config/scripts/build-mobile-web-app-bundle.test.mjs index 17df43aaa4d..f003a79e081 100644 --- a/config/scripts/build-mobile-web-app-bundle.test.mjs +++ b/config/scripts/build-mobile-web-app-bundle.test.mjs @@ -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/ ) }) }) diff --git a/config/scripts/verify-mobile-web-app-bundle.mjs b/config/scripts/verify-mobile-web-app-bundle.mjs index f3fa416b8e9..db9b39115c0 100644 --- a/config/scripts/verify-mobile-web-app-bundle.mjs +++ b/config/scripts/verify-mobile-web-app-bundle.mjs @@ -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) diff --git a/mobile/src/tasks/mobile-tasks-chrome-styles.ts b/mobile/src/tasks/mobile-tasks-chrome-styles.ts index a29865cb463..ae530a7e3f1 100644 --- a/mobile/src/tasks/mobile-tasks-chrome-styles.ts +++ b/mobile/src/tasks/mobile-tasks-chrome-styles.ts @@ -26,6 +26,9 @@ export const mobileTasksChromeStyles = StyleSheet.create({ }, titleWrap: { flex: 1, + minWidth: 0 + }, + titleLine: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, diff --git a/mobile/src/tasks/mobile-tasks-host-subtitle.test.tsx b/mobile/src/tasks/mobile-tasks-host-subtitle.test.tsx new file mode 100644 index 00000000000..438165bb10d --- /dev/null +++ b/mobile/src/tasks/mobile-tasks-host-subtitle.test.tsx @@ -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, 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 { + 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 { + 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') + }) +}) diff --git a/mobile/src/tasks/mobile-tasks-host-subtitle.tsx b/mobile/src/tasks/mobile-tasks-host-subtitle.tsx new file mode 100644 index 00000000000..375c9b34f79 --- /dev/null +++ b/mobile/src/tasks/mobile-tasks-host-subtitle.tsx @@ -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(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 ( + + {host ? title : ''} + + ) +} + +const styles = StyleSheet.create({ + reserved: { minHeight: hostScreenStyles.hostPlatformText.lineHeight } +}) diff --git a/mobile/src/tasks/mobile-tasks-screen-chrome.tsx b/mobile/src/tasks/mobile-tasks-screen-chrome.tsx index da12b97087e..4d6c583ea04 100644 --- a/mobile/src/tasks/mobile-tasks-screen-chrome.tsx +++ b/mobile/src/tasks/mobile-tasks-screen-chrome.tsx @@ -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) { - - Tasks + + + Tasks + +