mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 00:03:15 +00:00
Merge branch 'fix2-native-a-merge' into mobile-rearch
This commit is contained in:
@@ -30,7 +30,7 @@ import {
|
||||
MOBILE_NATIVE_BASELINE_MODE
|
||||
} from '../src/mobile-web/mobile-native-baseline-mode'
|
||||
import { mobileHostWorkspaceEntry } from '../src/mobile-web/mobile-web-home-navigation'
|
||||
import { loadHosts } from '../src/transport/host-store'
|
||||
import { loadHostCatalog, loadHosts } from '../src/transport/host-store'
|
||||
import { extractPairingCodeFromUrl } from '../src/transport/pairing'
|
||||
import { recoverMobileRelayPairing } from '../src/transport/mobile-relay-pairing-recovery'
|
||||
|
||||
@@ -171,7 +171,7 @@ export default function RootLayout() {
|
||||
}
|
||||
|
||||
async function getNavigation(data: unknown) {
|
||||
return notificationNavigationResolverRef.current!.resolve(data, loadHosts)
|
||||
return notificationNavigationResolverRef.current!.resolve(data, loadHostCatalog)
|
||||
}
|
||||
|
||||
async function handleNotificationResponse(response: Notifications.NotificationResponse) {
|
||||
|
||||
@@ -177,6 +177,12 @@ export function MobileBrowserPane({
|
||||
focused: addressFocused,
|
||||
url: tab.url
|
||||
})
|
||||
// The screencast `navigation` event is a newer-host refinement; the tab props stay
|
||||
// the baseline so an older host that never emits it does not freeze Back/Forward.
|
||||
useEffect(() => {
|
||||
setNavigationState({ canGoBack: tab.canGoBack, canGoForward: tab.canGoForward })
|
||||
}, [tab.canGoBack, tab.canGoForward])
|
||||
|
||||
useEffect(() => {
|
||||
if (addressSync.nextState === addressSyncState) {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { BrowserScreencastFrame } from '../transport/browser-screencast-protocol'
|
||||
import { MobileBrowserPane, type MobileBrowserTab } from './MobileBrowserPane'
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
AppState: { currentState: 'active', addEventListener: () => ({ remove: () => {} }) },
|
||||
Image: 'Image',
|
||||
PanResponder: { create: () => ({ panHandlers: {} }) },
|
||||
PixelRatio: { get: () => 2 },
|
||||
Platform: { OS: 'android' },
|
||||
Pressable: 'Pressable',
|
||||
StyleSheet: {
|
||||
absoluteFillObject: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 },
|
||||
create: (styles: unknown) => styles
|
||||
},
|
||||
Text: 'Text',
|
||||
TextInput: 'TextInput',
|
||||
View: 'View'
|
||||
}))
|
||||
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
ArrowUp: 'ArrowUp',
|
||||
ChevronLeft: 'ChevronLeft',
|
||||
ChevronRight: 'ChevronRight',
|
||||
Monitor: 'Monitor',
|
||||
RefreshCw: 'RefreshCw',
|
||||
Smartphone: 'Smartphone'
|
||||
}))
|
||||
|
||||
let pageCounter = 0
|
||||
|
||||
function browserTab(navigation: { canGoBack: boolean; canGoForward: boolean }): MobileBrowserTab {
|
||||
return {
|
||||
type: 'browser',
|
||||
id: `tab-${pageCounter}`,
|
||||
title: 'Dashboard',
|
||||
browserWorkspaceId: 'bw-1',
|
||||
browserPageId: `page-${pageCounter}`,
|
||||
url: 'https://dashboard.example',
|
||||
loading: false,
|
||||
isActive: true,
|
||||
...navigation
|
||||
}
|
||||
}
|
||||
|
||||
function backButtonDisabled(renderer: ReactTestRenderer): boolean {
|
||||
const back = renderer.root
|
||||
.findAllByType('Pressable')
|
||||
.find((node) => node.props.accessibilityLabel === 'Back')
|
||||
if (!back) {
|
||||
throw new Error('Back control not found')
|
||||
}
|
||||
return back.props.disabled === true
|
||||
}
|
||||
|
||||
async function renderPane(navigation: { canGoBack: boolean; canGoForward: boolean }): Promise<{
|
||||
renderer: ReactTestRenderer
|
||||
emit: (payload: unknown) => void
|
||||
update: (next: { canGoBack: boolean; canGoForward: boolean }) => Promise<void>
|
||||
}> {
|
||||
pageCounter += 1
|
||||
let listener: ((payload: unknown) => void) | null = null
|
||||
const operations = {
|
||||
subscribe: (
|
||||
_target: unknown,
|
||||
_request: unknown,
|
||||
handlers: {
|
||||
onEvent: (payload: unknown) => void
|
||||
onFrame?: (frame: BrowserScreencastFrame) => void
|
||||
}
|
||||
) => {
|
||||
listener = handlers.onEvent
|
||||
return () => {}
|
||||
},
|
||||
request: vi.fn()
|
||||
}
|
||||
const worktreeId = `wt-${pageCounter}`
|
||||
const props = (tab: MobileBrowserTab) => ({
|
||||
operations,
|
||||
worktreeId,
|
||||
tab,
|
||||
screencastSupported: true,
|
||||
keyboardLift: 0,
|
||||
bottomInset: 0,
|
||||
onToast: () => {}
|
||||
})
|
||||
|
||||
let renderer: ReactTestRenderer
|
||||
await act(async () => {
|
||||
renderer = create(createElement(MobileBrowserPane, props(browserTab(navigation))), {
|
||||
createNodeMock: () => ({ setNativeProps: () => {} })
|
||||
})
|
||||
await Promise.resolve()
|
||||
})
|
||||
const mounted: ReactTestRenderer = renderer!
|
||||
const viewport = mounted.root
|
||||
.findAllByType('View')
|
||||
.find((node) => typeof node.props.onLayout === 'function')
|
||||
if (!viewport) {
|
||||
throw new Error('Viewport with onLayout not found')
|
||||
}
|
||||
act(() => {
|
||||
viewport.props.onLayout({ nativeEvent: { layout: { width: 360, height: 640 } } })
|
||||
})
|
||||
if (!listener) {
|
||||
throw new Error('browser.screencast subscription not created')
|
||||
}
|
||||
return {
|
||||
renderer: mounted,
|
||||
emit: (payload) => act(() => listener?.(payload)),
|
||||
update: async (next) => {
|
||||
await act(async () => {
|
||||
mounted.update(createElement(MobileBrowserPane, props(browserTab(next))))
|
||||
await Promise.resolve()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('MobileBrowserPane navigation enablement', () => {
|
||||
it('keeps Back enabled from the tab props against a host that never reports navigation', async () => {
|
||||
const { renderer, emit } = await renderPane({ canGoBack: true, canGoForward: false })
|
||||
|
||||
expect(backButtonDisabled(renderer)).toBe(false)
|
||||
|
||||
// An older host emits `ready` without the navigation flags and no `navigation` event.
|
||||
emit({ type: 'ready', tab: { url: 'https://dashboard.example' } })
|
||||
|
||||
expect(backButtonDisabled(renderer)).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts a newer host navigation event as a refinement between tab updates', async () => {
|
||||
const { renderer, emit } = await renderPane({ canGoBack: true, canGoForward: false })
|
||||
|
||||
emit({
|
||||
type: 'navigation',
|
||||
tab: { url: 'https://dashboard.example/start', canGoBack: false, canGoForward: false }
|
||||
})
|
||||
|
||||
expect(backButtonDisabled(renderer)).toBe(true)
|
||||
})
|
||||
|
||||
it('follows the tab props when an older host republishes navigability', async () => {
|
||||
const { renderer, emit, update } = await renderPane({ canGoBack: false, canGoForward: false })
|
||||
|
||||
expect(backButtonDisabled(renderer)).toBe(true)
|
||||
emit({ type: 'ready', tab: { url: 'https://dashboard.example' } })
|
||||
|
||||
// Without a `navigation` event, the republished tab is the only signal there is.
|
||||
await update({ canGoBack: true, canGoForward: false })
|
||||
expect(backButtonDisabled(renderer)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -240,10 +240,16 @@ export function useMobileBrowserStream(args: MobileBrowserStreamArgs) {
|
||||
return
|
||||
}
|
||||
const event = payload as ScreencastEvent
|
||||
if ((event.type === 'ready' || event.type === 'navigation') && event.tab) {
|
||||
// Only a payload that actually carries the flags may override the tab props;
|
||||
// an older host's `ready` omits them and would pin both arrows off.
|
||||
if (
|
||||
(event.type === 'ready' || event.type === 'navigation') &&
|
||||
typeof event.tab?.canGoBack === 'boolean' &&
|
||||
typeof event.tab.canGoForward === 'boolean'
|
||||
) {
|
||||
setNavigationState?.({
|
||||
canGoBack: event.tab.canGoBack === true,
|
||||
canGoForward: event.tab.canGoForward === true
|
||||
canGoBack: event.tab.canGoBack,
|
||||
canGoForward: event.tab.canGoForward
|
||||
})
|
||||
}
|
||||
handleBrowserScreencastEvent({
|
||||
|
||||
@@ -6,8 +6,9 @@ export function MobileSyntaxSegments({ segments }: { segments: MobileSyntaxSegme
|
||||
let sourceOffset = 0
|
||||
return (
|
||||
<>
|
||||
{segments.map((segment) => {
|
||||
const key = `${sourceOffset}:${segment.kind}`
|
||||
{segments.map((segment, index) => {
|
||||
// The index keeps two adjacent zero-length segments of the same kind distinct.
|
||||
const key = `${index}:${sourceOffset}:${segment.kind}`
|
||||
sourceOffset += segment.text.length
|
||||
return (
|
||||
<Text key={key} style={[webSyntaxTextStyle, syntaxTokenStyles[segment.kind]]}>
|
||||
|
||||
@@ -254,8 +254,9 @@ function parseInlineSafely(text: string): InlineToken[] {
|
||||
|
||||
function keyedInlineTokens(tokens: InlineToken[]): { key: string; token: InlineToken }[] {
|
||||
let sourceOffset = 0
|
||||
return tokens.map((token) => {
|
||||
const key = `${sourceOffset}:${token.kind}`
|
||||
// The index keeps two adjacent zero-length tokens of the same kind distinct.
|
||||
return tokens.map((token, index) => {
|
||||
const key = `${index}:${sourceOffset}:${token.kind}`
|
||||
sourceOffset += token.text.length
|
||||
return { key, token }
|
||||
})
|
||||
|
||||
@@ -65,6 +65,12 @@ export function MobileFileExplorerPanel(props: {
|
||||
: null),
|
||||
[forceReconnect, hostId, nativeHost.client, operationsProp]
|
||||
)
|
||||
// Why: `operations` is null in exactly the disconnected state Retry exists for
|
||||
// (no client), so the revive path cannot hang off it (#5049).
|
||||
const reconnect = useCallback(
|
||||
() => (operations ? operations.reconnect() : forceReconnect(hostId)),
|
||||
[forceReconnect, hostId, operations]
|
||||
)
|
||||
const connState = connectionState ?? nativeHost.state
|
||||
const scopeRef = useRef('')
|
||||
const scope = `${hostId}:${worktreeId}`
|
||||
@@ -233,12 +239,12 @@ export function MobileFileExplorerPanel(props: {
|
||||
(relativePath: string) => {
|
||||
if (connState !== 'connected' && hostId) {
|
||||
pendingDirectoryRetriesRef.current.add(relativePath)
|
||||
void operations?.reconnect()
|
||||
void reconnect()
|
||||
return
|
||||
}
|
||||
void loadDirectory(relativePath)
|
||||
},
|
||||
[connState, hostId, loadDirectory, operations]
|
||||
[connState, hostId, loadDirectory, reconnect]
|
||||
)
|
||||
|
||||
const previewFile = useCallback(
|
||||
@@ -316,9 +322,7 @@ export function MobileFileExplorerPanel(props: {
|
||||
<Pressable
|
||||
style={styles.retryButton}
|
||||
onPress={() =>
|
||||
connState !== 'connected' && hostId
|
||||
? void operations?.reconnect()
|
||||
: void loadDirectory('')
|
||||
connState !== 'connected' && hostId ? void reconnect() : void loadDirectory('')
|
||||
}
|
||||
>
|
||||
<Text style={styles.retryText}>Retry</Text>
|
||||
|
||||
@@ -54,6 +54,16 @@ export function MobileFilePreviewScreen({
|
||||
: null),
|
||||
[forceReconnect, nativeHost.client, operationsProp, previewHostId]
|
||||
)
|
||||
// Why: `operations` is null in exactly the disconnected state Retry exists for.
|
||||
const reconnect = useCallback(
|
||||
() =>
|
||||
operations
|
||||
? operations.reconnect()
|
||||
: previewHostId
|
||||
? forceReconnect(previewHostId)
|
||||
: Promise.resolve(),
|
||||
[forceReconnect, operations, previewHostId]
|
||||
)
|
||||
const connState = connectionState ?? nativeHost.state
|
||||
const handleOpenExternalUrl = useCallback(
|
||||
(url: string) => {
|
||||
@@ -221,11 +231,11 @@ export function MobileFilePreviewScreen({
|
||||
(preview.status === 'error' && preview.reconnect) ||
|
||||
connState !== 'connected'
|
||||
) {
|
||||
await operations?.reconnect()
|
||||
await reconnect()
|
||||
return
|
||||
}
|
||||
void loadPreview()
|
||||
}, [connState, loadPreview, operations, preview, previewParams])
|
||||
}, [connState, loadPreview, preview, previewParams, reconnect])
|
||||
|
||||
const displayPath =
|
||||
previewParams?.source === 'terminalArtifact'
|
||||
|
||||
@@ -113,7 +113,8 @@ export async function executeMobileWebNativeChatTerminalOperation(args: {
|
||||
args.client,
|
||||
binding.hostTerminalId!,
|
||||
String.fromCharCode(27),
|
||||
true,
|
||||
// Escape must not carry Return: the extra newline submits the agent's input line.
|
||||
false,
|
||||
args.terminalClientId,
|
||||
payload.deadline,
|
||||
false,
|
||||
|
||||
@@ -167,12 +167,17 @@ it('revalidates opaque GitHub Project mutation targets before every write', asyn
|
||||
expect(
|
||||
sendRequest.mock.calls.filter(([method]) => method === 'github.project.viewTable')
|
||||
).toHaveLength(18)
|
||||
expect(sendRequest).toHaveBeenCalledWith('github.mergePR', {
|
||||
repo: `id:host-repo-private`,
|
||||
prNumber: 8,
|
||||
method: 'squash',
|
||||
prRepo: { owner: 'stablyai', repo: 'orca', host: 'github.com' }
|
||||
})
|
||||
expect(sendRequest).toHaveBeenCalledWith(
|
||||
'github.mergePR',
|
||||
{
|
||||
repo: `id:host-repo-private`,
|
||||
prNumber: 8,
|
||||
method: 'squash',
|
||||
prRepo: { owner: 'stablyai', repo: 'orca', host: 'github.com' }
|
||||
},
|
||||
// A merge routinely outruns the 30s default.
|
||||
{ timeoutMs: 60_000 }
|
||||
)
|
||||
expect(sendRequest).toHaveBeenCalledWith(
|
||||
'github.prChecks',
|
||||
{
|
||||
|
||||
@@ -196,4 +196,15 @@ describe('notification route coordination', () => {
|
||||
// A bare push into the nested host route lands on a blank host screen (#12001).
|
||||
expect(notificationEffect).not.toContain('mobileHomeDestination(')
|
||||
})
|
||||
|
||||
it('validates the tap against the full host catalog, not just token-backed hosts', () => {
|
||||
const start = rootLayoutSource.indexOf('// ─── Notification tap routing ───')
|
||||
const end = rootLayoutSource.indexOf('// ─── End notification tap routing ───', start)
|
||||
const notificationEffect = rootLayoutSource.slice(start, end)
|
||||
|
||||
// loadHosts() omits any host whose keychain read failed, which both kills the tap
|
||||
// (unknown host id) and hides the credential-recovery status.
|
||||
expect(notificationEffect).toContain('resolve(data, loadHostCatalog)')
|
||||
expect(notificationEffect).not.toContain('resolve(data, loadHosts)')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,7 +4,8 @@ import {
|
||||
getNotificationNavigationPath,
|
||||
getNotificationNavigationTarget,
|
||||
LatestNotificationNavigationResolver,
|
||||
resolveNotificationNavigation
|
||||
resolveNotificationNavigation,
|
||||
type NotificationKnownHost
|
||||
} from './notification-routing'
|
||||
|
||||
describe('notification routing', () => {
|
||||
@@ -113,7 +114,7 @@ describe('notification routing', () => {
|
||||
await expect(
|
||||
resolveNotificationNavigation(
|
||||
{ hostId: 'host-1', worktreeId: 'repo::/tmp/worktree' },
|
||||
async () => [{ id: 'host-1' }]
|
||||
async () => [{ id: 'host-1', credentialStatus: 'ready' as const }]
|
||||
)
|
||||
).resolves.toEqual({
|
||||
target: {
|
||||
@@ -125,9 +126,22 @@ describe('notification routing', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('routes a host with unreadable credentials to recovery instead of dropping the tap', async () => {
|
||||
await expect(
|
||||
resolveNotificationNavigation({ hostId: 'host-1', worktreeId: 'workspace-one' }, async () => [
|
||||
{ id: 'host-1', credentialStatus: 'temporarily-unavailable' as const }
|
||||
])
|
||||
).resolves.toMatchObject({ target: { credentialRecovery: 'retry' } })
|
||||
await expect(
|
||||
resolveNotificationNavigation({ hostId: 'host-1' }, async () => [
|
||||
{ id: 'host-1', credentialStatus: 'missing' as const }
|
||||
])
|
||||
).resolves.toMatchObject({ target: { credentialRecovery: 're-pair' } })
|
||||
})
|
||||
|
||||
it('suppresses an older tap whose paired-host read finishes after a newer tap', async () => {
|
||||
const firstHosts = deferred<readonly { id: string }[]>()
|
||||
const secondHosts = deferred<readonly { id: string }[]>()
|
||||
const firstHosts = deferred<readonly NotificationKnownHost[]>()
|
||||
const secondHosts = deferred<readonly NotificationKnownHost[]>()
|
||||
const resolver = new LatestNotificationNavigationResolver()
|
||||
const first = resolver.resolve(
|
||||
{ hostId: 'host-1', worktreeId: 'workspace-one' },
|
||||
@@ -138,11 +152,11 @@ describe('notification routing', () => {
|
||||
() => secondHosts.promise
|
||||
)
|
||||
|
||||
secondHosts.resolve([{ id: 'host-1' }])
|
||||
secondHosts.resolve([{ id: 'host-1', credentialStatus: 'ready' }])
|
||||
await expect(second).resolves.toMatchObject({
|
||||
target: { kind: 'session', hostWorkspaceId: 'workspace-two' }
|
||||
})
|
||||
firstHosts.resolve([{ id: 'host-1' }])
|
||||
firstHosts.resolve([{ id: 'host-1', credentialStatus: 'ready' }])
|
||||
await expect(first).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -71,11 +71,16 @@ export function getNotificationNavigationPath(
|
||||
return getNotificationNavigationTargetPath(target)
|
||||
}
|
||||
|
||||
// Why: the credential status must be required, not optional — a loader that only knows
|
||||
// device-token-backed hosts (loadHosts) silently drops unreadable ones from knownHostIds
|
||||
// and kills the tap instead of routing it to credential recovery.
|
||||
export type NotificationKnownHost = { id: string; credentialStatus: HostCredentialStatus }
|
||||
|
||||
export async function resolveNotificationNavigation(
|
||||
data: unknown,
|
||||
loadKnownHosts: () => Promise<readonly { id: string; credentialStatus?: HostCredentialStatus }[]>
|
||||
loadKnownHosts: () => Promise<readonly NotificationKnownHost[]>
|
||||
): Promise<NotificationNavigation | null> {
|
||||
let hosts: readonly { id: string; credentialStatus?: HostCredentialStatus }[]
|
||||
let hosts: readonly NotificationKnownHost[]
|
||||
try {
|
||||
hosts = await loadKnownHosts()
|
||||
} catch {
|
||||
@@ -98,7 +103,7 @@ export class LatestNotificationNavigationResolver {
|
||||
|
||||
async resolve(
|
||||
data: unknown,
|
||||
loadKnownHosts: () => Promise<readonly { id: string }[]>
|
||||
loadKnownHosts: () => Promise<readonly NotificationKnownHost[]>
|
||||
): Promise<NotificationNavigation | null> {
|
||||
const sequence = ++this.latestSequence
|
||||
const navigation = await resolveNotificationNavigation(data, loadKnownHosts)
|
||||
|
||||
@@ -196,9 +196,9 @@ export function MobileSessionCommandDock({ controller }: { controller: MobileSes
|
||||
if (!key.repeatable) {
|
||||
return
|
||||
}
|
||||
const input = createTerminalLiveAccessoryInput(key)
|
||||
void handleAccessoryKey(input)
|
||||
startAccessoryRepeat(input)
|
||||
// startAccessoryRepeat owns the press-time send; sending here too
|
||||
// emits the key twice per tap.
|
||||
startAccessoryRepeat(createTerminalLiveAccessoryInput(key))
|
||||
}}
|
||||
onPressOut={() => {
|
||||
if (key.repeatable) {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const commandDockSource = readFileSync(
|
||||
new URL('./MobileSessionCommandDock.tsx', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
function repeatablePressInBlock(): string {
|
||||
const start = commandDockSource.indexOf('onPressIn={() => {')
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
const end = commandDockSource.indexOf('onPressOut={', start)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
return commandDockSource.slice(start, end)
|
||||
}
|
||||
|
||||
describe('accessory key press send ownership', () => {
|
||||
// startAccessoryRepeat's controller sends at press time and then repeats; a second
|
||||
// press-time send in the dock emitted every arrow/Backspace/Tab tap twice.
|
||||
it('gives the repeat controller sole ownership of the press-time send', () => {
|
||||
const block = repeatablePressInBlock()
|
||||
|
||||
expect(block).toContain('startAccessoryRepeat(createTerminalLiveAccessoryInput(key))')
|
||||
expect(block).not.toContain('handleAccessoryKey(')
|
||||
})
|
||||
|
||||
it('still sends non-repeatable keys once on release', () => {
|
||||
expect(commandDockSource).toContain(
|
||||
'void handleAccessoryKey(createTerminalLiveAccessoryInput(key))'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -52,10 +52,14 @@ function extractMutationError(error: unknown, method: string): string {
|
||||
async function sendGithubPrMutation(
|
||||
client: Pick<RpcClient, 'sendRequest'>,
|
||||
method: string,
|
||||
params: Record<string, unknown>
|
||||
params: Record<string, unknown>,
|
||||
options?: { timeoutMs?: number }
|
||||
): Promise<GitHubPrMutationOutcome> {
|
||||
try {
|
||||
const response = await client.sendRequest(method, params)
|
||||
// Keep the two-argument call shape when no timeout is requested.
|
||||
const response = options
|
||||
? await client.sendRequest(method, params, options)
|
||||
: await client.sendRequest(method, params)
|
||||
if (!response.ok) {
|
||||
return { ok: false, error: response.error?.message || `Request failed: ${method}` }
|
||||
}
|
||||
@@ -79,7 +83,8 @@ async function sendGithubPrMutation(
|
||||
export async function fetchMergePR(
|
||||
client: Pick<RpcClient, 'sendRequest'>,
|
||||
worktreeId: string,
|
||||
args: { prNumber: number; method?: GitHubPRMergeMethod; prRepo?: GitHubPrRepoSlug | null }
|
||||
args: { prNumber: number; method?: GitHubPRMergeMethod; prRepo?: GitHubPrRepoSlug | null },
|
||||
options?: { timeoutMs?: number }
|
||||
): Promise<GitHubPrMutationOutcome> {
|
||||
const params: Record<string, unknown> = { prNumber: args.prNumber }
|
||||
if (args.method) {
|
||||
@@ -88,7 +93,8 @@ export async function fetchMergePR(
|
||||
return sendGithubPrMutation(
|
||||
client,
|
||||
'github.mergePR',
|
||||
buildGithubPrParams('github.mergePR', worktreeId, params, { prRepo: args.prRepo })
|
||||
buildGithubPrParams('github.mergePR', worktreeId, params, { prRepo: args.prRepo }),
|
||||
options
|
||||
)
|
||||
}
|
||||
|
||||
@@ -312,7 +318,8 @@ export async function fetchRerunPRChecks(
|
||||
headSha?: string | null
|
||||
failedOnly?: boolean
|
||||
prRepo?: GitHubPrRepoSlug | null
|
||||
}
|
||||
},
|
||||
options?: { timeoutMs?: number }
|
||||
): Promise<GitHubPrMutationOutcome> {
|
||||
const params: Record<string, unknown> = { prNumber: args.prNumber }
|
||||
if (args.failedOnly !== undefined) {
|
||||
@@ -324,6 +331,7 @@ export async function fetchRerunPRChecks(
|
||||
return sendGithubPrMutation(
|
||||
client,
|
||||
'github.rerunPRChecks',
|
||||
buildGithubPrParams('github.rerunPRChecks', worktreeId, params, { prRepo: args.prRepo })
|
||||
buildGithubPrParams('github.rerunPRChecks', worktreeId, params, { prRepo: args.prRepo }),
|
||||
options
|
||||
)
|
||||
}
|
||||
|
||||
@@ -113,6 +113,87 @@ describe('openMobileFileTap', () => {
|
||||
expect(switchSessionTab).toHaveBeenCalledWith(openedTab)
|
||||
})
|
||||
|
||||
it('opens a sibling-workspace path through the resolved owning workspace', async () => {
|
||||
const operations = createOperations([
|
||||
{
|
||||
kind: 'worktree-file' as const,
|
||||
relativePath: 'docs/readme.md',
|
||||
localAbsolutePath: '/repo-b/docs/readme.md',
|
||||
workspaceId: 'wt-2'
|
||||
}
|
||||
])
|
||||
const pushPreviewRoute = vi.fn()
|
||||
|
||||
openMobileTerminalFileTap({
|
||||
operations,
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
worktreeName: 'workspace one',
|
||||
pathText: '/repo-b/docs/readme.md',
|
||||
terminalHandle: 'terminal-1',
|
||||
line: null,
|
||||
column: null,
|
||||
pushPreviewRoute,
|
||||
openBrowser: vi.fn(),
|
||||
triggerOpenFeedback: vi.fn(),
|
||||
fetchSessionTabs: vi.fn(),
|
||||
getSessionTabs: () => [],
|
||||
getActiveSessionTabId: () => null,
|
||||
getActivationState: activeTerminalState,
|
||||
switchSessionTab: vi.fn(),
|
||||
scheduleDelayedAction: vi.fn()
|
||||
})
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
// Opening it in this session's workspace would hit a same-named file or nothing.
|
||||
expect(pushPreviewRoute).toHaveBeenCalledWith({
|
||||
pathname: '/h/[hostId]/files/preview/[worktreeId]',
|
||||
params: expect.objectContaining({
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-2',
|
||||
source: 'worktree',
|
||||
relativePath: 'docs/readme.md'
|
||||
})
|
||||
})
|
||||
expect(pushPreviewRoute.mock.calls[0]?.[0].params).not.toHaveProperty('worktreeName')
|
||||
expect(operations.openWorktreeFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('addresses files.open at the workspace the host resolved', async () => {
|
||||
const operations = createOperations([
|
||||
{
|
||||
kind: 'worktree-file' as const,
|
||||
relativePath: 'src/index.ts',
|
||||
localAbsolutePath: '/repo/src/index.ts',
|
||||
workspaceId: 'wt-1'
|
||||
}
|
||||
])
|
||||
|
||||
openMobileTerminalFileTap({
|
||||
operations,
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
pathText: 'src/index.ts',
|
||||
terminalHandle: 'terminal-1',
|
||||
line: null,
|
||||
column: null,
|
||||
pushPreviewRoute: vi.fn(),
|
||||
openBrowser: vi.fn(),
|
||||
triggerOpenFeedback: vi.fn(),
|
||||
fetchSessionTabs: vi.fn(),
|
||||
getSessionTabs: () => [],
|
||||
getActiveSessionTabId: () => 'terminal-tab',
|
||||
getActivationState: activeTerminalState,
|
||||
switchSessionTab: vi.fn(),
|
||||
scheduleDelayedAction: vi.fn()
|
||||
})
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(operations.openWorktreeFile).toHaveBeenCalledWith('wt-1', 'src/index.ts')
|
||||
})
|
||||
|
||||
it('opens the file when optional haptic feedback is unavailable', async () => {
|
||||
const operations = createOperations([worktreeTarget('README.md', '/repo/README.md')])
|
||||
|
||||
|
||||
@@ -132,7 +132,13 @@ async function openMobileFileTapAsync<T extends FileTapSessionTab>(
|
||||
|
||||
const openedPath = resolved.relativePath
|
||||
triggerMobileTerminalOpenFeedback(options.triggerOpenFeedback)
|
||||
if (options.line !== null || options.column !== null) {
|
||||
// A sibling-workspace hit has no tab in this session to open into, so it must go
|
||||
// to the preview route addressed at the workspace that actually holds the file.
|
||||
if (
|
||||
resolvedWorktreeId !== options.worktreeId ||
|
||||
options.line !== null ||
|
||||
options.column !== null
|
||||
) {
|
||||
options.pushPreviewRoute(
|
||||
createMobileFilePreviewHref({
|
||||
hostId: options.hostId,
|
||||
@@ -151,7 +157,7 @@ async function openMobileFileTapAsync<T extends FileTapSessionTab>(
|
||||
options.openBrowser(filesystemPathToFileUri(resolved.localAbsolutePath))
|
||||
return
|
||||
}
|
||||
await options.operations.openWorktreeFile(options.worktreeId, openedPath)
|
||||
await options.operations.openWorktreeFile(resolvedWorktreeId, openedPath)
|
||||
scheduleOpenedWorktreeTabActivation(options, openedPath)
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@ export type MobileNativeChatTab = {
|
||||
|
||||
export type MobileNativeChatAgentStatusWithProvider = MobileWebNativeChatAgentStatus & {
|
||||
model?: string
|
||||
/** Host flag marking `lastAssistantMessage` as tool output rather than a reply. */
|
||||
lastAssistantMessageIsToolOutput?: boolean
|
||||
providerSession?: {
|
||||
id: string
|
||||
transcriptPath?: string
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import {
|
||||
createMobileNativeChatStreamingGate,
|
||||
deriveMobileNativeChatStreaming,
|
||||
mobileNativeChatStreamPreview,
|
||||
type MobileNativeChatStreamingGate
|
||||
} from './mobile-native-chat-streaming-gate'
|
||||
|
||||
@@ -33,6 +34,40 @@ function run(ticks: { folded: NativeChatMessage[]; text?: string; live?: boolean
|
||||
return { gate, results }
|
||||
}
|
||||
|
||||
describe('mobileNativeChatStreamPreview', () => {
|
||||
it('drops a preview the provider flagged as tool output', () => {
|
||||
// Regression: a Bash result was published as `lastAssistantMessage` for the status
|
||||
// card, then rendered here as an un-collapsed assistant bubble that no catch-up rule
|
||||
// could retire, so it sat in the chat for the rest of the turn.
|
||||
expect(
|
||||
mobileNativeChatStreamPreview(
|
||||
{
|
||||
lastAssistantMessage: 'Exit code 1\nimport { Foo }',
|
||||
lastAssistantMessageIsToolOutput: true
|
||||
},
|
||||
true
|
||||
)
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('passes assistant prose through while working', () => {
|
||||
expect(mobileNativeChatStreamPreview({ lastAssistantMessage: 'Working on it' }, true)).toBe(
|
||||
'Working on it'
|
||||
)
|
||||
})
|
||||
|
||||
it('drops any preview once the turn is not working', () => {
|
||||
expect(
|
||||
mobileNativeChatStreamPreview({ lastAssistantMessage: 'Working on it' }, false)
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('tolerates a missing status', () => {
|
||||
expect(mobileNativeChatStreamPreview(null, true)).toBeUndefined()
|
||||
expect(mobileNativeChatStreamPreview(undefined, true)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveMobileNativeChatStreaming', () => {
|
||||
it('shows a genuine reply that repeats the previous turn as a prefix', () => {
|
||||
const prior = [assistant('a1', 'The tests pass.')]
|
||||
|
||||
@@ -18,6 +18,22 @@ export type MobileNativeChatStreamingGate = {
|
||||
baselineTailId: string | null
|
||||
}
|
||||
|
||||
/** The live status line doubles as a tool-output mirror: when the host marks the
|
||||
* last assistant message as tool stdout/stderr there is no reply to preview, and
|
||||
* rendering it paints raw command output as a streaming chat bubble. */
|
||||
export function mobileNativeChatStreamPreview(
|
||||
status:
|
||||
| { lastAssistantMessage?: string; lastAssistantMessageIsToolOutput?: boolean }
|
||||
| null
|
||||
| undefined,
|
||||
working: boolean
|
||||
): string | undefined {
|
||||
if (!working || status?.lastAssistantMessageIsToolOutput === true) {
|
||||
return undefined
|
||||
}
|
||||
return status?.lastAssistantMessage
|
||||
}
|
||||
|
||||
export function createMobileNativeChatStreamingGate(
|
||||
scopeKey: string | null = null
|
||||
): MobileNativeChatStreamingGate {
|
||||
|
||||
@@ -84,7 +84,10 @@ const HEAD_TIMER_CREATION_SHA256 =
|
||||
const HEAD_TIMER_CLEANUP_SHA256 = '8a45ae3c8a01a639a40ffaf3c0fc89a2e0b610623306818c86bad4ef9195b824'
|
||||
const HEAD_RUNTIME_STRING_SHA256 =
|
||||
'77fce1bf3cd5c150255a191a107569b11d334da95d3cb47459189965f57f901b'
|
||||
const HEAD_HOST_JSX_SHA256 = '2911efcb57dbc9f6de1f2a7b3ed6ca4fa8a9735d48649fe062cb400df756e1ce'
|
||||
// Re-frozen when the repeatable accessory key's press handler dropped its duplicate
|
||||
// handleAccessoryKey call: startAccessoryRepeat already sends at press time, so every
|
||||
// tap emitted the key twice. Same element count, one attribute body changed.
|
||||
const HEAD_HOST_JSX_SHA256 = '5b6acbcb34eaa59aa0020f7d6337ccbeb40b3195ff0d798911d9c035d49042fa'
|
||||
const HEAD_LEAF_JSX_SHA256 = '7551bacf163f59c150cc8a9150c443df9804a882365f459053d3ab73ac557f42'
|
||||
const HEAD_STYLE_REFERENCE_SHA256 =
|
||||
'3e4f57e5c8691d443187ffe306eae28506d5505276ea3de7a4f2f1df1cfa3885'
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { HostSessionNativeChatTarget } from './host-session-native-chat-operations'
|
||||
import { nativeHostSessionNativeChatOperations } from './native-host-session-native-chat-operations'
|
||||
|
||||
function target(overrides: Partial<HostSessionNativeChatTarget> = {}): HostSessionNativeChatTarget {
|
||||
return {
|
||||
workspaceId: 'wt-1',
|
||||
agent: 'claude',
|
||||
sessionId: 'session-1',
|
||||
transcriptPath: null,
|
||||
terminalId: 'terminal-1',
|
||||
clientId: 'device-1',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function client(sendRequest: RpcClient['sendRequest']): RpcClient {
|
||||
return { sendRequest } as unknown as RpcClient
|
||||
}
|
||||
|
||||
describe('native host session native chat operations', () => {
|
||||
it('stops the agent with a bare Escape that cannot submit the input line', async () => {
|
||||
const sendRequest = vi.fn<RpcClient['sendRequest']>().mockResolvedValue({
|
||||
ok: true,
|
||||
result: { delivered: true }
|
||||
})
|
||||
const operations = nativeHostSessionNativeChatOperations(client(sendRequest))
|
||||
|
||||
await operations.stop(target(), Date.now() + 15_000)
|
||||
|
||||
expect(sendRequest).toHaveBeenCalledWith(
|
||||
'terminal.send',
|
||||
expect.objectContaining({ text: String.fromCharCode(27), enter: false }),
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the legacy file inventory scoped to the workspace that produced it', async () => {
|
||||
const sendRequest = vi.fn<RpcClient['sendRequest']>(async (method, params) => {
|
||||
if (method === 'files.searchPaths') {
|
||||
return { ok: false, error: { code: 'method_not_found', message: 'unsupported' } }
|
||||
}
|
||||
const worktree = (params as { worktree: string }).worktree
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
files:
|
||||
worktree === 'id:wt-1'
|
||||
? [{ relativePath: 'alpha/one.ts' }]
|
||||
: [{ relativePath: 'beta/two.ts' }]
|
||||
}
|
||||
}
|
||||
})
|
||||
const operations = nativeHostSessionNativeChatOperations(client(sendRequest))
|
||||
|
||||
await expect(operations.searchFiles(target(), 'o')).resolves.toEqual(['alpha/one.ts'])
|
||||
// A second workspace must re-read; the first workspace's inventory is not its own.
|
||||
await expect(operations.searchFiles(target({ workspaceId: 'wt-2' }), 'o')).resolves.toEqual([
|
||||
'beta/two.ts'
|
||||
])
|
||||
// The first workspace still answers from its cached inventory.
|
||||
await expect(operations.searchFiles(target(), 'o')).resolves.toEqual(['alpha/one.ts'])
|
||||
expect(sendRequest.mock.calls.filter(([method]) => method === 'files.list')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -22,8 +22,10 @@ export function nativeHostSessionNativeChatOperations(
|
||||
client: RpcClient
|
||||
): HostSessionNativeChatOperations {
|
||||
let searchSupported: boolean | null = null
|
||||
let legacyPaths: string[] | null = null
|
||||
let legacyLoad: Promise<string[] | null> | null = null
|
||||
// Why: the legacy full-inventory fallback is per workspace — one shared list makes
|
||||
// `@` autocomplete in a second workspace suggest the first workspace's files.
|
||||
const legacyPathsByWorkspace = new Map<string, string[]>()
|
||||
const legacyLoadByWorkspace = new Map<string, Promise<string[] | null>>()
|
||||
return {
|
||||
async readability(workspaceId) {
|
||||
if (isFloatingWorkspaceWorktreeId(workspaceId)) {
|
||||
@@ -92,7 +94,9 @@ export function nativeHostSessionNativeChatOperations(
|
||||
return sendNative(target, text, enter, client, deadline)
|
||||
},
|
||||
stop(target, deadline) {
|
||||
return sendNative(target, escape(), true, client, deadline)
|
||||
// Escape must not carry Return: the extra newline submits whatever the agent
|
||||
// had parked on its input line.
|
||||
return sendNative(target, escape(), false, client, deadline)
|
||||
},
|
||||
async searchFiles(target, query) {
|
||||
if (searchSupported !== false) {
|
||||
@@ -110,22 +114,28 @@ export function nativeHostSessionNativeChatOperations(
|
||||
}
|
||||
searchSupported = false
|
||||
}
|
||||
let legacyPaths = legacyPathsByWorkspace.get(target.workspaceId)
|
||||
if (!legacyPaths) {
|
||||
let legacyLoad = legacyLoadByWorkspace.get(target.workspaceId)
|
||||
if (!legacyLoad) {
|
||||
// Older hosts expose only the full inventory RPC; overlapping queries must
|
||||
// share one slow local/SSH read.
|
||||
legacyLoad = client
|
||||
.sendRequest('files.list', {
|
||||
worktree: `id:${target.workspaceId}`
|
||||
})
|
||||
.then((response) => (response.ok ? extractPaths(response.result) : null))
|
||||
.finally(() => {
|
||||
legacyLoad = null
|
||||
legacyLoadByWorkspace.delete(target.workspaceId)
|
||||
})
|
||||
legacyLoadByWorkspace.set(target.workspaceId, legacyLoad)
|
||||
}
|
||||
const paths = await legacyLoad
|
||||
if (!paths) {
|
||||
return []
|
||||
}
|
||||
legacyPaths = paths
|
||||
legacyPathsByWorkspace.set(target.workspaceId, paths)
|
||||
}
|
||||
return rankSuggestions(legacyPaths, query, FILE_RESULT_LIMIT)
|
||||
},
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useMobileNativeChatTarget } from './use-mobile-native-chat-target'
|
||||
import { useNativeChatAcceptedAction } from './use-native-chat-action-outcomes'
|
||||
import { useThrottledLatestValue } from './use-throttled-latest-value'
|
||||
import { isMobileNativeChatAgentWorking } from './mobile-native-chat-working-state'
|
||||
import { mobileNativeChatStreamPreview } from './mobile-native-chat-streaming-gate'
|
||||
import type { HostSessionChatDraftOperations } from './host-session-chat-draft-operations'
|
||||
import type { HostSessionChatPendingDeliveryOperations } from './host-session-chat-pending-delivery-operations'
|
||||
import type { MobileNativeChatController } from './mobile-native-chat-controller-contract'
|
||||
@@ -159,9 +160,9 @@ export function useMobileNativeChatController(args: {
|
||||
// Throttle the streaming bubble: OpenCode emits a status frame per streamed
|
||||
// part, and each one re-renders and re-parses the whole accumulated markdown.
|
||||
const nativeChatStreamingText = useThrottledLatestValue(
|
||||
nativeChatAgentWorking && !activeChatStructured
|
||||
? nativeChatStatus?.lastAssistantMessage
|
||||
: undefined,
|
||||
activeChatStructured
|
||||
? undefined
|
||||
: mobileNativeChatStreamPreview(nativeChatStatus, nativeChatAgentWorking),
|
||||
NATIVE_CHAT_STREAM_THROTTLE_MS
|
||||
)
|
||||
const {
|
||||
|
||||
@@ -340,6 +340,39 @@ describe('useMobileNativeChatDrafts', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('normalizes a multi-line prompt so its transcript echo can retire it', async () => {
|
||||
const prompt = 'first line\nsecond line'
|
||||
// The transcript records the prompt with whitespace runs collapsed.
|
||||
const echo = userTextMessage('m1', 'first line second line')
|
||||
await mount('a')
|
||||
// An identical earlier turn must be counted, or the pending retires against it.
|
||||
await act(async () =>
|
||||
renderer?.update(createElement(Harness, { tabId: 'a', messages: [echo] }))
|
||||
)
|
||||
expect(state?.captureSendOrigin(prompt)).toMatchObject({
|
||||
normalizedText: 'first line second line',
|
||||
baselineOccurrences: 1
|
||||
})
|
||||
|
||||
const origin = state?.captureSendOrigin(prompt)
|
||||
act(() => {
|
||||
if (origin) {
|
||||
state?.acceptSend(origin, prompt)
|
||||
}
|
||||
})
|
||||
expect(state?.pending.map((pending) => pending.text)).toEqual([prompt])
|
||||
|
||||
await act(async () =>
|
||||
renderer?.update(
|
||||
createElement(Harness, {
|
||||
tabId: 'a',
|
||||
messages: [echo, userTextMessage('m2', 'first line second line')]
|
||||
})
|
||||
)
|
||||
)
|
||||
expect(state?.pending).toEqual([])
|
||||
})
|
||||
|
||||
it('clears one pending per landed message so duplicate sends are not all dropped', async () => {
|
||||
await mount('a')
|
||||
const origin = state?.captureSendOrigin('ping')
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { HostSessionChatDraftOperations } from './host-session-chat-draft-o
|
||||
import type { HostSessionChatPendingDeliveryOperations } from './host-session-chat-pending-delivery-operations'
|
||||
import {
|
||||
findLandedUnconfirmedSends,
|
||||
normalizeReconcileText,
|
||||
type UnconfirmedSend
|
||||
} from './mobile-native-chat-draft-reconcile'
|
||||
import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key'
|
||||
@@ -149,7 +150,9 @@ export function useMobileNativeChatDrafts(args: {
|
||||
? {
|
||||
draftKey,
|
||||
draftEditGeneration: draftEditGenerationsRef.current.readDraft(draftKey),
|
||||
...capturePendingOrigin(text.trim())
|
||||
// Transcript rows are compared fully normalized (ANSI stripped, whitespace
|
||||
// runs collapsed); a bare trim leaves every multi-line prompt unmatchable.
|
||||
...capturePendingOrigin(normalizeReconcileText(text))
|
||||
}
|
||||
: null,
|
||||
[capturePendingOrigin, draftKey]
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
countUserTextOccurrences,
|
||||
findLandedImagePreviewEchoes,
|
||||
mergeLandedImagePreviewEchoes,
|
||||
migrateImagePreviewMessageIds
|
||||
migrateImagePreviewMessageIds,
|
||||
normalizeReconcileText
|
||||
} from './mobile-native-chat-draft-reconcile'
|
||||
import { rebaseMobileNativeChatPendingBaselines } from './mobile-native-chat-pending-baseline'
|
||||
import { retireLandedMobileNativeChatPending } from './mobile-native-chat-pending-retirement'
|
||||
@@ -192,7 +193,7 @@ export function useMobileNativeChatPendingDeliveries(args: {
|
||||
const current = pendingBySessionRef.current[storageKey] ?? NO_PENDING_MESSAGES
|
||||
const earlierOutstanding = current.filter(
|
||||
(pending) =>
|
||||
pending.text.trim() === origin.normalizedText &&
|
||||
normalizeReconcileText(pending.text) === origin.normalizedText &&
|
||||
pending.expectedOccurrence > origin.baselineOccurrences
|
||||
).length
|
||||
const expectedImageEchoOrdinal =
|
||||
|
||||
@@ -36,12 +36,18 @@ const hash = (parts: string[] | string): string =>
|
||||
*
|
||||
* Declarations dropped from 196 to 195 when projectRowGitHubRepository was
|
||||
* deleted: it carried over from the monolith with no caller in either shape.
|
||||
*
|
||||
* Re-frozen when the monolith's `nameIsAutoManaged` computation was restored to the
|
||||
* workspace-create action (it had been dropped, so a typed workspace name was
|
||||
* discarded) and `toggleGitHubProjectFieldVisibility` went back to the monolith's
|
||||
* functional state updater. Both move the composition toward the baseline, not away:
|
||||
* one added statement, one changed callback body, no hook or declaration count change.
|
||||
*/
|
||||
const SCREEN_HOOKS = '8ba3974a4c4c26bd04aa22cb0e59757c98ebda4a91ab6b7081215c5e8710d0b3'
|
||||
const SCREEN_HOOKS = 'c5026e3f5633d36eae56f0492c5b239896f0b6d363079be1568de6460d7eae30'
|
||||
const DIFF_HOOKS = '93c7189b32bed8456cc51814fffa8ce80cf62011ef968a9d53ddec2b9686f58f'
|
||||
const STATEMENTS = '02ef31a6b6a30748c41e485dbac8fb4e4433e887ecdbd2db5442a81c31bdf244'
|
||||
const STATEMENTS = '300258f651ed5aec07966cd4a382597ee21b381764aaa6222d18ce35b2600dd3'
|
||||
const DECLARATIONS = '11ddb68df1bdde8ee3299bb391de14765e6860b8406d0c17f837223e3667bd13'
|
||||
const SEMANTICS = '541a22531e77209f9c9fea32b49cb0539a8e1bd2e6b74ca7e5e1de0642f028c9'
|
||||
const SEMANTICS = '990736b2b2b450230fca06bb27ed97903d9827c59ad7e4c7be442a0d854f5750'
|
||||
const STYLES = '1db6af69c791d9963928541ad5310942fcbda6d984b422c90b6eb92b6816579a'
|
||||
const RENDER_TREE = '92596eb283232607d8c2df3f09ba970232c7df496555c6f59e0c7160a00501af'
|
||||
|
||||
@@ -70,7 +76,7 @@ describe('Mobile Tasks refactor parity', () => {
|
||||
|
||||
it('preserves RPC calls, runtime strings, and JSX host signatures', () => {
|
||||
const semantics = readMobileTasksSemanticSource()
|
||||
expect(semantics.split('\n')).toHaveLength(3_236)
|
||||
expect(semantics.split('\n')).toHaveLength(3_237)
|
||||
expect(hash(semantics)).toBe(SEMANTICS)
|
||||
})
|
||||
|
||||
|
||||
@@ -3,13 +3,17 @@ import type { RpcClient } from '../transport/rpc-client'
|
||||
|
||||
export function nativeHostTaskItemFileOperations(client: RpcClient): HostTaskItemFileOperations {
|
||||
return {
|
||||
refreshChecks(target, headSha) {
|
||||
return request(client, 'github.prChecks', {
|
||||
async refreshChecks(target, headSha) {
|
||||
const checks = await request(client, 'github.prChecks', {
|
||||
...repoPayload(target),
|
||||
prNumber: target.number,
|
||||
headSha,
|
||||
noCache: true
|
||||
})
|
||||
if (!Array.isArray(checks)) {
|
||||
throw new Error('Invalid checks response')
|
||||
}
|
||||
return checks
|
||||
},
|
||||
async rerunChecks(target, headSha, failedOnly) {
|
||||
const result = await request<{ ok?: boolean; error?: string }>(
|
||||
|
||||
@@ -41,7 +41,11 @@ export function nativeHostTaskListOperations(client: RpcClient): HostTaskListOpe
|
||||
)
|
||||
},
|
||||
async listGitLabTodos(repoId) {
|
||||
return successfulResult(client.sendRequest('gitlab.todos', { repo: `id:${repoId}` }))
|
||||
// An empty todo list comes back as null from older hosts; the seam promises an array.
|
||||
const todos = await successfulResult(
|
||||
client.sendRequest('gitlab.todos', { repo: `id:${repoId}` })
|
||||
)
|
||||
return Array.isArray(todos) ? todos : []
|
||||
},
|
||||
async listLinear(payload) {
|
||||
const response = payload.query
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { nativeHostTaskItemFileOperations } from './native-host-task-item-file-operations'
|
||||
import { nativeHostTaskListOperations } from './native-host-task-list-operations'
|
||||
import { nativeHostTaskProjectFileOperations } from './native-host-task-project-file-operations'
|
||||
import { nativeHostTaskProjectMutationOperations } from './native-host-task-project-mutation-operations'
|
||||
|
||||
function client(sendRequest: RpcClient['sendRequest']): RpcClient {
|
||||
return { sendRequest } as unknown as RpcClient
|
||||
}
|
||||
|
||||
const itemTarget = { repoId: 'repo-1', number: 7 }
|
||||
const projectTarget = { number: 7, slug: { owner: 'orca', repo: 'orca' }, type: 'pr' as const }
|
||||
|
||||
describe('native host task operation contracts', () => {
|
||||
it('rejects a non-array checks payload instead of crashing the checks list', async () => {
|
||||
const sendRequest = vi
|
||||
.fn<RpcClient['sendRequest']>()
|
||||
.mockResolvedValue({ ok: true, result: { error: 'rate limited' } })
|
||||
|
||||
await expect(
|
||||
nativeHostTaskItemFileOperations(client(sendRequest)).refreshChecks(itemTarget, 'sha')
|
||||
).rejects.toThrow('Invalid checks response')
|
||||
await expect(
|
||||
nativeHostTaskProjectFileOperations(client(sendRequest)).refreshChecks(
|
||||
projectTarget,
|
||||
'repo-1',
|
||||
'sha'
|
||||
)
|
||||
).rejects.toThrow('Invalid checks response')
|
||||
})
|
||||
|
||||
it('reads an absent GitLab todo list as empty rather than an error banner', async () => {
|
||||
const sendRequest = vi
|
||||
.fn<RpcClient['sendRequest']>()
|
||||
.mockResolvedValue({ ok: true, result: null })
|
||||
|
||||
await expect(
|
||||
nativeHostTaskListOperations(client(sendRequest)).listGitLabTodos('repo-1')
|
||||
).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('gives project merge and rerun the long timeout their item-level twins use', async () => {
|
||||
const sendRequest = vi.fn<RpcClient['sendRequest']>().mockResolvedValue({
|
||||
ok: true,
|
||||
result: { ok: true }
|
||||
})
|
||||
const operations = nativeHostTaskProjectMutationOperations(client(sendRequest))
|
||||
|
||||
await operations.rerunChecks(projectTarget, 'repo-1', { failedOnly: true })
|
||||
await operations.merge(projectTarget, 'repo-1', 'squash')
|
||||
|
||||
for (const call of sendRequest.mock.calls) {
|
||||
expect(call[2]).toEqual({ timeoutMs: 60_000 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -7,12 +7,16 @@ export function nativeHostTaskProjectFileOperations(
|
||||
): HostTaskProjectFileOperations {
|
||||
return {
|
||||
async refreshChecks(target, repoId, headSha) {
|
||||
return request(client, 'github.prChecks', {
|
||||
const checks = await request(client, 'github.prChecks', {
|
||||
...repoPayload(target, repoId),
|
||||
prNumber: target.number,
|
||||
headSha,
|
||||
noCache: true
|
||||
})
|
||||
if (!Array.isArray(checks)) {
|
||||
throw new Error('Invalid checks response')
|
||||
}
|
||||
return checks
|
||||
},
|
||||
async setFileViewed(target, repoId, payload) {
|
||||
const result = await request(client, 'github.setPRFileViewed', {
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
fetchResolveReviewThread
|
||||
} from '../session/github-pr-mutations'
|
||||
|
||||
const PROJECT_PR_MUTATION_TIMEOUT_MS = 60_000
|
||||
|
||||
export function nativeHostTaskProjectMutationOperations(
|
||||
client: RpcClient
|
||||
): HostTaskProjectMutationOperations {
|
||||
@@ -116,20 +118,23 @@ export function nativeHostTaskProjectMutationOperations(
|
||||
},
|
||||
async rerunChecks(target, repoId, payload) {
|
||||
requirePrMutation(
|
||||
await fetchRerunPRChecks(client, repoId, {
|
||||
prNumber: target.number,
|
||||
...payload,
|
||||
prRepo: slugPayload(target)
|
||||
})
|
||||
await fetchRerunPRChecks(
|
||||
client,
|
||||
repoId,
|
||||
{ prNumber: target.number, ...payload, prRepo: slugPayload(target) },
|
||||
// A CI rerun and a merge both routinely outrun the 30s default.
|
||||
{ timeoutMs: PROJECT_PR_MUTATION_TIMEOUT_MS }
|
||||
)
|
||||
)
|
||||
},
|
||||
async merge(target, repoId, method) {
|
||||
requirePrMutation(
|
||||
await fetchMergePR(client, repoId, {
|
||||
prNumber: target.number,
|
||||
method,
|
||||
prRepo: slugPayload(target)
|
||||
})
|
||||
await fetchMergePR(
|
||||
client,
|
||||
repoId,
|
||||
{ prNumber: target.number, method, prRepo: slugPayload(target) },
|
||||
{ timeoutMs: PROJECT_PR_MUTATION_TIMEOUT_MS }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const createActionsSource = readFileSync(
|
||||
new URL('./use-mobile-tasks-workspace-create-actions.tsx', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
describe('task workspace create name authority', () => {
|
||||
// Without this flag the host defaults nameIsAutoManaged to true and drops the typed
|
||||
// name, publishing the generated one with displayNameKind 'generated'.
|
||||
it('threads the typed-name decision into the create call', () => {
|
||||
expect(createActionsSource).toContain(
|
||||
'const nameIsAutoManaged =\n !trimmedWorkspaceName || trimmedWorkspaceName === workspaceLastAutoName'
|
||||
)
|
||||
expect(createActionsSource).toContain('nameIsAutoManaged,')
|
||||
})
|
||||
|
||||
it('keeps the last auto-generated name in scope and in the callback deps', () => {
|
||||
const deps = createActionsSource.slice(createActionsSource.lastIndexOf(' ['))
|
||||
expect(deps).toContain('workspaceLastAutoName')
|
||||
})
|
||||
})
|
||||
@@ -12,7 +12,6 @@ export function useMobileTasksClientSettingsActions(model: ProjectRepositoryReso
|
||||
const {
|
||||
defaultRepoSelectionRef,
|
||||
githubProjectFieldVisibilityScope,
|
||||
githubProjectHiddenFieldIdsByView,
|
||||
repoSelectionHydratedRef,
|
||||
setDefaultGitHubPreset,
|
||||
setGithubCurrentPage,
|
||||
@@ -104,24 +103,25 @@ export function useMobileTasksClientSettingsActions(model: ProjectRepositoryReso
|
||||
if (!githubProjectFieldVisibilityScope) {
|
||||
return
|
||||
}
|
||||
const hidden = new Set(
|
||||
githubProjectHiddenFieldIdsByView[githubProjectFieldVisibilityScope] ?? []
|
||||
)
|
||||
if (hidden.has(fieldId)) {
|
||||
hidden.delete(fieldId)
|
||||
} else {
|
||||
hidden.add(fieldId)
|
||||
}
|
||||
const next = { ...githubProjectHiddenFieldIdsByView }
|
||||
if (hidden.size === 0) {
|
||||
delete next[githubProjectFieldVisibilityScope]
|
||||
} else {
|
||||
next[githubProjectFieldVisibilityScope] = [...hidden]
|
||||
}
|
||||
setGithubProjectHiddenFieldIdsByView(next)
|
||||
persistTaskResumeState({ githubProjectHiddenFieldIdsByView: next })
|
||||
// Why: two toggles in one batch both read render scope and the first is lost.
|
||||
setGithubProjectHiddenFieldIdsByView((current) => {
|
||||
const hidden = new Set(current[githubProjectFieldVisibilityScope] ?? [])
|
||||
if (hidden.has(fieldId)) {
|
||||
hidden.delete(fieldId)
|
||||
} else {
|
||||
hidden.add(fieldId)
|
||||
}
|
||||
const next = { ...current }
|
||||
if (hidden.size === 0) {
|
||||
delete next[githubProjectFieldVisibilityScope]
|
||||
} else {
|
||||
next[githubProjectFieldVisibilityScope] = [...hidden]
|
||||
}
|
||||
persistTaskResumeState({ githubProjectHiddenFieldIdsByView: next })
|
||||
return next
|
||||
})
|
||||
},
|
||||
[githubProjectFieldVisibilityScope, githubProjectHiddenFieldIdsByView, persistTaskResumeState]
|
||||
[githubProjectFieldVisibilityScope, persistTaskResumeState]
|
||||
)
|
||||
const persistTaskSource = useCallback(
|
||||
(nextProvider: TaskProvider) => {
|
||||
|
||||
@@ -33,7 +33,8 @@ export function useMobileTasksWorkspaceCreateActions(model: WorkspaceSshStateMod
|
||||
taskWorkspaceCreationOperations,
|
||||
tasksSupported,
|
||||
trustedOrcaHooks,
|
||||
workspaceDetectedAgentIds
|
||||
workspaceDetectedAgentIds,
|
||||
workspaceLastAutoName
|
||||
} = model
|
||||
const createWorkspace = useCallback(
|
||||
async (
|
||||
@@ -139,6 +140,10 @@ export function useMobileTasksWorkspaceCreateActions(model: WorkspaceSshStateMod
|
||||
})
|
||||
return
|
||||
}
|
||||
const trimmedWorkspaceName = workspaceNameOverride?.trim() ?? ''
|
||||
// A typed name that still matches the generated one stays auto-managed.
|
||||
const nameIsAutoManaged =
|
||||
!trimmedWorkspaceName || trimmedWorkspaceName === workspaceLastAutoName
|
||||
let selection: MobileComposerCreateSelection
|
||||
if (item.provider === 'github') {
|
||||
const source = item.source
|
||||
@@ -232,6 +237,7 @@ export function useMobileTasksWorkspaceCreateActions(model: WorkspaceSshStateMod
|
||||
workspaceName: workspaceNameOverride,
|
||||
note: comment,
|
||||
sparseCheckout: sparseCheckoutOverride,
|
||||
nameIsAutoManaged,
|
||||
worktreeCreateIdempotency: taskWorkspaceCreationOperations
|
||||
.readRuntimeCapabilities()
|
||||
.then((capabilities) => capabilities.worktreeCreateIdempotency)
|
||||
@@ -267,7 +273,8 @@ export function useMobileTasksWorkspaceCreateActions(model: WorkspaceSshStateMod
|
||||
taskWorkspaceCreationOperations,
|
||||
tasksSupported,
|
||||
trustedOrcaHooks,
|
||||
workspaceDetectedAgentIds
|
||||
workspaceDetectedAgentIds,
|
||||
workspaceLastAutoName
|
||||
]
|
||||
)
|
||||
return Object.assign(model, {
|
||||
|
||||
@@ -46,6 +46,37 @@ describe('terminal accessory repeat', () => {
|
||||
expect(sent).toEqual(['down', 'down', 'down'])
|
||||
})
|
||||
|
||||
it('emits one keystroke per tap and holds at the iOS 400ms/45ms cadence', async () => {
|
||||
vi.useFakeTimers()
|
||||
const sent: number[] = []
|
||||
const start = Date.now()
|
||||
const send = vi.fn(async () => {
|
||||
sent.push(Date.now() - start)
|
||||
return true
|
||||
})
|
||||
const repeat = createTerminalAccessoryRepeatController<string>()
|
||||
|
||||
// A tap: press and release before the repeat delay elapses.
|
||||
repeat.start('down', send)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
repeat.stop()
|
||||
await vi.runAllTimersAsync()
|
||||
expect(sent).toEqual([0])
|
||||
|
||||
// A hold: first send at press time, then 400ms, then every 45ms.
|
||||
sent.length = 0
|
||||
const holdStart = Date.now()
|
||||
repeat.start('down', send)
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_ACCESSORY_REPEAT_DELAY_MS)
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS)
|
||||
repeat.stop()
|
||||
expect(sent.map((at) => at - (holdStart - start))).toEqual([
|
||||
0,
|
||||
TERMINAL_ACCESSORY_REPEAT_DELAY_MS,
|
||||
TERMINAL_ACCESSORY_REPEAT_DELAY_MS + TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS
|
||||
])
|
||||
})
|
||||
|
||||
it('does not schedule another repeat after release while a send is pending', async () => {
|
||||
vi.useFakeTimers()
|
||||
const pending = deferred()
|
||||
|
||||
Reference in New Issue
Block a user