Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions apps/mobile/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,10 @@ other client.
(`keyboardType`, `submitLabel`, `onSubmit`, `textInputAutocapitalization`).
- Two more shapes worth knowing: a `Section`'s `isExpanded` is honoured **only** under
`listStyle('sidebar')` — that list style is what makes the thread groups collapsible, not a
cosmetic choice; and `BottomSheet` is a plain native view that mounts in the **RN** tree, not a
`Host` child (only its contents are SwiftUI).
cosmetic choice; and `BottomSheet` needs a `Host` like every other `@expo/ui` view (its source
reads like a plain RN view, but mounting it directly red-boxes). Give that host
`style={{ position: 'absolute' }}` + `pointerEvents="box-none"` so it claims no layout, and set
`fitToContents` or SwiftUI presents a short sheet at a near-full-screen detent.
- **What deliberately stays React Native, and why.** `@expo/ui` renders SwiftUI, so anything whose
indispensable part is an RN view cannot cross over — RN→SwiftUI is the supported direction, and
going back needs `RNHostView`, whose bidirectional nesting is the very thing 57.0.5 had to fix.
Expand All @@ -68,6 +70,15 @@ other client.
than worked around: `Image` takes SF Symbols, asset-catalog names, and local file URIs but
**never a remote URL**, so the account avatar is an SF Symbol; and the agent brand marks are RN
SVG components, so thread rows and the new-thread picker name the agent in text instead.
- **`src/polyfills.ts` is imported first in the root layout, and the connection depends on it.**
React Native installs `AbortController`/`AbortSignal` from the 2019 `abort-controller` package, which
has neither `signal.reason` nor `signal.throwIfAborted()`. `foxts/async-retry` opens with
`options.signal?.throwIfAborted()` — the optional chain guards the signal, not the method — so
every retry given a signal threw `TypeError: undefined is not a function` before doing any work,
and the mobile client never reached the network at all (CODE-462). Nothing about that reads as a
platform gap from the outside: it looks exactly like an unreachable host. When a `foxts`/web API
behaves differently here than under Node, suspect this class first, and probe the runtime rather
than reasoning from the API's documentation.
- **Styling = Uniwind + Tailwind v4, NOT NativeWind.** HeroUI Native 1.0's official companion is `uniwind` (`heroui-native` + `uniwind` + Tailwind v4): metro `withUniwindConfig`, babel is only `babel-preset-expo`, styles are CSS-first in `src/global.css`, and the generated `src/uniwind-types.d.ts` is committed and Biome-ignored. Earlier NativeWind plans are superseded — don't reach for `nativewind`. HeroUI Native still peers on `react-native-gesture-handler` **^2.x** — gesture-handler 3.x is off the table until HeroUI widens that peer.
- **Versions are hard-pinned to the Expo SDK.** React Native must track the SDK's expected version (SDK 57 = RN 0.86.0 / reanimated 4.5.0 / worklets 0.10.0 / gesture-handler ~2.32.0; the SDK's own expectations live in `expo/bundledNativeModules.json` — align with `pnpm -F @linkcode/mobile exec expo install --fix`, then revert its `typescript` edit back to `catalog:`), and `react`/`react-dom` follow `bundledNativeModules.json` too (SDK 57 → 19.2.3). **That pin is Expo's, not RN's** — `react-native@0.86.0` peers on `react: ^19.2.3`, a caret range the catalog's 19.2.7 also satisfies, so don't argue from "RN requires exactly this". Hold the pin because `expo install --fix` rewrites anything else back, and because React's renderer internals are compiled against a matching `react` — a drift fails at runtime, subtly, not at build. The root pnpm catalog deliberately keeps its own 19.2.7 rather than unifying down: that trades one version fork for keeping the web apps' React cadence off the Expo SDK's. The cost of that trade is **two react copies in the tree**, which is what `vitest.config.ts` here works around (CODE-444). `@sentry/react-native` follows the SDK's expected line (~7.11.0 on SDK 57), not the package's own `latest`.
- **Two RN-resolution traps:** after changing the RN version, run `pnpm dedupe react-native` (a residual nested copy at the old version, pulled by `packages/presentation/ui`'s optional peer, breaks uniwind's `className` augmentation); and install `@gorhom/bottom-sheet` even though it is only an optional peer — Metro statically resolves HeroUI's `try/catch` require of it and fails without it.
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/app/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// First import in the app: patches the AbortSignal gaps everything below assumes (see the module).
import '../polyfills';
import { BottomSheetModalProvider } from '@gorhom/bottom-sheet';
import { sanitizeSentryTransaction } from '@linkcode/common/sentry';
import type { TelemetryConfig } from '@linkcode/common/telemetry-config';
Expand Down
3 changes: 2 additions & 1 deletion apps/mobile/src/app/host/[hostId]/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export default function HostLayout(): React.ReactNode {
}

function HostConnection({ host }: { host: HostProfile }): React.ReactNode {
const { client, status, retry } = useHostClient(host);
const { client, status, retry, failure } = useHostClient(host);
const screenOptions = useStackScreenOptions();
const setLastActiveHostId = useHostRegistryStore((state) => state.setLastActiveHostId);

Expand All @@ -32,6 +32,7 @@ function HostConnection({ host }: { host: HostProfile }): React.ReactNode {
<HostConnectionState
status={status}
url={'url' in host ? host.url : `${host.name} · LinkCode Cloud`}
failure={failure}
onRetry={retry}
/>
);
Expand Down
11 changes: 10 additions & 1 deletion apps/mobile/src/components/host-connection-state.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
import { Button, Host, ProgressView, Text, VStack } from '@expo/ui/swift-ui';
import { foregroundStyle, multilineTextAlignment } from '@expo/ui/swift-ui/modifiers';
import { font, foregroundStyle, multilineTextAlignment } from '@expo/ui/swift-ui/modifiers';
import { useTranslations } from 'use-intl';

const SECONDARY = foregroundStyle({ type: 'hierarchical', style: 'secondary' });
const FOOTNOTE = font({ textStyle: 'footnote' });

export interface HostConnectionStateProps {
status: 'connecting' | 'error';
url: string;
/** The underlying failure, when the controller reported one. */
failure?: string;
onRetry: () => void;
}

/** Full-screen fallback shown while a host connection is being established or has failed. */
export function HostConnectionState({
status,
url,
failure,
onRetry,
}: HostConnectionStateProps): React.ReactNode {
const t = useTranslations('mobile.connection');
Expand All @@ -29,6 +33,11 @@ export function HostConnectionState({
) : (
<>
<Text modifiers={[multilineTextAlignment('center')]}>{t('error', { url })}</Text>
{failure ? (
<Text modifiers={[FOOTNOTE, SECONDARY, multilineTextAlignment('center')]}>
{failure}
</Text>
) : null}
<Button label={t('retry')} onPress={onRetry} />
</>
)}
Expand Down
112 changes: 62 additions & 50 deletions apps/mobile/src/components/new-thread-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
BottomSheet,
Button,
Form,
Host,
HStack,
Picker,
Section,
Expand Down Expand Up @@ -59,63 +60,74 @@ export function NewThreadSheet({
};

return (
<BottomSheet isPresented={isPresented} onIsPresentedChange={onIsPresentedChange}>
<Form>
{/* Segmented rather than the old icon chips: the agent brand marks are RN SVG
// `BottomSheet` is SwiftUI like any other `@expo/ui` view and red-boxes when mounted straight
// into the RN tree. The host carries no layout of its own — the sheet presents over the whole
// screen from UIKit — so it stays zero-sized and lets touches through to the screen behind it.
<Host style={{ position: 'absolute' }} pointerEvents="box-none">
{/* Sized to its content, or SwiftUI presents it at a near-full-screen detent — a sheet this
short reads as a takeover otherwise. */}
<BottomSheet
isPresented={isPresented}
onIsPresentedChange={onIsPresentedChange}
fitToContents
>
<Form>
{/* Segmented rather than the old icon chips: the agent brand marks are RN SVG
components, which have no place in a SwiftUI view tree. */}
<Section title={t('kindLabel')}>
<Picker
selection={kind}
onSelectionChange={setKind}
modifiers={[pickerStyle('segmented')]}
>
{AgentKindSchema.options.map((option) => (
<Text key={option} modifiers={[tag(option)]}>
{AGENT_LABELS[option]}
</Text>
))}
</Picker>
</Section>

{ordered.length > 0 ? (
// An inline picker draws the selection checkmark itself, replacing the hand-placed one.
<Section title={t('projectLabel')}>
<Section title={t('kindLabel')}>
<Picker
selection={effectiveCwd}
onSelectionChange={setSelectedCwd}
modifiers={[pickerStyle('inline')]}
selection={kind}
onSelectionChange={setKind}
modifiers={[pickerStyle('segmented')]}
>
{ordered.map((workspace) => (
<VStack
key={workspace.workspaceId}
alignment="leading"
spacing={2}
modifiers={[tag(workspace.cwd)]}
>
<Text>{workspace.name ?? repositoryLabel(workspace.cwd)}</Text>
<Text modifiers={[FOOTNOTE, SECONDARY]}>{workspace.cwd}</Text>
</VStack>
{AgentKindSchema.options.map((option) => (
<Text key={option} modifiers={[tag(option)]}>
{AGENT_LABELS[option]}
</Text>
))}
</Picker>
</Section>
) : (

{ordered.length > 0 ? (
// An inline picker draws the selection checkmark itself, replacing the hand-placed one.
<Section title={t('projectLabel')}>
<Picker
selection={effectiveCwd}
onSelectionChange={setSelectedCwd}
modifiers={[pickerStyle('inline')]}
>
{ordered.map((workspace) => (
<VStack
key={workspace.workspaceId}
alignment="leading"
spacing={2}
modifiers={[tag(workspace.cwd)]}
>
<Text>{workspace.name ?? repositoryLabel(workspace.cwd)}</Text>
<Text modifiers={[FOOTNOTE, SECONDARY]}>{workspace.cwd}</Text>
</VStack>
))}
</Picker>
</Section>
) : (
<Section>
<HStack spacing={12}>
<Text>{t('cwdLabel')}</Text>
<TextField
testID="thread-cwd-input"
text={customPath}
placeholder={t('cwdPlaceholder')}
modifiers={[textInputAutocapitalization('never'), autocorrectionDisabled()]}
/>
</HStack>
</Section>
)}

<Section>
<HStack spacing={12}>
<Text>{t('cwdLabel')}</Text>
<TextField
testID="thread-cwd-input"
text={customPath}
placeholder={t('cwdPlaceholder')}
modifiers={[textInputAutocapitalization('never'), autocorrectionDisabled()]}
/>
</HStack>
<Button label={t('create')} onPress={create} modifiers={[disabled(creating)]} />
</Section>
)}

<Section>
<Button label={t('create')} onPress={create} modifiers={[disabled(creating)]} />
</Section>
</Form>
</BottomSheet>
</Form>
</BottomSheet>
</Host>
);
}
57 changes: 57 additions & 0 deletions apps/mobile/src/polyfills.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { AbortError } from 'foxts/abort-error';

/**
* React Native installs `AbortController`/`AbortSignal` from the `abort-controller` npm package
* (`react-native/Libraries/Core/setUpXHR.js`), a 2019 polyfill that predates two parts of the spec
* this codebase relies on: `signal.reason` and `signal.throwIfAborted()`.
*
* The gap is silent and total. `foxts/async-retry` opens with `options.signal?.throwIfAborted()` —
* the optional chain guards the *signal*, not the method — so every `asyncRetry` call given a
* signal threw `TypeError: undefined is not a function` before doing any work. That is the whole
* of CODE-462: `ConnectionController` passes a signal, so the mobile client never reached the
* network at all, which reads from the outside exactly like an unreachable host.
*
* `reason` is patched alongside it because the two are one feature: `throwIfAborted` is specified
* to throw `reason`, and our own controller re-throws `signal.reason` when a run is superseded.
* Left unpatched that throws `undefined`, which no `catch` can classify.
*
* Imported for side effects before anything else in the root layout. Native `timeout()`/`any()`
* signals still resolve, because the throw falls back to a fresh `AbortError` when nothing
* recorded a reason.
*/

const RECORDED_REASON = Symbol('linkcode.abortReason');

interface ReasonCarrier {
[RECORDED_REASON]?: unknown;
}

/* eslint-disable sukka/class-prototype -- patching an existing global's prototype is the only
shape a polyfill can take; there is no class here to declare. */
if (typeof AbortSignal.prototype.throwIfAborted !== 'function') {
// Capturing the original unbound is the point: it is re-invoked below with an explicit `this`,
// and binding here would fix it to the prototype rather than the instance doing the aborting.
// eslint-disable-next-line @typescript-eslint/unbound-method -- see above
const nativeAbort = AbortController.prototype.abort;

AbortController.prototype.abort = function abort(this: AbortController, reason?: unknown): void {
(this.signal as ReasonCarrier)[RECORDED_REASON] = reason ?? new AbortError();
nativeAbort.call(this);
};

Object.defineProperty(AbortSignal.prototype, 'reason', {
configurable: true,
get(this: ReasonCarrier): unknown {
return this[RECORDED_REASON];
},
});

AbortSignal.prototype.throwIfAborted = function throwIfAborted(this: AbortSignal): void {
if (!this.aborted) return;
// `abort(reason)` takes any value and `throwIfAborted` is specified to rethrow it verbatim;
// narrowing to Error here would make the polyfill lie about what the caller aborted with.
// eslint-disable-next-line @typescript-eslint/only-throw-error -- see above
throw (this as ReasonCarrier)[RECORDED_REASON] ?? new AbortError();
};
}
/* eslint-enable sukka/class-prototype */
6 changes: 6 additions & 0 deletions apps/mobile/src/runtime/use-host-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { ConnectionSource } from '@linkcode/client-core';
import { ConnectionController, LinkCodeClient } from '@linkcode/client-core';
import NetInfo from '@react-native-community/netinfo';
import { randomUUID } from 'expo-crypto';
import { extractErrorMessage } from 'foxts/extract-error-message';
import { useCallback, useEffect, useState, useSyncExternalStore } from 'react';
import { AppState } from 'react-native';
import type { HostProfile } from '../stores/host-store';
Expand All @@ -15,6 +16,10 @@ interface HostClientBase {
readonly attempt: number;
/** Abandon any pending backoff and dial again now. */
readonly retry: () => void;
/** Why the last attempt failed, when the controller knows. Kept because "unable to reach" alone
* tells neither the user nor a triager whether the host is down, unreachable, or speaking a
* different wire version — the causes need entirely different responses. */
readonly failure?: string;
}

interface HostClientReady extends HostClientBase {
Expand Down Expand Up @@ -79,6 +84,7 @@ export function useHostClient(host: HostProfile): HostClientState {
: {
attempt: snapshot.attempt,
client: null,
failure: extractErrorMessage(snapshot.error, false) ?? undefined,
retry,
status: snapshot.status === 'error' ? 'error' : 'connecting',
};
Expand Down