mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user