diff --git a/.agents/skills/ios-debugger-agent/SKILL.md b/.agents/skills/ios-debugger-agent/SKILL.md index 7afa579383d9..8d204ff201bc 100644 --- a/.agents/skills/ios-debugger-agent/SKILL.md +++ b/.agents/skills/ios-debugger-agent/SKILL.md @@ -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 ` 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. diff --git a/.agents/skills/test-t3-mobile/SKILL.md b/.agents/skills/test-t3-mobile/SKILL.md index 3fcf94334fd6..afebcb7aa7d5 100644 --- a/.agents/skills/test-t3-mobile/SKILL.md +++ b/.agents/skills/test-t3-mobile/SKILL.md @@ -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 +node scripts/mobile-native-client.ts ensure android +``` + +`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 @@ -98,10 +102,9 @@ 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 com.t3tools.t3code.dev app xcrun simctl openurl ``` @@ -109,10 +112,9 @@ Accept the iOS confirmation prompt and dismiss the developer menu when it obscur ### 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 shell pm path com.t3tools.t3code.dev adb -s reverse tcp: tcp: adb -s shell am start -W \ -a android.intent.action.VIEW \ diff --git a/AGENTS.md b/AGENTS.md index ccf1fdc1d85c..38df1e94fa8c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ` 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. diff --git a/apps/mobile/README.md b/apps/mobile/README.md index a9a8177c4ece..20f98c6c9e34 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -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 +# Or: node ../../scripts/mobile-native-client.ts ensure android +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 diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 7b7ffe626bc8..d36eabc3cec5 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -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"; @@ -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], diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 5f35c14de415..ba5251538685 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -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, @@ -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( diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index a350931fbf45..b29c77cb378d 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -7,7 +7,7 @@ import { resolveLockedWorkspaceLabel, type EnvMode, } from "./BranchToolbar.logic"; -import { composerFloatingLayerProps } from "./chat/composerEventScope"; +import { useComposerMenuProps } from "./chat/composerEventScope"; import { Select, SelectGroup, @@ -37,6 +37,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe previousWorktreeLabel, onUsePreviousWorktree, }: BranchToolbarEnvModeSelectorProps) { + const composerFloatingLayerProps = useComposerMenuProps(); const showPreviousWorktree = Boolean(previousWorktreeLabel && onUsePreviousWorktree); const envModeItems = useMemo( () => [ diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index 0fa879c78a55..231e4b0d824d 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -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, @@ -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]); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 5bcdb05dbfdf..f7d3ac540ccc 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -90,6 +90,7 @@ import { } from "./composerMentionDrag"; import { composerFloatingLayerProps, + useComposerMenuProps, isInsideCollapsedComposerControls, isInsideComposerFloatingLayer, isInsideRestingComposerControlScope, @@ -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; diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx index 78cfb7b8d8fa..1969e2e9de36 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx @@ -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: { @@ -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); diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 6f777399451d..bc8e7b48ce96 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -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: { /** @@ -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"; diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 0a863e0bd446..da293c38ddc9 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -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; @@ -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({ diff --git a/apps/web/src/components/chat/composerEventScope.test.ts b/apps/web/src/components/chat/composerEventScope.test.ts index 365c5304aa2d..f40c9a68e6ee 100644 --- a/apps/web/src/components/chat/composerEventScope.test.ts +++ b/apps/web/src/components/chat/composerEventScope.test.ts @@ -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 { @@ -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 | 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); diff --git a/apps/web/src/components/chat/composerEventScope.ts b/apps/web/src/components/chat/composerEventScope.ts index 88e24fd89422..391c468d0d03 100644 --- a/apps/web/src/components/chat/composerEventScope.ts +++ b/apps/web/src/components/chat/composerEventScope.ts @@ -1,3 +1,5 @@ +import { useComposerHandleContext } from "../../composerHandleContext"; + const COMPOSER_FLOATING_LAYER_SELECTOR = [ '[data-composer-drawer-layer="true"]', '[data-chat-composer-floating-layer="true"]', @@ -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; } diff --git a/apps/web/src/components/device/DevicePanel.tsx b/apps/web/src/components/device/DevicePanel.tsx index 950a202a7b9f..6f1ae81eddb9 100644 --- a/apps/web/src/components/device/DevicePanel.tsx +++ b/apps/web/src/components/device/DevicePanel.tsx @@ -389,7 +389,7 @@ export function DevicePanel(props: { ) : null} {loaded && !hostBusy ? (