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
2 changes: 2 additions & 0 deletions .agents/skills/ios-debugger-agent/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ Avoid generic Mac window automation for switching among Simulator windows. Expli

## Choose build or launch

For T3 Code Mobile, run `node scripts/mobile-native-client.ts ensure ios <simulator-udid>` from the checkout on the simulator host first. It checks the local Expo native fingerprint against the installed client and builds/installs when stale, missing, or unknown. Then launch with the intended Metro bundle. Authorized verification includes native builds and installs; do not stop because the existing client is old. Use `check` instead of `ensure` only when the user explicitly prohibits rebuilding or requests a read-only check.

- Use `build_run_sim` when native source, native dependencies, entitlements, or project configuration changed.
- Use `test_sim` for the smallest relevant native test target or test cases; do not run an entire workspace test matrix routinely.
- Use `launch_app_sim` when a compatible app is already installed and no native rebuild is needed.
Expand Down
32 changes: 17 additions & 15 deletions .agents/skills/test-t3-mobile/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,24 +15,28 @@ Inspect the host and the affected code before launching processes:

- On macOS with Xcode, prefer one representative iOS Simulator when the change is cross-platform so the user can watch through serve-sim. Load and follow [`ios-debugger-agent`](../ios-debugger-agent/SKILL.md), and load [`ios-simulator-browser`](../ios-simulator-browser/SKILL.md) when live streaming is available.
- On macOS, Linux, or Windows with the Android SDK, use one Android Emulator when Android is the affected surface or iOS tooling is unavailable.
- When the change is platform-specific, test that platform. When neither platform is viable, report the missing SDK, emulator, or dev-client prerequisite rather than claiming verification.
- When the change is platform-specific, test that platform. When neither platform is viable, report the missing SDK or emulator prerequisite rather than claiming verification. A missing development client is a build step, not a blocker.

Do not treat unavailable iOS tooling as a blocker when Android is a valid representative target.

## Choose the lightest valid launch path
## Ensure a compatible native client

- For JavaScript, TypeScript, or asset-only changes, reuse a compatible installed development client and start Metro. Do not rebuild native code merely to load a new bundle.
- For native source, native dependencies, entitlements, config plugins, or generated project changes, rebuild the affected platform.
- Use `vp run ios:dev` or `vp run android:dev` only when an Expo clean prebuild is actually required; both commands regenerate the native project.
- If the user requested no native rebuild and no compatible app is installed, reuse an existing compatible `.app` or `.apk` artifact when available. Otherwise report the missing dev client instead of silently rebuilding.
Authorized mobile verification includes building and installing a development client. A missing, stale, or unknown native client is not a reason to skip verification or leave a PR in draft. Build and install it, then continue. Respect an explicit user instruction not to rebuild; otherwise do not ask for separate permission.

The development identity on both platforms is:
Run this from the checkout being tested, on the machine that hosts the selected simulator or emulator. Select and boot one explicit iOS UDID or Android emulator serial first:

- App: `T3 Code Dev`
- Bundle/package identifier: `com.t3tools.t3code.dev`
- URL scheme: `t3code-dev`
```bash
node scripts/mobile-native-client.ts ensure ios <simulator-udid>
node scripts/mobile-native-client.ts ensure android <emulator-serial>
```

`ensure` compares the checkout's local Expo development fingerprint and the installed app's binary contents against the last successful build record. It reuses a matching client; otherwise it runs a clean prebuild, builds and installs the development app, and records the successful result. It does not start Metro. Start Metro below after it succeeds. On hosts with an `agent-job` requirement, run the entire `ensure` command through that queue.

For a read-only decision, use `check` in place of `ensure`. Exit 0 means compatible, 2 means build required, and 1 means an operational error. An app installed outside this helper is initially unknown and gets rebuilt once. Records are local to the simulator host under `~/.cache/t3code/native-clients` and work across checkouts. Do not copy records between machines or write them manually.

A JavaScript-only diff, bundle identifier, app version, or recent install date does not prove native compatibility. Always check the whole checkout. Expo fingerprints are computed locally with `APP_VARIANT=development`; no EAS credentials or cloud build are required. Generated `ios/` and `android/` directories are excluded by `.fingerprintignore`, so edit native source modules or config plugins rather than generated output.

Bundle or package presence proves the correct variant, not native compatibility. Reuse it only when the current changes did not alter its Expo SDK, native dependencies, config plugins, entitlements, generated project, or native source.
The development identity is `T3 Code Dev`, bundle/package `com.t3tools.t3code.dev`, scheme `t3code-dev`. If a build fails, investigate the build error and fix the local prerequisites. Report the concrete failure if it cannot be resolved, not “no compatible client.”

## Start one disposable T3 environment

Expand Down Expand Up @@ -98,21 +102,19 @@ Use `ios-debugger-agent` to select one UDID and set these XcodeBuildMCP session
- Simulator ID: the selected UDID
- Bundle ID: `com.t3tools.t3code.dev`

Check the installed client with:
After `ensure` succeeds, open the Metro URL:

```bash
xcrun simctl get_app_container <simulator-udid> com.t3tools.t3code.dev app
xcrun simctl openurl <simulator-udid> <printed-dev-client-url>
```

Accept the iOS confirmation prompt and dismiss the developer menu when it obscures the app.

### Android launch

Select one running emulator serial from `adb devices` and check the installed client:
Use the emulator serial already checked by `ensure`:

```bash
adb -s <emulator-serial> shell pm path com.t3tools.t3code.dev
adb -s <emulator-serial> reverse tcp:<metro-port> tcp:<metro-port>
adb -s <emulator-serial> shell am start -W \
-a android.intent.action.VIEW \
Expand Down
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real
- The server is event-sourced and its async flows emit typed receipts. Wait on receipts and worker drains, never on sleeps or polling. A test that needs a timeout to pass is wrong.
- Upon request, user-visible frontend changes should get one integrated pass in a real client: `test-t3-app` for web, `test-t3-mobile` for mobile. The primary agent does this once after integrating. Subagents do not launch their own dev servers. Ask permission before doing computer use or spinning up browsers.

For authorized mobile verification, a missing or outdated native client is a build step, not a blocker. Run `node scripts/mobile-native-client.ts ensure <ios|android> <device-id>` on the simulator host before starting Metro. It checks the local Expo fingerprint and builds/installs when needed. See `test-t3-mobile` for the full workflow.

## Pull requests

- Never make a PR unless the developer explicitly asks you to do so.
Expand Down
17 changes: 16 additions & 1 deletion apps/mobile/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,22 @@ repository-root `.env` or `.env.local`, not an `apps/mobile/.env` file. See

## Development

Start Metro for the dev client:
For simulator/emulator development, select and boot a device, then ensure its native client matches
this checkout before starting Metro:

```bash
node ../../scripts/mobile-native-client.ts ensure ios <simulator-udid>
# Or: node ../../scripts/mobile-native-client.ts ensure android <emulator-serial>
vp run dev:client
```

The helper compares a local Expo fingerprint and the installed binary with its last successful
build record. It builds and installs missing, stale, or unverified clients and reuses matching ones.
Use `check` instead of `ensure` for a read-only decision: exit 0 means compatible, 2 means a build is
needed, and 1 means an operational error. Run it on the simulator host; no EAS login is required.
An externally installed client is unverified until the helper builds it once.

Start Metro for an already verified dev client:

```bash
vp run dev:client
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/BranchToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ import {
} from "./ui/menu";
import { Separator } from "./ui/separator";
import { ComposerSurface } from "./chat/ComposerSurface";
import { composerFloatingLayerProps } from "./chat/composerEventScope";
import { useComposerMenuProps } from "./chat/composerEventScope";
import { measureRestingComposerControls } from "./chat/restingComposerControlsMeasurement";
import { resolveRestingComposerControlsNaturalWidth } from "./composerFooterLayout";
import { cn } from "~/lib/utils";
Expand Down Expand Up @@ -120,6 +120,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
previousWorktreeLabel,
onUsePreviousWorktree,
}: MobileRunContextSelectorProps) {
const composerFloatingLayerProps = useComposerMenuProps();
const activeEnvironment = useMemo(
() => availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null,
[availableEnvironments, environmentId],
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/BranchToolbarBranchSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import { vcsEnvironment } from "../state/vcs";
import { cn } from "../lib/utils";
import { parsePullRequestReference } from "../pullRequestReference";
import { getSourceControlPresentation } from "../sourceControlPresentation";
import { composerFloatingLayerProps } from "./chat/composerEventScope";
import { useComposerMenuProps } from "./chat/composerEventScope";
import {
deriveLocalBranchNameFromRemoteRef,
resolveBranchTriggerLabel,
Expand Down Expand Up @@ -113,6 +113,7 @@ export function BranchToolbarBranchSelector({
onCheckoutPullRequestRequest,
onComposerFocusRequest,
}: BranchToolbarBranchSelectorProps) {
const composerFloatingLayerProps = useComposerMenuProps();
const startFromOriginSwitchId = useId();
const stopThreadSession = useAtomCommand(threadEnvironment.stopSession, "thread session stop");
const updateThreadMetadata = useAtomCommand(
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/BranchToolbarEnvModeSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
resolveLockedWorkspaceLabel,
type EnvMode,
} from "./BranchToolbar.logic";
import { composerFloatingLayerProps } from "./chat/composerEventScope";
import { useComposerMenuProps } from "./chat/composerEventScope";
import {
Select,
SelectGroup,
Expand Down Expand Up @@ -37,6 +37,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
previousWorktreeLabel,
onUsePreviousWorktree,
}: BranchToolbarEnvModeSelectorProps) {
const composerFloatingLayerProps = useComposerMenuProps();
const showPreviousWorktree = Boolean(previousWorktreeLabel && onUsePreviousWorktree);
const envModeItems = useMemo(
() => [
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/BranchToolbarEnvironmentSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { memo, useMemo } from "react";

import type { EnvironmentOption } from "./BranchToolbar.logic";
import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
import { composerFloatingLayerProps } from "./chat/composerEventScope";
import { useComposerMenuProps } from "./chat/composerEventScope";
import {
Select,
SelectGroup,
Expand Down Expand Up @@ -34,6 +34,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
availableEnvironments,
onEnvironmentChange,
}: BranchToolbarEnvironmentSelectorProps) {
const composerFloatingLayerProps = useComposerMenuProps();
const activeEnvironment = useMemo(() => {
return availableEnvironments.find((env) => env.environmentId === environmentId) ?? null;
}, [availableEnvironments, environmentId]);
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ import {
} from "./composerMentionDrag";
import {
composerFloatingLayerProps,
useComposerMenuProps,
isInsideCollapsedComposerControls,
isInsideComposerFloatingLayer,
isInsideRestingComposerControlScope,
Expand Down Expand Up @@ -1038,6 +1039,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
onRuntimeModeChange: (mode: RuntimeMode) => void;
}) {
const size = props.size ?? "sm";
const composerFloatingLayerProps = useComposerMenuProps();
const [open, setOpen] = useComposerMenuState(props.hidden);
const runtimeModeOption = runtimeModeConfig[props.runtimeMode];
const RuntimeModeIcon = runtimeModeOption.icon;
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/chat/CompactComposerControlsMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
MenuTrigger,
} from "../ui/menu";
import { ComposerControl, ComposerControlIcon } from "./ComposerControl";
import { composerFloatingLayerProps } from "./composerEventScope";
import { useComposerMenuProps } from "./composerEventScope";
import { useComposerMenuState } from "./useComposerMenuState";

export const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: {
Expand All @@ -28,6 +28,7 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls
onToggleInteractionMode: () => void;
onRuntimeModeChange: (mode: RuntimeMode) => void;
}) {
const composerFloatingLayerProps = useComposerMenuProps();
const size = props.size ?? "sm";
const [open, setOpen] = useComposerMenuState(props.hidden);

Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/chat/ProviderModelPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
ComposerControlChevron,
type ComposerControlSize,
} from "./ComposerControl";
import { composerFloatingLayerProps } from "./composerEventScope";
import { useComposerMenuProps } from "./composerEventScope";

export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
/**
Expand Down Expand Up @@ -56,6 +56,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
getModelDisabledReason?: (instanceId: ProviderInstanceId, model: string) => string | null;
onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void;
}) {
const composerFloatingLayerProps = useComposerMenuProps();
const [uncontrolledIsMenuOpen, setUncontrolledIsMenuOpen] = useState(false);
const isMenuOpen = props.open ?? uncontrolledIsMenuOpen;
const size = props.size ?? "sm";
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/chat/TraitsPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import {
ComposerControlIcon,
type ComposerControlSize,
} from "./ComposerControl";
import { composerFloatingLayerProps } from "./composerEventScope";
import { useComposerMenuProps } from "./composerEventScope";
import { useComposerMenuState } from "./useComposerMenuState";

type ProviderOptions = ReadonlyArray<ProviderOptionSelection>;
Expand Down Expand Up @@ -557,6 +557,7 @@ export const TraitsPicker = memo(function TraitsPicker({
size?: ComposerControlSize;
hidden?: boolean;
}) {
const composerFloatingLayerProps = useComposerMenuProps();
const [isMenuOpen, setIsMenuOpen] = useComposerMenuState(hidden);
const { descriptors, primarySelectDescriptor, ultrathinkPromptControlled } =
getTraitsSectionVisibility({
Expand Down
40 changes: 40 additions & 0 deletions apps/web/src/components/chat/composerEventScope.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { act, createElement, useLayoutEffect } from "react";
import { create } from "react-test-renderer";
import { afterEach, describe, expect, it, vi } from "vite-plus/test";

import { ComposerHandleContext, type ComposerHandleRef } from "../../composerHandleContext";
import {
isInsideCollapsedComposerControls,
isInsideComposerFloatingLayer,
isInsideRestingComposerControlScope,
useComposerMenuProps,
} from "./composerEventScope";

class FakeElement {
Expand All @@ -21,6 +25,42 @@ afterEach(() => {
vi.unstubAllGlobals();
});

describe("composer menu focus", () => {
it.each([
["an open menu", '[data-chat-composer-floating-layer="true"]', true],
["an unmounted menu", null, true],
["another control", "input", false],
])("closes while focus is on %s", async (_label, selector, shouldFocusComposer) => {
const body = new FakeElement(null);
const editor = new FakeElement(null);
const activeElement = selector === null ? body : new FakeElement(selector);
const document = { body, activeElement };
const composerRef = {
current: { focusAtEnd: () => (document.activeElement = editor) },
} as unknown as ComposerHandleRef;
vi.stubGlobal("Element", FakeElement);
vi.stubGlobal("document", document);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
let menuProps: ReturnType<typeof useComposerMenuProps> | undefined;
function Probe() {
const props = useComposerMenuProps();
useLayoutEffect(() => {
menuProps = props;
}, [props]);
return null;
}
const renderer = await act(() =>
create(createElement(ComposerHandleContext, { value: composerRef }, createElement(Probe))),
);
try {
expect(menuProps?.finalFocus?.()).toBe(false);
expect(document.activeElement).toBe(shouldFocusComposer ? editor : activeElement);
} finally {
await act(() => renderer.unmount());
}
});
});

describe("composer event scopes", () => {
it("recognizes events from the portaled resting controls", () => {
vi.stubGlobal("Element", FakeElement);
Expand Down
20 changes: 20 additions & 0 deletions apps/web/src/components/chat/composerEventScope.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { useComposerHandleContext } from "../../composerHandleContext";

const COMPOSER_FLOATING_LAYER_SELECTOR = [
'[data-composer-drawer-layer="true"]',
'[data-chat-composer-floating-layer="true"]',
Expand All @@ -7,6 +9,24 @@ export const composerFloatingLayerProps = {
"data-chat-composer-floating-layer": "true",
} as const;

export function useComposerMenuProps() {
const composerRef = useComposerHandleContext();

return {
...composerFloatingLayerProps,
finalFocus: composerRef
? () => {
const activeElement = document.activeElement;
if (activeElement !== document.body && !isInsideComposerFloatingLayer(activeElement)) {
return false;
}
composerRef.current?.focusAtEnd();
return false;
}
: undefined,
};
}

export function isInsideComposerFloatingLayer(target: EventTarget | null): boolean {
return target instanceof Element && target.closest(COMPOSER_FLOATING_LAYER_SELECTOR) !== null;
}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/device/DevicePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,7 @@ export function DevicePanel(props: {
) : null}
{loaded && !hostBusy ? (
<Button
className="self-start"
className={grouped.length > 0 ? "self-start" : "self-center"}
variant={grouped.length > 0 ? "ghost" : "outline"}
size="sm"
onClick={() => void list({ environmentId, input: {} })}
Expand Down
4 changes: 2 additions & 2 deletions knip.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@
"vitest": { "entry": [".github/**/*.test.cjs"] },
},
"scripts": {
// Knip loads its preprocessor through a CLI option, not a source import.
"entry": ["knip-schemas.ts"],
// Knip loads its preprocessor through a CLI option; native verification runs directly.
"entry": ["knip-schemas.ts", "mobile-native-client.ts"],
},
"apps/server": {
// Vite+ pack entries and the launcher used by installed background services.
Expand Down
Loading
Loading