From 109848fe7919b000e0cf2a19e3cf08bed372dcd3 Mon Sep 17 00:00:00 2001 From: drown0315 Date: Thu, 9 Jul 2026 23:12:54 +0800 Subject: [PATCH 1/8] chore: add ask ui launch planning docs --- docs/product/ask-ui-launch-prd.md | 176 +++++++++++++++++++++++ issues/0.0.4-ask-ui-launch.md | 226 ++++++++++++++++++++++++++++++ 2 files changed, 402 insertions(+) create mode 100644 docs/product/ask-ui-launch-prd.md create mode 100644 issues/0.0.4-ask-ui-launch.md diff --git a/docs/product/ask-ui-launch-prd.md b/docs/product/ask-ui-launch-prd.md new file mode 100644 index 0000000..39b8f46 --- /dev/null +++ b/docs/product/ask-ui-launch-prd.md @@ -0,0 +1,176 @@ +# Ask UI Launch PRD + +## Problem Statement + +Ask UI is intended to be started from the same Codex, Claude Code, or similar coding-agent session that will later receive UI-targeted feedback. Today that startup path is still a set of manual local steps: choose a Flutter device, run the target Flutter app, start the Dart Bridge, start or build the Web workbench, open the browser with the right parameters, then separately enter the Agent Session Command polling loop. + +That manual chain is fragile. It exposes implementation details such as the React/Vite development server to installed users, makes the Agent Session depend on browser-created session state, and leaves too much room for mismatched device, flavor, VM Service, Bridge Session, and poll command parameters. + +Ask UI needs a single launch contract that coding agents can run reliably. The installed-user path should work from a Flutter project with an `ask_ui_bridge` dev dependency, serve the packaged Web workbench from the local bridge, open the browser, and return the exact Agent Session Command needed to keep receiving Chat messages from Ask UI. + +## Solution + +Add a first-class `launch` command to the Ask UI bridge CLI. + +The launch command discovers or accepts a Target Device, supports Flutter flavor-related app startup options, runs the target Flutter app, captures the VM Service URI, starts or reuses the local Bridge Server, creates a Bridge Session, serves the packaged Web workbench, opens a browser URL that attaches to the created Bridge Session, and returns machine-readable JSON containing the workbench URL and Agent Session Command. + +Installed users should run Ask UI through the pub package: + +- Add the runtime package to the Flutter app. +- Add the bridge package as a dev dependency. +- Run the bridge executable through Dart. + +The React/TypeScript Web app remains the implementation of the workbench, but users do not install or run the Web source. Release preparation builds the Web app into static files and includes those files inside the bridge package artifact. The Bridge Server serves those static files from the same local origin as the API. + +The coding-agent skill stays thin. It calls `launch`, handles `needs_device_selection` JSON by asking the user to choose a device, runs the returned Agent Session Command when launch is ready, treats polled Ask UI messages as current task input, writes replies through the Agent Session Command, and continues polling until the user stops. + +## User Stories + +1. As a Flutter developer, I want to start Ask UI with one command from my coding-agent session, so that I do not manually coordinate several local processes. +2. As a Flutter developer, I want the launch command to use the same Agent Session that will later edit code, so that UI feedback returns to the right coding agent. +3. As a Flutter developer, I want the installed-user command to run from my Flutter project, so that Ask UI startup is project-local and reproducible. +4. As a Flutter developer, I want to install the bridge as a dev dependency, so that the project controls the Ask UI bridge version. +5. As a Flutter developer, I want the runtime package to remain separate from the bridge dev tool, so that production app dependencies stay minimal. +6. As a Flutter developer, I want launch to discover Flutter devices when I do not specify one, so that I can choose from the devices currently visible to Flutter. +7. As a Flutter developer, I want launch to proceed automatically when exactly one usable device exists, so that common single-device setups are fast. +8. As a Flutter developer, I want launch to ask me when multiple devices are available, so that it does not guess the wrong Target Device. +9. As a Flutter developer, I want launch to accept a device id, so that I can start a known emulator or physical device directly. +10. As a Flutter developer, I want launch to accept an unambiguous device name, so that I can use human-friendly device references. +11. As a Flutter developer, I want ambiguous device names to return a selection prompt, so that launch does not silently bind to the wrong device. +12. As a Flutter developer, I want launch errors for missing devices to be clear, so that I know whether to connect a device or start an emulator. +13. As a Flutter developer, I want launch to support Flutter flavors, so that I can inspect the same app variant I normally develop. +14. As a Flutter developer, I want launch to support Flutter target files, so that flavor-specific entrypoints work. +15. As a Flutter developer, I want launch to support Dart defines, so that local app configuration works without editing code. +16. As a Flutter developer, I want flavor and target values to appear in launch output, so that I can verify which app variant is running. +17. As a Flutter developer, I want launch to run the Flutter app for me, so that Ask UI starts from a real VM Service session. +18. As a Flutter developer, I want launch to capture the VM Service URI automatically, so that I do not copy it from Flutter logs. +19. As a Flutter developer, I want Flutter startup failures returned as structured launch errors, so that the agent can explain or retry the correct step. +20. As a Flutter developer, I want launch to create the Bridge Session before opening the browser, so that the Agent Session Command already has a session id. +21. As a Flutter developer, I want the browser to attach to the existing Bridge Session, so that it does not create a duplicate session. +22. As a Flutter developer, I want the launch command to return the exact Agent Session Command, so that Codex or Claude Code can enter the polling loop immediately. +23. As a Flutter developer, I want the returned Agent Session Command to include the bridge URL and session id, so that it does not rely on port scanning or local state guessing. +24. As a Flutter developer, I want Chat to become Agent ready after launch, so that I can send UI-targeted work from the workbench. +25. As a Flutter developer, I want the Web workbench to open automatically after launch, so that I do not manually assemble a URL. +26. As a Flutter developer, I want a no-open option, so that automated or remote workflows can print the URL without opening a browser. +27. As a Flutter developer, I want the printed workbench URL to remain usable when browser opening fails, so that I can open it manually. +28. As a Flutter developer, I want the same local origin to serve the Web workbench and Bridge API, so that installed startup has fewer moving parts. +29. As a Flutter developer, I do not want to install Node dependencies just to use Ask UI, so that React and Vite remain implementation details. +30. As a Flutter developer, I want the packaged Web workbench to be included in the bridge package, so that `launch` works after pub installation. +31. As an Ask UI maintainer, I want release preparation to build Web assets into the bridge package, so that pub publishing contains all runtime workbench files. +32. As an Ask UI maintainer, I want bridge package publish rules to include packaged Web assets, so that the published artifact is complete. +33. As an Ask UI maintainer, I want development-only generated files excluded from normal commits, so that package hygiene stays clean. +34. As an Ask UI contributor, I want an explicit Web development mode, so that I can use Vite hot reload while working on the workbench. +35. As an Ask UI contributor, I want packaged Web to be the default launch path, so that development conveniences do not leak into installed-user behavior. +36. As an Ask UI contributor, I want launch to capture the actual Web dev server URL, so that alternate Vite ports work. +37. As a coding agent, I want launch output to be JSON, so that I can parse status, errors, URLs, and next steps reliably. +38. As a coding agent, I want device-selection output to include the command to rerun with the selected device, so that I can preserve flavor and target options. +39. As a coding agent, I want launch success output to include a clear next step, so that I run the Agent Session Command rather than replying too early. +40. As a coding agent, I want launch failures to use stable error codes, so that I can decide whether to ask the user, retry, or stop. +41. As a coding agent, I want the launch skill to stay thin, so that fragile process orchestration lives in tested CLI code. +42. As a coding agent, I want the skill to prefer the installed bridge command, so that project-local package versions are honored. +43. As a coding agent, I want the skill to support the monorepo development fallback, so that Ask UI can dogfood its own source checkout. +44. As a coding agent, I want the skill to ask the user only when device selection is needed, so that startup remains mostly automatic. +45. As a coding agent, I want polled Ask UI messages to become current task input, so that selected widgets, comments, and snapshots drive normal code edits. +46. As a coding agent, I want to write replies through the Agent Session Command, so that Chat History stays synchronized with the browser. +47. As a coding agent, I want to continue polling after each reply, so that the browser feedback loop remains live. +48. As a coding agent, I want command-level workflow errors to be reported through the agent error path, so that the user sees failures in Chat without confusing them with normal replies. +49. As a Flutter developer, I want launch to cleanly stop its child processes when interrupted, so that Flutter, bridge, and Web dev processes do not linger unexpectedly. +50. As a Flutter developer, I want launch logs and JSON output separated, so that agents can parse output without filtering noisy process logs. +51. As a Flutter developer, I want launch to preserve existing Bridge Session API behavior, so that current Web features continue working. +52. As a Flutter developer, I want existing URL bootstrap by VM Service, project root, and device id to keep working, so that current development and tests are not broken. +53. As a Flutter developer, I want the new session attach URL to be additive, so that launch can improve startup without removing the older bootstrap path. +54. As a Flutter developer, I want launch to avoid global installation as the default, so that version mismatch between runtime, bridge, and skill is less likely. +55. As a Flutter developer, I want a global command to remain possible later, so that convenience installs can be added without changing the core contract. +56. As an Ask UI maintainer, I want launch modules to be deep and testable, so that device discovery, Flutter startup, static serving, and browser opening can evolve independently. +57. As an Ask UI maintainer, I want packaging validation, so that releases do not accidentally omit the Web workbench. +58. As an Ask UI maintainer, I want launch to match the existing Agent Chat Long-Poll model, so that browser feedback keeps flowing through the original Agent Session. + +## Implementation Decisions + +- Add `launch` as a bridge CLI command alongside the existing server start path and Agent Session Command. +- Keep `agent poll` as the canonical command for receiving Ask UI Chat messages and writing replies or command-level errors. +- Make the installed-user default command project-local through Dart package execution. +- Treat global installation as optional convenience, not the default skill path. +- Keep the coding-agent skill thin; it calls the launcher and follows returned next-step instructions. +- Use machine-readable JSON as the launch command output contract. +- Use stable launch statuses such as ready, needs-device-selection, and error. +- Include stable launch error codes for missing devices, ambiguous device selection, Flutter startup failure, bridge startup failure, session creation failure, packaged Web missing, browser open failure where appropriate, and invalid arguments. +- Discover devices through Flutter's machine-readable device listing. +- Match explicit device ids exactly after trimming. +- Allow unambiguous device-name matching, but do not guess when names match multiple devices. +- Preserve flavor, target, Dart defines, project root, and open/no-open intent in device-selection continuation output. +- Treat Flutter flavor as app launch metadata, not Bridge Session identity. +- Pass flavor, target, and Dart defines to Flutter run without hardcoding project-specific flavor maps. +- Allow future optional configuration for flavor-to-target defaults, but keep first-version launch explicit. +- Capture the VM Service URI from Flutter run output. +- Keep Flutter app process management inside a dedicated launcher module with a small interface. +- Start or reuse the local Bridge Server before creating the Bridge Session. +- Create the Bridge Session from launch after VM Service URI, project root, and device id are known. +- Return the session id from launch success output. +- Return an executable Agent Session Command string from launch success output. +- Return the bridge URL from launch success output and avoid port scanning in the Agent Session Command. +- Add Web attach bootstrap using bridge URL and session id. +- Preserve the existing Web create bootstrap using VM Service URI, project root, and device id. +- Make attach bootstrap avoid creating a second Bridge Session. +- Make Web clients use the attached bridge origin for Chat, Widget Tree, events, Live App Surface, snapshots, inspector actions, and hot actions. +- Serve packaged Web static files from the Bridge Server in installed-user mode. +- Keep `/api` routing distinct from Web static fallback. +- Return Web `index.html` for non-API workbench routes so refresh and deep links work. +- Keep unknown API routes as JSON API failures rather than Web fallback. +- Build the React/TypeScript Web app before publishing the bridge package. +- Copy the Web build output into the bridge package artifact before publishing. +- Ensure publish ignore rules include required packaged Web files and exclude local-only artifacts. +- Add a release preparation command or script that validates the packaged Web layout. +- Default launch to packaged Web. +- Add explicit Web development mode for contributors who want Vite hot reload. +- Web development mode should capture the actual dev server URL rather than assuming a fixed port. +- Browser opening should be best-effort; launch output must always include the workbench URL when the session is otherwise ready. +- Keep process logs off stdout when stdout is used for JSON. +- Use stderr or log files for child process diagnostics where needed. +- Keep launch foreground in the first version so Ctrl-C can clean up child processes. +- Consider detached launch, status, and stop commands as later extensions. +- Use deep modules for launch parsing, device discovery, Flutter app launch, Bridge Server launch, packaged Web serving, Web session bootstrap, browser opening, release packaging, and skill workflow. +- Avoid duplicating complex Flutter, bridge, Web, or browser orchestration inside the skill body. + +## Testing Decisions + +- Tests should verify externally observable contracts: command arguments, JSON output, selected device behavior, spawned Flutter command shape, VM Service parsing, Bridge Session creation calls, static HTTP responses, Web bootstrap states, generated URLs, and skill workflow text. +- Avoid tests that depend on private helper names or incidental implementation state. +- Launch command tests should cover existing command compatibility, argument parsing, invalid arguments, flavor and target preservation, repeatable Dart defines, no-open behavior, and Web dev flag behavior. +- Device discovery tests should use fake Flutter machine output covering no devices, one usable device, multiple devices, explicit id match, unambiguous name match, ambiguous name match, unsupported devices, malformed output, and Flutter command failure. +- Flutter app launcher tests should cover command construction, flavor and target pass-through, Dart define pass-through, VM Service URI parsing, startup failure before VM Service, and process cleanup on interruption. +- Bridge Server launcher tests should cover start, reuse, port conflict, bridge URL output, and session creation failure propagation. +- Packaged Web serving tests should cover serving index, serving assets, API route preservation, unknown API JSON errors, Web fallback behavior, and missing packaged Web behavior. +- Web session bootstrap tests should cover attach mode, existing create mode, incomplete attach parameters, incomplete create parameters, bridge origin resolution, and no duplicate session creation in attach mode. +- Browser launcher tests should cover generated workbench URL parameters, no-open behavior, successful open, failed open with URL retained, and metadata such as device id and flavor in the URL. +- Release packaging validation should check that Web build output exists in the bridge package artifact layout and that required files are not ignored by publish rules. +- Skill workflow validation should cover launch success, device-selection rerun, launch error handling, running the returned Agent Session Command, replying with reply-to correlation, command-level error reporting, and continuing to poll. +- Existing bridge server tests are prior art for HTTP routing and JSON response behavior. +- Existing Web session bootstrap tests are prior art for focused URL parsing and session state tests. +- Existing Agent Session Command tests are prior art for machine-readable CLI output and poll/reply loop contracts. +- First-version tests do not need to run against a real physical device, real browser, real pub publish, or real coding-agent runtime. + +## Out of Scope + +- Remote or hosted Ask UI service startup. +- Multi-user shared browser sessions. +- Multiple active Agent Session pollers for one Bridge Session. +- Automatic installation of Flutter, Dart, Node, or platform device tooling. +- Project-specific flavor-to-target configuration in the first version. +- Global CLI installation as the default documented skill path. +- Detached daemon lifecycle, launch status, and launch stop commands. +- Automatic recovery from Flutter process crashes after a successful launch. +- Port scanning by the Agent Session Command. +- Browser-created session id discovery by the Agent Session. +- Replacing the existing Agent Session Command protocol. +- Requiring installed users to run the React/Vite development server. +- Publishing the Web source as a separate user-installed package. +- Persisting launch sessions across bridge backend restart. + +## Further Notes + +- This PRD follows the same architectural shape proven by Lavish Editor: a thin skill delegates process orchestration and long-poll mechanics to a tested CLI/server contract. +- The React/TypeScript Web app remains source code for maintainers, while installed users receive compiled static assets inside the bridge package. +- The Bridge Session remains the owner of Chat History, Agent Status, device binding, and Agent Session poller handoff. +- The Agent Session remains the owner of code editing authority and communicates through the poll/reply loop. +- Published GitHub issue: https://github.com/drown0315/ask_ui/issues/28 diff --git a/issues/0.0.4-ask-ui-launch.md b/issues/0.0.4-ask-ui-launch.md new file mode 100644 index 0000000..19ccb19 --- /dev/null +++ b/issues/0.0.4-ask-ui-launch.md @@ -0,0 +1,226 @@ +# Issues 0.0.4: Ask UI Launch + +This issue set breaks the Ask UI launch and packaging plan into tracer-bullet slices. + +Source plan: + +- Conversation plan for a Lavish-style Ask UI launcher. +- `docs/adr/0003-agent-chat-long-poll.md` +- `docs/product/agent-session-command-prd.md` + +## Issue 1: Serve Packaged Web From Bridge + +Type: AFK + +## What to build + +Serve the built Ask UI Web workbench from the Bridge Server so installed users do not need to run the React/Vite development server. + +The bridge should continue serving all existing `/api` routes while also returning the packaged workbench static files from the pub package. The release build should copy the React build output into the bridge package because pub publishing only includes files under the bridge package root. + +## Acceptance criteria + +- [ ] Bridge Server can return the packaged workbench `index.html` from the local HTTP server. +- [ ] Bridge Server can return packaged workbench static assets such as JavaScript and CSS files. +- [ ] Existing `/api` routes keep their current behavior while static serving is enabled. +- [ ] Unknown API routes still return JSON API errors rather than falling through to the workbench document. +- [ ] Non-API workbench routes fall back to `index.html` so browser refresh works. +- [ ] The packaged static file location lives under the bridge package root. +- [ ] The web build output is copied into that bridge package location during release preparation. +- [ ] Tests cover static file serving, API route preservation, and missing static file behavior. + +## Blocked by + +None - can start immediately. + +## Issue 2: Attach Web To Existing Bridge Session + +Type: AFK + +## What to build + +Allow the Web workbench to attach to a Bridge Session that was already created by the launch command. + +The existing URL bootstrap path should keep working: when the URL contains `vmServiceUri`, `projectRoot`, and `deviceId`, Web creates or reuses a Bridge Session through `/api/sessions`. The new path should read `bridgeUrl` and `sessionId` from the URL and attach directly to that session without creating another one. This lets launch create the session first, then open a browser that immediately joins it. + +## Acceptance criteria + +- [ ] Web recognizes a ready attach bootstrap when the URL contains `bridgeUrl` and `sessionId`. +- [ ] Attach bootstrap does not call `POST /api/sessions`. +- [ ] Attach bootstrap sets the ready session id used by the workbench. +- [ ] Attach bootstrap preserves the bridge origin used by chat, widget tree, events, live app surface, snapshots, and inspector clients. +- [ ] Existing create bootstrap from `vmServiceUri`, `projectRoot`, and `deviceId` still works. +- [ ] Incomplete bootstrap errors identify the missing fields for the selected bootstrap mode. +- [ ] Tests cover attach mode, create mode, and incomplete URL combinations. + +## Blocked by + +None - can start immediately. + +## Issue 3: Add Launch CLI Device And Flavor Contract + +Type: AFK + +## What to build + +Add the first `launch` command contract to the bridge CLI. + +The command should parse launch options, discover Flutter devices when needed, preserve Flutter flavor-related arguments, and return machine-readable JSON that a coding agent can act on. This slice does not need to start the full app yet; it establishes the command surface and the device selection path. + +## Acceptance criteria + +- [ ] The bridge entrypoint supports `launch` without breaking the existing server start path. +- [ ] The bridge entrypoint keeps the existing `agent poll` command behavior. +- [ ] `launch` accepts `--device`. +- [ ] `launch` accepts `--flavor`. +- [ ] `launch` accepts `--target` and maps it to Flutter target intent. +- [ ] `launch` accepts repeatable `--dart-define` values. +- [ ] `launch` accepts `--project-root`. +- [ ] `launch` accepts `--no-open`. +- [ ] `launch` discovers devices with Flutter's machine-readable device output when `--device` is omitted. +- [ ] No usable devices returns one JSON error object. +- [ ] Multiple usable devices returns `status: needs_device_selection` with device choices. +- [ ] A single usable device can be selected automatically. +- [ ] A provided device id or unambiguous device name selects that target. +- [ ] An ambiguous provided device name returns a device-selection result instead of guessing. +- [ ] Device-selection output preserves flavor, target, dart defines, and project root in the suggested next command. +- [ ] Tests cover argument parsing, device discovery outcomes, flavor argument preservation, and existing command compatibility. + +## Blocked by + +None - can start immediately. + +## Issue 4: Launch Flutter App And Create Bridge Session + +Type: AFK + +## What to build + +Make `launch` start the target Flutter app and create the Bridge Session needed by the workbench and Agent Session Command. + +For a selected device, launch should run Flutter with the requested device, flavor, target, dart defines, and mode defaults. It should capture the VM service URI, start or reuse the local Bridge Server, create a Bridge Session through the existing session API, and return the connection details plus the exact agent poll command. + +## Acceptance criteria + +- [ ] `launch --device ` starts the Flutter app on the selected device. +- [ ] `--flavor`, `--target`, and repeatable `--dart-define` are passed to Flutter run. +- [ ] `launch` captures the Flutter VM service URI from Flutter run output. +- [ ] `launch` fails with a JSON error when Flutter run fails before exposing a VM service URI. +- [ ] `launch` starts or reuses the local Bridge Server. +- [ ] `launch` creates a Bridge Session with `vmServiceUri`, `projectRoot`, and `deviceId`. +- [ ] Bridge Session creation failures are surfaced as JSON launch errors. +- [ ] Successful output includes bridge URL, session id, selected device, VM service URI, project root, flavor metadata, and target metadata. +- [ ] Successful output includes an executable `agent poll` command using the returned bridge URL and session id. +- [ ] Tests cover Flutter run command construction, VM service parsing, bridge session creation, success output, and failure output. + +## Blocked by + +- Issue 3: Add Launch CLI Device And Flavor Contract + +## Issue 5: Open Packaged Workbench From Launch + +Type: AFK + +## What to build + +Open the packaged Ask UI workbench from the launch command and wire it to the Bridge Session created by launch. + +After the Bridge Session exists, launch should construct a workbench URL served by the Bridge Server, include attach parameters, and open the user's browser unless `--no-open` is present. The command output should give the agent both the browser URL and the next Agent Session Command to run. + +## Acceptance criteria + +- [ ] `launch` constructs a workbench URL served by the Bridge Server. +- [ ] The workbench URL includes `bridgeUrl` and `sessionId` attach parameters. +- [ ] The workbench URL includes useful launch metadata such as device id and flavor when present. +- [ ] The browser opens by default after launch succeeds. +- [ ] `--no-open` suppresses browser opening without changing the generated URL. +- [ ] Successful output includes the workbench URL. +- [ ] Successful output includes a `nextStep` telling the agent to run the returned agent command. +- [ ] The opened Web workbench attaches to the existing session instead of creating a duplicate session. +- [ ] Tests cover URL construction, no-open behavior, browser open failure handling, and attach-mode integration. + +## Blocked by + +- Issue 1: Serve Packaged Web From Bridge +- Issue 2: Attach Web To Existing Bridge Session +- Issue 4: Launch Flutter App And Create Bridge Session + +## Issue 6: Add Monorepo Web Dev Mode + +Type: AFK + +## What to build + +Support a development-only Web mode for Ask UI contributors while keeping packaged Web as the default installed-user path. + +In the Ask UI monorepo, `launch --web-dev` should start or reuse the Vite dev server and open its URL with the same session attach parameters. Without `--web-dev`, launch should use the packaged workbench served by the Bridge Server. + +## Acceptance criteria + +- [ ] `launch --web-dev` is available for monorepo development. +- [ ] Web dev mode starts the Vite dev server from the Web app package. +- [ ] Web dev mode captures the actual Vite URL, including alternate ports. +- [ ] Web dev mode opens the Vite workbench URL with session attach parameters. +- [ ] Default launch behavior uses packaged Web rather than Vite. +- [ ] If packaged Web is missing and `--web-dev` was not requested, launch returns a clear JSON error. +- [ ] Web dev mode is not required for installed users. +- [ ] Tests cover mode selection, Vite URL parsing, fallback errors, and generated Web dev URLs. + +## Blocked by + +- Issue 4: Launch Flutter App And Create Bridge Session + +## Issue 7: Prepare Pub Package Executable And Release Build + +Type: AFK + +## What to build + +Prepare `ask_ui_bridge` for pub package installation as the default user-facing launcher. + +The bridge package should expose an executable so Flutter projects can add `ask_ui_bridge` as a dev dependency and run `dart run ask_ui_bridge launch`. Release preparation should build the Web app, copy the static output into the bridge package, and keep generated or local-only files out of normal development commits except where they are required for the pub artifact. + +## Acceptance criteria + +- [ ] The bridge package declares an executable for `dart run ask_ui_bridge`. +- [ ] Package metadata is suitable for pub publishing. +- [ ] The release build runs the Web build before publishing. +- [ ] The release build copies Web build output into the bridge package static directory. +- [ ] Publish ignore rules do not exclude files required by the packaged workbench. +- [ ] Development-only artifacts such as `node_modules`, Vite build info, and local cache files remain excluded. +- [ ] Documentation or command help shows the installed-user command `dart run ask_ui_bridge launch`. +- [ ] Tests or validation scripts cover the release build's expected packaged file layout. + +## Blocked by + +- Issue 1: Serve Packaged Web From Bridge +- Issue 3: Add Launch CLI Device And Flavor Contract + +## Issue 8: Add Ask UI Launch Skill Workflow + +Type: AFK + +## What to build + +Add the project skill workflow that lets Codex, Claude Code, and similar coding-agent sessions start Ask UI and enter the Agent Session Command loop. + +The skill should stay thin. It should call the launcher, handle device selection results, run the returned agent poll command, process Ask UI messages as normal agent tasks, write replies through the Agent Session Command, and continue polling until the session ends or the user asks to stop. + +## Acceptance criteria + +- [ ] The skill instructs agents to prefer `dart run ask_ui_bridge launch`. +- [ ] The skill documents the monorepo development fallback command. +- [ ] The skill explains how to pass optional device and flavor arguments. +- [ ] The skill handles `needs_device_selection` by asking the user to choose and rerunning launch with the selected device. +- [ ] The skill handles `ready` by running the returned agent command. +- [ ] The skill treats returned poll messages as current-session task input. +- [ ] The skill tells the agent to reply with `agent poll --reply-to --agent-reply `. +- [ ] The skill tells the agent to report command-level workflow errors with `--agent-error` when appropriate. +- [ ] The skill tells the agent to keep polling after each reply unless the user asks to stop. +- [ ] The skill does not duplicate fragile Flutter, bridge, Web, or browser orchestration details that belong in the CLI. +- [ ] Validation covers the documented launch, device selection, poll, reply, and continue-poll paths. + +## Blocked by + +- Issue 3: Add Launch CLI Device And Flavor Contract +- Issue 5: Open Packaged Workbench From Launch From 0b802851a1f1f8ac0dafdb39471151658d7027b1 Mon Sep 17 00:00:00 2001 From: drown0315 Date: Fri, 10 Jul 2026 11:53:45 +0800 Subject: [PATCH 2/8] feat: serve packaged web from bridge --- apps/bridge/bin/ask_ui_bridge.dart | 14 ++ .../lib/server/ask_ui_bridge_server.dart | 103 +++++++++++++ .../server/bridge_server_test_harness.dart | 4 + .../bridge/test/server/packaged_web_test.dart | 141 ++++++++++++++++++ apps/bridge/web/README.md | 4 + apps/web/package.json | 3 +- apps/web/scripts/copy-dist-to-bridge.mjs | 30 ++++ apps/web/scripts/copy-dist-to-bridge.test.mjs | 38 +++++ 8 files changed, 336 insertions(+), 1 deletion(-) create mode 100644 apps/bridge/test/server/packaged_web_test.dart create mode 100644 apps/bridge/web/README.md create mode 100644 apps/web/scripts/copy-dist-to-bridge.mjs create mode 100644 apps/web/scripts/copy-dist-to-bridge.test.mjs diff --git a/apps/bridge/bin/ask_ui_bridge.dart b/apps/bridge/bin/ask_ui_bridge.dart index 24374e8..f394139 100644 --- a/apps/bridge/bin/ask_ui_bridge.dart +++ b/apps/bridge/bin/ask_ui_bridge.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'dart:isolate'; import 'package:ask_ui_bridge/agent_command/agent_session_command.dart'; import 'package:ask_ui_bridge/app_controller/flutter_app_controller.dart'; @@ -23,6 +24,7 @@ Future main(List args) async { final host = _readOption(args, '--host') ?? InternetAddress.loopbackIPv4.host; final port = int.tryParse(_readOption(args, '--port') ?? '') ?? 8787; final logger = BridgeLogger(write: stdout.writeln); + final packagedWebRoot = await _resolvePackagedWebRoot(); final server = AskUiBridgeServer( sessionStore: SessionStore(), @@ -33,6 +35,7 @@ Future main(List args) async { vmServiceFactory: VmServiceFactory(), logger: logger, ), + packagedWebRoot: packagedWebRoot, logger: logger, ); final boundPort = await server.start(host: host, port: port); @@ -51,3 +54,14 @@ String? _readOption(List args, String name) { } return args[index + 1]; } + +Future _resolvePackagedWebRoot() async { + final Uri? serverLibraryUri = await Isolate.resolvePackageUri( + Uri.parse('package:ask_ui_bridge/server/ask_ui_bridge_server.dart'), + ); + if (serverLibraryUri == null) { + return Directory('web'); + } + + return Directory.fromUri(serverLibraryUri.resolve('../../web')); +} diff --git a/apps/bridge/lib/server/ask_ui_bridge_server.dart b/apps/bridge/lib/server/ask_ui_bridge_server.dart index c117df5..16fb1bc 100644 --- a/apps/bridge/lib/server/ask_ui_bridge_server.dart +++ b/apps/bridge/lib/server/ask_ui_bridge_server.dart @@ -26,6 +26,7 @@ class AskUiBridgeServer { FlutterDeviceChecker? flutterDeviceChecker, DeviceStreamFactory? deviceStreamFactory, SnapshotCapture? snapshotCapture, + Directory? packagedWebRoot, bool Function(String path)? snapshotFileExists, bool Function(String projectRoot)? projectRootExists, Duration sessionEventsHeartbeatInterval = const Duration(seconds: 15), @@ -47,6 +48,7 @@ class AskUiBridgeServer { logger: logger ?? BridgeLogger(write: print), ), _snapshotCapture = snapshotCapture ?? AdbSnapshotCapture().capture, + _packagedWebRoot = packagedWebRoot, _snapshotFileExists = snapshotFileExists ?? ((path) => File(path).existsSync()), _chatIngress = ChatIngress( @@ -66,6 +68,7 @@ class AskUiBridgeServer { final BridgeSessionCreator _sessionCreator; final DeviceWebSocketSession _deviceWebSocketSession; final SnapshotCapture _snapshotCapture; + final Directory? _packagedWebRoot; final bool Function(String path) _snapshotFileExists; final ChatIngress _chatIngress; final BridgeSessionEventStream _sessionEventStream; @@ -245,6 +248,20 @@ class AskUiBridgeServer { return; } + if (_isApiRequest(request)) { + await _writeJson( + request.response, + statusCode: HttpStatus.notFound, + body: {'error': 'not_found'}, + ); + return; + } + + if ((request.method == 'GET' || request.method == 'HEAD') && + await _servePackagedWeb(request)) { + return; + } + await _writeJson( request.response, statusCode: HttpStatus.notFound, @@ -252,6 +269,92 @@ class AskUiBridgeServer { ); } + bool _isApiRequest(HttpRequest request) { + return request.uri.pathSegments.isNotEmpty && + request.uri.pathSegments.first == 'api'; + } + + Future _servePackagedWeb(HttpRequest request) async { + final Directory? webRoot = _packagedWebRoot; + if (webRoot == null) { + return false; + } + + final File indexFile = + File('${webRoot.path}${Platform.pathSeparator}index.html'); + if (!webRoot.existsSync() || !indexFile.existsSync()) { + await _writeJson( + request.response, + statusCode: HttpStatus.notFound, + body: {'error': 'packaged_web_not_found'}, + ); + return true; + } + + File file = indexFile; + if (request.uri.path != '/') { + final List pathSegments = request.uri.pathSegments; + if (pathSegments.every((segment) => segment != '..')) { + final String path = [ + webRoot.path, + ...pathSegments, + ].join(Platform.pathSeparator); + final File candidate = File(path); + if (candidate.existsSync()) { + file = candidate; + } else if (_isPackagedAssetRequest(pathSegments)) { + await _writeJson( + request.response, + statusCode: HttpStatus.notFound, + body: {'error': 'packaged_web_asset_not_found'}, + ); + return true; + } + } + } + + request.response.statusCode = HttpStatus.ok; + request.response.headers.contentType = _contentTypeFor(file.path); + if (request.method == 'GET') { + await request.response.addStream(file.openRead()); + } + await request.response.close(); + return true; + } + + bool _isPackagedAssetRequest(List pathSegments) { + if (pathSegments.isEmpty) { + return false; + } + + return pathSegments.first == 'assets' || pathSegments.last.contains('.'); + } + + ContentType _contentTypeFor(String path) { + if (path.endsWith('.html')) { + return ContentType.html; + } + if (path.endsWith('.js')) { + return ContentType('application', 'javascript', charset: 'utf-8'); + } + if (path.endsWith('.css')) { + return ContentType('text', 'css', charset: 'utf-8'); + } + if (path.endsWith('.json')) { + return ContentType.json; + } + if (path.endsWith('.svg')) { + return ContentType('image', 'svg+xml'); + } + if (path.endsWith('.png')) { + return ContentType('image', 'png'); + } + if (path.endsWith('.ico')) { + return ContentType('image', 'x-icon'); + } + return ContentType.binary; + } + Future _createSession(HttpRequest request) async { _logger.info('session create start'); final Map? body = await _readJsonObject( diff --git a/apps/bridge/test/server/bridge_server_test_harness.dart b/apps/bridge/test/server/bridge_server_test_harness.dart index e59997f..058ea60 100644 --- a/apps/bridge/test/server/bridge_server_test_harness.dart +++ b/apps/bridge/test/server/bridge_server_test_harness.dart @@ -29,6 +29,7 @@ class BridgeServerFixture { ), DeviceStreamFactory deviceStreamFactory = const ShellDeviceStreamFactory(), RecordingSnapshotCapture? snapshotCapture, + Directory? packagedWebRoot, bool Function(String projectRoot) projectRootExists = _projectRootExists, Duration sessionEventsHeartbeatInterval = const Duration(seconds: 15), }) async { @@ -42,6 +43,7 @@ class BridgeServerFixture { flutterDeviceChecker: flutterDeviceChecker, deviceStreamFactory: deviceStreamFactory, snapshotCapture: this.snapshotCapture, + packagedWebRoot: packagedWebRoot, snapshotFileExists: existingSnapshotPaths.contains, projectRootExists: projectRootExists, sessionEventsHeartbeatInterval: sessionEventsHeartbeatInterval, @@ -86,6 +88,7 @@ class BridgeServerFixture { required FlutterDeviceChecker flutterDeviceChecker, required DeviceStreamFactory deviceStreamFactory, required RecordingSnapshotCapture snapshotCapture, + Directory? packagedWebRoot, required bool Function(String path) snapshotFileExists, required bool Function(String projectRoot) projectRootExists, required Duration sessionEventsHeartbeatInterval, @@ -97,6 +100,7 @@ class BridgeServerFixture { flutterDeviceChecker: flutterDeviceChecker, deviceStreamFactory: deviceStreamFactory, snapshotCapture: snapshotCapture.capture, + packagedWebRoot: packagedWebRoot, snapshotFileExists: snapshotFileExists, projectRootExists: projectRootExists, sessionEventsHeartbeatInterval: sessionEventsHeartbeatInterval, diff --git a/apps/bridge/test/server/packaged_web_test.dart b/apps/bridge/test/server/packaged_web_test.dart new file mode 100644 index 0000000..b45315d --- /dev/null +++ b/apps/bridge/test/server/packaged_web_test.dart @@ -0,0 +1,141 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:file_testkit/file_testkit.dart'; +import 'package:test/test.dart'; + +import 'bridge_server_test_harness.dart'; + +void main() { + group('AskUiBridgeServer packaged Web', () { + test('serves packaged workbench index from root', () async { + await FileTestkit.runZoned(() async { + final fixture = await startFixture(); + final client = HttpClient(); + addTearDown(client.close); + + final request = await client.getUrl(fixture.baseUri.resolve('/')); + final response = await request.close(); + final body = await utf8.decodeStream(response); + + expect(response.statusCode, HttpStatus.ok); + expect(response.headers.contentType?.mimeType, 'text/html'); + expect(body, contains('Ask UI')); + }); + }); + + test('serves packaged JavaScript assets', () async { + await FileTestkit.runZoned(() async { + final fixture = await startFixture(); + final client = HttpClient(); + addTearDown(client.close); + + final request = await client.getUrl( + fixture.baseUri.resolve('/assets/app.js'), + ); + final response = await request.close(); + final body = await utf8.decodeStream(response); + + expect(response.statusCode, HttpStatus.ok); + expect( + response.headers.contentType?.mimeType, + 'application/javascript', + ); + expect(body, 'console.log("ask ui");'); + }); + }); + + test('keeps unknown API routes as JSON errors', () async { + await FileTestkit.runZoned(() async { + final fixture = await startFixture(); + final client = HttpClient(); + addTearDown(client.close); + + final request = await client.getUrl( + fixture.baseUri.resolve('/api/missing'), + ); + final response = await request.close(); + final body = jsonDecode(await utf8.decodeStream(response)) + as Map; + + expect(response.statusCode, HttpStatus.notFound); + expect(response.headers.contentType?.mimeType, 'application/json'); + expect(body, {'error': 'not_found'}); + }); + }); + + test('falls back to index for non-API workbench routes', () async { + await FileTestkit.runZoned(() async { + final fixture = await startFixture(); + final client = HttpClient(); + addTearDown(client.close); + + final request = await client.getUrl( + fixture.baseUri.resolve('/sessions/session-1/comments'), + ); + final response = await request.close(); + final body = await utf8.decodeStream(response); + + expect(response.statusCode, HttpStatus.ok); + expect(response.headers.contentType?.mimeType, 'text/html'); + expect(body, contains('Ask UI')); + }); + }); + + test('returns JSON not found for missing packaged assets', () async { + await FileTestkit.runZoned(() async { + final fixture = await startFixture(); + final client = HttpClient(); + addTearDown(client.close); + + final request = await client.getUrl( + fixture.baseUri.resolve('/assets/missing.js'), + ); + final response = await request.close(); + final body = jsonDecode(await utf8.decodeStream(response)) + as Map; + + expect(response.statusCode, HttpStatus.notFound); + expect(response.headers.contentType?.mimeType, 'application/json'); + expect(body, {'error': 'packaged_web_asset_not_found'}); + }); + }); + + test('returns JSON not found when packaged Web root is missing', () async { + await FileTestkit.runZoned(() async { + final packagedWebRoot = Directory('/ask-ui-packaged-web-test'); + final fixture = BridgeServerFixture(); + await fixture.start(packagedWebRoot: packagedWebRoot); + addTearDown(fixture.close); + final client = HttpClient(); + addTearDown(client.close); + + final request = await client.getUrl(fixture.baseUri.resolve('/')); + final response = await request.close(); + final body = jsonDecode(await utf8.decodeStream(response)) + as Map; + + expect(response.statusCode, HttpStatus.notFound); + expect(response.headers.contentType?.mimeType, 'application/json'); + expect(body, {'error': 'packaged_web_not_found'}); + }); + }); + }); +} + +Future startFixture() async { + final packagedWebRoot = Directory('/ask-ui-packaged-web-test'); + await packagedWebRoot.create(); + await File('${packagedWebRoot.path}/index.html').writeAsString( + '
Ask UI
', + ); + await Directory('${packagedWebRoot.path}/assets').create(); + await File('${packagedWebRoot.path}/assets/app.js').writeAsString( + 'console.log("ask ui");', + ); + + final fixture = BridgeServerFixture(); + await fixture.start(packagedWebRoot: packagedWebRoot); + addTearDown(fixture.close); + return fixture; +} diff --git a/apps/bridge/web/README.md b/apps/bridge/web/README.md new file mode 100644 index 0000000..4fdb8b4 --- /dev/null +++ b/apps/bridge/web/README.md @@ -0,0 +1,4 @@ +# Packaged Ask UI Web + +Release preparation copies the built Ask UI Web workbench into this directory +so the Bridge Server can serve it from the Dart package. diff --git a/apps/web/package.json b/apps/web/package.json index 0b96776..635baa3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,8 +6,9 @@ "scripts": { "dev": "vite", "build": "npm run typecheck && vite build", + "build:bridge": "npm run build && node scripts/copy-dist-to-bridge.mjs", "lint": "eslint . --max-warnings 0", - "test": "node --test \"src/**/*.test.ts\"", + "test": "node --test \"src/**/*.test.ts\" \"scripts/**/*.test.mjs\"", "typecheck": "tsc --noEmit", "preview": "vite preview" }, diff --git a/apps/web/scripts/copy-dist-to-bridge.mjs b/apps/web/scripts/copy-dist-to-bridge.mjs new file mode 100644 index 0000000..b080fa8 --- /dev/null +++ b/apps/web/scripts/copy-dist-to-bridge.mjs @@ -0,0 +1,30 @@ +import { cp, mkdir, rm, stat } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const scriptPath = fileURLToPath(import.meta.url); +const webRoot = path.resolve(path.dirname(scriptPath), ".."); +const repoRoot = path.resolve(webRoot, "..", ".."); + +export async function copyDistToBridge({ + webDistDir = path.join(webRoot, "dist"), + bridgeWebDir = path.join(repoRoot, "apps", "bridge", "web"), +} = {}) { + await assertFileExists(path.join(webDistDir, "index.html")); + await rm(bridgeWebDir, { recursive: true, force: true }); + await mkdir(bridgeWebDir, { recursive: true }); + await cp(webDistDir, bridgeWebDir, { recursive: true }); +} + +async function assertFileExists(filePath) { + const fileStat = await stat(filePath).catch(() => null); + if (!fileStat?.isFile()) { + throw new Error( + `Expected built Web index.html at ${filePath}. Run npm run build first.`, + ); + } +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + await copyDistToBridge(); +} diff --git a/apps/web/scripts/copy-dist-to-bridge.test.mjs b/apps/web/scripts/copy-dist-to-bridge.test.mjs new file mode 100644 index 0000000..a825ac6 --- /dev/null +++ b/apps/web/scripts/copy-dist-to-bridge.test.mjs @@ -0,0 +1,38 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import assert from "node:assert/strict"; + +import { copyDistToBridge } from "./copy-dist-to-bridge.mjs"; + +test("copyDistToBridge replaces bridge web contents with Vite dist", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ask-ui-copy-web-")); + const webDistDir = path.join(root, "dist"); + const bridgeWebDir = path.join(root, "bridge-web"); + + try { + await mkdir(path.join(webDistDir, "assets"), { recursive: true }); + await mkdir(bridgeWebDir, { recursive: true }); + await writeFile(path.join(webDistDir, "index.html"), "
Ask UI
"); + await writeFile(path.join(webDistDir, "assets", "app.js"), "app"); + await writeFile(path.join(bridgeWebDir, "old.txt"), "old"); + + await copyDistToBridge({ + webDistDir, + bridgeWebDir, + }); + + assert.equal( + await readFile(path.join(bridgeWebDir, "index.html"), "utf8"), + "
Ask UI
", + ); + assert.equal( + await readFile(path.join(bridgeWebDir, "assets", "app.js"), "utf8"), + "app", + ); + await assert.rejects(readFile(path.join(bridgeWebDir, "old.txt"), "utf8")); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); From 3452518527f008af94cd569fb0d9bc4cbc2b130d Mon Sep 17 00:00:00 2001 From: drown0315 Date: Fri, 10 Jul 2026 12:04:01 +0800 Subject: [PATCH 3/8] chore: mark packaged web issue done --- issues/0.0.4-ask-ui-launch.md | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/issues/0.0.4-ask-ui-launch.md b/issues/0.0.4-ask-ui-launch.md index 19ccb19..1834a8e 100644 --- a/issues/0.0.4-ask-ui-launch.md +++ b/issues/0.0.4-ask-ui-launch.md @@ -12,6 +12,10 @@ Source plan: Type: AFK +Status: Done + +Implemented in `0b80285 feat: serve packaged web from bridge`. + ## What to build Serve the built Ask UI Web workbench from the Bridge Server so installed users do not need to run the React/Vite development server. @@ -20,14 +24,14 @@ The bridge should continue serving all existing `/api` routes while also returni ## Acceptance criteria -- [ ] Bridge Server can return the packaged workbench `index.html` from the local HTTP server. -- [ ] Bridge Server can return packaged workbench static assets such as JavaScript and CSS files. -- [ ] Existing `/api` routes keep their current behavior while static serving is enabled. -- [ ] Unknown API routes still return JSON API errors rather than falling through to the workbench document. -- [ ] Non-API workbench routes fall back to `index.html` so browser refresh works. -- [ ] The packaged static file location lives under the bridge package root. -- [ ] The web build output is copied into that bridge package location during release preparation. -- [ ] Tests cover static file serving, API route preservation, and missing static file behavior. +- [x] Bridge Server can return the packaged workbench `index.html` from the local HTTP server. +- [x] Bridge Server can return packaged workbench static assets such as JavaScript and CSS files. +- [x] Existing `/api` routes keep their current behavior while static serving is enabled. +- [x] Unknown API routes still return JSON API errors rather than falling through to the workbench document. +- [x] Non-API workbench routes fall back to `index.html` so browser refresh works. +- [x] The packaged static file location lives under the bridge package root. +- [x] The web build output is copied into that bridge package location during release preparation. +- [x] Tests cover static file serving, API route preservation, and missing static file behavior. ## Blocked by From e167d732334775fc3da86a51ca669b1c060c984e Mon Sep 17 00:00:00 2001 From: drown0315 Date: Fri, 10 Jul 2026 12:31:38 +0800 Subject: [PATCH 4/8] feat: attach web to existing bridge session --- apps/web/src/services/bridgeHttp.test.ts | 19 ++++++- apps/web/src/services/bridgeHttp.ts | 10 +++- .../src/session/bridgeSessionClient.test.ts | 41 ++++++++++++++- apps/web/src/session/sessionBootstrap.test.ts | 40 ++++++++++++++ apps/web/src/session/sessionBootstrap.ts | 52 +++++++++++++++++++ .../src/session/targetDeviceDisplay.test.ts | 15 ++++++ apps/web/src/session/targetDeviceDisplay.ts | 21 ++++++-- apps/web/src/session/useBridgeSession.ts | 37 +++++++++++++ apps/web/src/types/bridgeSession.ts | 6 ++- issues/0.0.4-ask-ui-launch.md | 14 ++--- 10 files changed, 238 insertions(+), 17 deletions(-) diff --git a/apps/web/src/services/bridgeHttp.test.ts b/apps/web/src/services/bridgeHttp.test.ts index ac6fcd3..3ce33f7 100644 --- a/apps/web/src/services/bridgeHttp.test.ts +++ b/apps/web/src/services/bridgeHttp.test.ts @@ -1,7 +1,13 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { parseBridgeJsonResponse, resolveBridgeOrigin } from './bridgeHttp.ts'; +import { + bridgeOrigin, + parseBridgeJsonResponse, + resetBridgeOriginOverride, + resolveBridgeOrigin, + setBridgeOriginOverride, +} from './bridgeHttp.ts'; test('uses the local Dart bridge as the default bridge origin', () => { assert.equal(resolveBridgeOrigin(undefined), 'http://127.0.0.1:8787'); @@ -15,6 +21,17 @@ test('normalizes configured bridge origins', () => { ); }); +test('allows bridge origin to be overridden at runtime', () => { + resetBridgeOriginOverride(); + assert.equal(bridgeOrigin, 'http://127.0.0.1:8787'); + + setBridgeOriginOverride(' http://127.0.0.1:9000/ '); + assert.equal(bridgeOrigin, 'http://127.0.0.1:9000'); + + resetBridgeOriginOverride(); + assert.equal(bridgeOrigin, 'http://127.0.0.1:8787'); +}); + test('reports an empty bridge response without surfacing JSON parse internals', async () => { const response = new Response('', { status: 404, diff --git a/apps/web/src/services/bridgeHttp.ts b/apps/web/src/services/bridgeHttp.ts index f43233b..36ca68c 100644 --- a/apps/web/src/services/bridgeHttp.ts +++ b/apps/web/src/services/bridgeHttp.ts @@ -37,10 +37,18 @@ export async function parseBridgeJsonResponse( } } -export const bridgeOrigin = resolveBridgeOrigin( +export let bridgeOrigin = resolveBridgeOrigin( import.meta.env?.VITE_ASK_UI_BRIDGE_ORIGIN, ); +export function setBridgeOriginOverride(origin: string): void { + bridgeOrigin = resolveBridgeOrigin(origin); +} + +export function resetBridgeOriginOverride(): void { + bridgeOrigin = resolveBridgeOrigin(import.meta.env?.VITE_ASK_UI_BRIDGE_ORIGIN); +} + export function bridgeRequestError( body: { error?: string; message?: string }, fallbackMessage: string, diff --git a/apps/web/src/session/bridgeSessionClient.test.ts b/apps/web/src/session/bridgeSessionClient.test.ts index 6283282..44a0402 100644 --- a/apps/web/src/session/bridgeSessionClient.test.ts +++ b/apps/web/src/session/bridgeSessionClient.test.ts @@ -2,6 +2,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { BridgeRequestError } from '../services/bridgeHttp.ts'; + import { + resetBridgeOriginOverride, + setBridgeOriginOverride, +} from '../services/bridgeHttp.ts'; import { createBridgeSession } from './bridgeSessionClient.ts'; import { getDeviceWebSocketUrl } from './deviceBridgeUrl.ts'; @@ -34,8 +38,10 @@ test('can request fixture video on the device WebSocket URL', () => { test('creates bridge session with target device id', async () => { const requestedBodies: unknown[] = []; + const requestedUrls: string[] = []; const originalFetch = globalThis.fetch; - globalThis.fetch = async (_input, init) => { + globalThis.fetch = async (input, init) => { + requestedUrls.push(String(input)); requestedBodies.push(JSON.parse(String(init?.body))); return new Response( JSON.stringify({ @@ -62,6 +68,9 @@ test('creates bridge session with target device id', async () => { deviceId: '19271FDF6007TY', }, ]); + assert.deepEqual(requestedUrls, [ + 'http://127.0.0.1:8787/api/sessions', + ]); assert.deepEqual(result, { sessionId: 'session-1', targetDevice: { @@ -75,6 +84,36 @@ test('creates bridge session with target device id', async () => { } }); +test('creates bridge session through the runtime bridge origin override', async () => { + const requestedUrls: string[] = []; + const originalFetch = globalThis.fetch; + resetBridgeOriginOverride(); + setBridgeOriginOverride('http://127.0.0.1:9000'); + globalThis.fetch = async (input) => { + requestedUrls.push(String(input)); + return new Response( + JSON.stringify({ + sessionId: 'session-1', + }), + ); + }; + + try { + await createBridgeSession({ + vmServiceUri: 'ws://127.0.0.1:12345/ws', + projectRoot: '/Users/example/app', + deviceId: '19271FDF6007TY', + }); + + assert.deepEqual(requestedUrls, [ + 'http://127.0.0.1:9000/api/sessions', + ]); + } finally { + globalThis.fetch = originalFetch; + resetBridgeOriginOverride(); + } +}); + test('reports Target Device bridge session errors with code and message', async () => { const originalFetch = globalThis.fetch; globalThis.fetch = async () => diff --git a/apps/web/src/session/sessionBootstrap.test.ts b/apps/web/src/session/sessionBootstrap.test.ts index 26f52aa..9c7d098 100644 --- a/apps/web/src/session/sessionBootstrap.test.ts +++ b/apps/web/src/session/sessionBootstrap.test.ts @@ -3,12 +3,48 @@ import test from 'node:test'; import { readSessionBootstrap } from './sessionBootstrap.ts'; +test('returns attach bootstrap when bridgeUrl and sessionId are present', () => { + const result = readSessionBootstrap( + 'http://127.0.0.1:5173/?bridgeUrl=++http%3A%2F%2F127.0.0.1%3A9000%2F++&sessionId=++session-1++&deviceId=++19271FDF6007TY++&projectRoot=++%2FUsers%2Fexample%2Fapp++', + ); + + assert.deepEqual(result, { + mode: 'attach', + status: 'ready', + bridgeUrl: 'http://127.0.0.1:9000', + sessionId: 'session-1', + deviceId: '19271FDF6007TY', + projectRoot: '/Users/example/app', + }); +}); + +test('marks attach bootstrap incomplete when sessionId is missing', () => { + const result = readSessionBootstrap( + 'http://127.0.0.1:5173/?bridgeUrl=http%3A%2F%2F127.0.0.1%3A9000', + ); + + assert.equal(result.status, 'incomplete'); + assert.equal(result.mode, 'attach'); + assert.deepEqual(result.missing, ['sessionId']); +}); + +test('marks attach bootstrap incomplete when bridgeUrl is missing but sessionId selects attach mode', () => { + const result = readSessionBootstrap( + 'http://127.0.0.1:5173/?sessionId=session-1', + ); + + assert.equal(result.status, 'incomplete'); + assert.equal(result.mode, 'attach'); + assert.deepEqual(result.missing, ['bridgeUrl']); +}); + test('marks bootstrap incomplete when vmServiceUri is missing', () => { const result = readSessionBootstrap( 'http://127.0.0.1:5173/?projectRoot=%2FUsers%2Fexample%2Fapp&deviceId=19271FDF6007TY', ); assert.equal(result.status, 'incomplete'); + assert.equal(result.mode, 'create'); assert.deepEqual(result.missing, ['vmServiceUri']); }); @@ -18,6 +54,7 @@ test('marks bootstrap incomplete when projectRoot is missing', () => { ); assert.equal(result.status, 'incomplete'); + assert.equal(result.mode, 'create'); assert.deepEqual(result.missing, ['projectRoot']); }); @@ -27,6 +64,7 @@ test('marks bootstrap incomplete when deviceId is missing', () => { ); assert.equal(result.status, 'incomplete'); + assert.equal(result.mode, 'create'); assert.deepEqual(result.missing, ['deviceId']); }); @@ -36,6 +74,7 @@ test('does not accept snake case device_id as the Target Device parameter', () = ); assert.equal(result.status, 'incomplete'); + assert.equal(result.mode, 'create'); assert.deepEqual(result.missing, ['deviceId']); }); @@ -45,6 +84,7 @@ test('returns trimmed bootstrap values when required parameters are present', () ); assert.deepEqual(result, { + mode: 'create', status: 'ready', vmServiceUri: 'ws://127.0.0.1:12345/ws', projectRoot: '/Users/example/app', diff --git a/apps/web/src/session/sessionBootstrap.ts b/apps/web/src/session/sessionBootstrap.ts index 714721e..e79e017 100644 --- a/apps/web/src/session/sessionBootstrap.ts +++ b/apps/web/src/session/sessionBootstrap.ts @@ -1,13 +1,28 @@ export type SessionBootstrap = | { + mode: 'create'; status: 'ready'; vmServiceUri: string; projectRoot: string; deviceId: string; } | { + mode: 'attach'; + status: 'ready'; + bridgeUrl: string; + sessionId: string; + projectRoot?: string; + deviceId?: string; + } + | { + mode: 'create'; status: 'incomplete'; missing: Array<'vmServiceUri' | 'projectRoot' | 'deviceId'>; + } + | { + mode: 'attach'; + status: 'incomplete'; + missing: Array<'bridgeUrl' | 'sessionId'>; }; /** @@ -28,6 +43,41 @@ export type SessionBootstrap = */ export function readSessionBootstrap(url: string): SessionBootstrap { const searchParams = new URL(url).searchParams; + const bridgeUrl = searchParams.get('bridgeUrl')?.trim() ?? ''; + const sessionId = searchParams.get('sessionId')?.trim() ?? ''; + + if (bridgeUrl || sessionId) { + const missing: Array<'bridgeUrl' | 'sessionId'> = []; + + if (!bridgeUrl) { + missing.push('bridgeUrl'); + } + + if (!sessionId) { + missing.push('sessionId'); + } + + if (missing.length > 0) { + return { + mode: 'attach', + status: 'incomplete', + missing, + }; + } + + const deviceId = searchParams.get('deviceId')?.trim() || undefined; + const projectRoot = searchParams.get('projectRoot')?.trim() || undefined; + + return { + mode: 'attach', + status: 'ready', + bridgeUrl: bridgeUrl.replace(/\/+$/, ''), + sessionId, + ...(deviceId ? { deviceId } : {}), + ...(projectRoot ? { projectRoot } : {}), + }; + } + const vmServiceUri = searchParams.get('vmServiceUri')?.trim() ?? ''; const projectRoot = searchParams.get('projectRoot')?.trim() ?? ''; const deviceId = searchParams.get('deviceId')?.trim() ?? ''; @@ -47,12 +97,14 @@ export function readSessionBootstrap(url: string): SessionBootstrap { if (missing.length > 0) { return { + mode: 'create', status: 'incomplete', missing, }; } return { + mode: 'create', status: 'ready', vmServiceUri, projectRoot, diff --git a/apps/web/src/session/targetDeviceDisplay.test.ts b/apps/web/src/session/targetDeviceDisplay.test.ts index 0c70021..e837b62 100644 --- a/apps/web/src/session/targetDeviceDisplay.test.ts +++ b/apps/web/src/session/targetDeviceDisplay.test.ts @@ -74,3 +74,18 @@ test('falls back to the target device id when display name is missing', () => { }, ); }); + +test('describes an attached session without Target Device metadata', () => { + assert.deepEqual( + getTargetDeviceDisplay({ + status: 'ready', + sessionId: 'session-1', + }), + { + topBarLabel: 'Attached session', + surfaceLabel: 'Attached session', + title: 'Attached bridge session', + status: 'ready', + }, + ); +}); diff --git a/apps/web/src/session/targetDeviceDisplay.ts b/apps/web/src/session/targetDeviceDisplay.ts index 9976fef..7a576b1 100644 --- a/apps/web/src/session/targetDeviceDisplay.ts +++ b/apps/web/src/session/targetDeviceDisplay.ts @@ -58,19 +58,30 @@ export function getTargetDeviceDisplay( } const displayName = state.targetDeviceDisplayName?.trim() ?? ''; + const targetDeviceId = state.targetDeviceId?.trim() ?? ''; + + if (!targetDeviceId) { + return { + topBarLabel: 'Attached session', + surfaceLabel: 'Attached session', + title: 'Attached bridge session', + status: 'ready', + }; + } + if (displayName) { return { topBarLabel: displayName, - surfaceLabel: state.targetDeviceId, - title: `${displayName} (${state.targetDeviceId})`, + surfaceLabel: targetDeviceId, + title: `${displayName} (${targetDeviceId})`, status: 'ready', }; } return { - topBarLabel: `Device ${state.targetDeviceId}`, - surfaceLabel: state.targetDeviceId, - title: state.targetDeviceId, + topBarLabel: `Device ${targetDeviceId}`, + surfaceLabel: targetDeviceId, + title: targetDeviceId, status: 'ready', }; } diff --git a/apps/web/src/session/useBridgeSession.ts b/apps/web/src/session/useBridgeSession.ts index 7ad4394..8b0d54f 100644 --- a/apps/web/src/session/useBridgeSession.ts +++ b/apps/web/src/session/useBridgeSession.ts @@ -1,5 +1,9 @@ import { useEffect, useState } from 'react'; import { createBridgeSession } from '../services/askUiBridgeClient'; +import { + resetBridgeOriginOverride, + setBridgeOriginOverride, +} from '../services/bridgeHttp'; import type { BridgeSessionState } from '../types/bridgeSession'; import { readSessionBootstrap } from './sessionBootstrap'; @@ -7,12 +11,28 @@ function initialBridgeSessionState(locationHref: string): BridgeSessionState { const bootstrap = readSessionBootstrap(locationHref); if (bootstrap.status === 'incomplete') { + if (bootstrap.mode === 'create') { + resetBridgeOriginOverride(); + } return { status: 'incomplete', missing: bootstrap.missing, }; } + if (bootstrap.mode === 'attach') { + setBridgeOriginOverride(bootstrap.bridgeUrl); + return { + status: 'ready', + sessionId: bootstrap.sessionId, + projectRoot: bootstrap.projectRoot ?? '', + targetDeviceId: bootstrap.deviceId, + clientId: getBridgeClientId(), + readOnly: false, + }; + } + + resetBridgeOriginOverride(); return { status: 'creating', }; @@ -61,6 +81,9 @@ export function useBridgeSession(locationHref: string): { let isCurrent = true; if (bootstrap.status === 'incomplete') { + if (bootstrap.mode === 'create') { + resetBridgeOriginOverride(); + } setBridgeSessionState({ status: 'incomplete', missing: bootstrap.missing, @@ -68,6 +91,20 @@ export function useBridgeSession(locationHref: string): { return; } + if (bootstrap.mode === 'attach') { + setBridgeOriginOverride(bootstrap.bridgeUrl); + setBridgeSessionState({ + status: 'ready', + sessionId: bootstrap.sessionId, + projectRoot: bootstrap.projectRoot ?? '', + targetDeviceId: bootstrap.deviceId, + clientId: getBridgeClientId(), + readOnly: false, + }); + return; + } + + resetBridgeOriginOverride(); setBridgeSessionState({ status: 'creating' }); const clientId = getBridgeClientId(); diff --git a/apps/web/src/types/bridgeSession.ts b/apps/web/src/types/bridgeSession.ts index 4d82317..a48a8b1 100644 --- a/apps/web/src/types/bridgeSession.ts +++ b/apps/web/src/types/bridgeSession.ts @@ -31,7 +31,9 @@ export type WidgetTreeLoadState = export type BridgeSessionState = | { status: 'incomplete'; - missing: Array<'vmServiceUri' | 'projectRoot' | 'deviceId'>; + missing: Array< + 'vmServiceUri' | 'projectRoot' | 'deviceId' | 'bridgeUrl' | 'sessionId' + >; } | { status: 'creating'; @@ -40,7 +42,7 @@ export type BridgeSessionState = status: 'ready'; sessionId: string; projectRoot: string; - targetDeviceId: string; + targetDeviceId?: string; targetDeviceDisplayName?: string; clientId: string; readOnly: boolean; diff --git a/issues/0.0.4-ask-ui-launch.md b/issues/0.0.4-ask-ui-launch.md index 1834a8e..7430b25 100644 --- a/issues/0.0.4-ask-ui-launch.md +++ b/issues/0.0.4-ask-ui-launch.md @@ -49,13 +49,13 @@ The existing URL bootstrap path should keep working: when the URL contains `vmSe ## Acceptance criteria -- [ ] Web recognizes a ready attach bootstrap when the URL contains `bridgeUrl` and `sessionId`. -- [ ] Attach bootstrap does not call `POST /api/sessions`. -- [ ] Attach bootstrap sets the ready session id used by the workbench. -- [ ] Attach bootstrap preserves the bridge origin used by chat, widget tree, events, live app surface, snapshots, and inspector clients. -- [ ] Existing create bootstrap from `vmServiceUri`, `projectRoot`, and `deviceId` still works. -- [ ] Incomplete bootstrap errors identify the missing fields for the selected bootstrap mode. -- [ ] Tests cover attach mode, create mode, and incomplete URL combinations. +- [x] Web recognizes a ready attach bootstrap when the URL contains `bridgeUrl` and `sessionId`. +- [x] Attach bootstrap does not call `POST /api/sessions`. +- [x] Attach bootstrap sets the ready session id used by the workbench. +- [x] Attach bootstrap preserves the bridge origin used by chat, widget tree, events, live app surface, snapshots, and inspector clients. +- [x] Existing create bootstrap from `vmServiceUri`, `projectRoot`, and `deviceId` still works. +- [x] Incomplete bootstrap errors identify the missing fields for the selected bootstrap mode. +- [x] Tests cover attach mode, create mode, and incomplete URL combinations. ## Blocked by From f394e9a2c5b50c0007f4a9ccd19189e1c724db9a Mon Sep 17 00:00:00 2001 From: drown0315 Date: Fri, 10 Jul 2026 13:13:10 +0800 Subject: [PATCH 5/8] feat: add launch cli device contract --- apps/bridge/bin/ask_ui_bridge.dart | 13 + apps/bridge/lib/launch/launch_command.dart | 390 ++++++++++++++++++ .../test/launch/launch_command_test.dart | 291 +++++++++++++ issues/0.0.4-ask-ui-launch.md | 32 +- 4 files changed, 710 insertions(+), 16 deletions(-) create mode 100644 apps/bridge/lib/launch/launch_command.dart create mode 100644 apps/bridge/test/launch/launch_command_test.dart diff --git a/apps/bridge/bin/ask_ui_bridge.dart b/apps/bridge/bin/ask_ui_bridge.dart index f394139..a532195 100644 --- a/apps/bridge/bin/ask_ui_bridge.dart +++ b/apps/bridge/bin/ask_ui_bridge.dart @@ -4,6 +4,7 @@ import 'dart:isolate'; import 'package:ask_ui_bridge/agent_command/agent_session_command.dart'; import 'package:ask_ui_bridge/app_controller/flutter_app_controller.dart'; import 'package:ask_ui_bridge/inspector/flutter_inspector_client.dart'; +import 'package:ask_ui_bridge/launch/launch_command.dart'; import 'package:ask_ui_bridge/logging/bridge_logger.dart'; import 'package:ask_ui_bridge/server/ask_ui_bridge_server.dart'; import 'package:ask_ui_bridge/sessions/session_store.dart'; @@ -21,6 +22,14 @@ Future main(List args) async { return; } + if (_isLaunchCommand(args)) { + final LaunchCommandResult result = await runLaunchCommand(args); + stdout.write(result.stdout); + stderr.write(result.stderr); + exitCode = result.exitCode; + return; + } + final host = _readOption(args, '--host') ?? InternetAddress.loopbackIPv4.host; final port = int.tryParse(_readOption(args, '--port') ?? '') ?? 8787; final logger = BridgeLogger(write: stdout.writeln); @@ -47,6 +56,10 @@ bool _isAgentCommand(List args) { return args.isNotEmpty && args.first == 'agent'; } +bool _isLaunchCommand(List args) { + return args.isNotEmpty && args.first == 'launch'; +} + String? _readOption(List args, String name) { final index = args.indexOf(name); if (index == -1 || index + 1 >= args.length) { diff --git a/apps/bridge/lib/launch/launch_command.dart b/apps/bridge/lib/launch/launch_command.dart new file mode 100644 index 0000000..6ec3c7f --- /dev/null +++ b/apps/bridge/lib/launch/launch_command.dart @@ -0,0 +1,390 @@ +import 'dart:convert'; +import 'dart:io'; + +typedef FlutterDevicesRunner = Future Function( + String executable, + List arguments, +); + +/// Result returned by the launch command runner. +/// +/// The binary writes `stdout`, `stderr`, and `exitCode` directly. Tests use the +/// same object to verify the JSON contract without spawning a subprocess. +class LaunchCommandResult { + const LaunchCommandResult({ + required this.exitCode, + this.stdout = '', + this.stderr = '', + }); + + final int exitCode; + final String stdout; + final String stderr; +} + +/// Parses the first Ask UI launch contract and selects a Flutter device. +/// +/// This slice does not start Flutter yet. It returns machine-readable launch +/// intent and device-selection output so later launch phases can continue from +/// the same CLI contract. +Future runLaunchCommand( + List args, { + FlutterDevicesRunner listDevices = Process.run, +}) async { + late final _LaunchOptions options; + try { + options = _LaunchOptions.parse(args); + } on _LaunchValidationError { + return _LaunchOutput.failure('invalid_arguments'); + } + + late final List<_LaunchDevice> usableDevices; + try { + usableDevices = await _FlutterDeviceDiscovery( + listDevices: listDevices, + ).discoverUsableDevices(); + } on _DeviceDiscoveryException { + return _LaunchOutput.failure('device_discovery_failed'); + } + + if (usableDevices.isEmpty) { + return _LaunchOutput.failure('no_usable_devices'); + } + + final List<_LaunchDevice> matchingDevices = _matchingDevices( + usableDevices, + options.requestedDevice, + ); + if (matchingDevices.length == 1) { + return _LaunchOutput.ready(options, matchingDevices.single); + } + + if (options.requestedDevice != null && matchingDevices.isEmpty) { + return _LaunchOutput.failure('device_not_found'); + } + + if (usableDevices.length == 1 && options.requestedDevice == null) { + return _LaunchOutput.ready(options, usableDevices.single); + } + + return _LaunchOutput.needsDeviceSelection( + options, + matchingDevices.isEmpty ? usableDevices : matchingDevices, + ); +} + +List<_LaunchDevice> _matchingDevices( + List<_LaunchDevice> usableDevices, + String? requestedDevice, +) { + final String? trimmedRequest = requestedDevice?.trim(); + if (trimmedRequest == null || trimmedRequest.isEmpty) { + return const <_LaunchDevice>[]; + } + + final List<_LaunchDevice> idMatches = usableDevices + .where((device) => device.id == trimmedRequest) + .toList(growable: false); + if (idMatches.isNotEmpty) { + return idMatches; + } + + final String normalizedRequest = trimmedRequest.toLowerCase(); + return usableDevices + .where( + (device) => device.name.toLowerCase().contains(normalizedRequest), + ) + .toList(growable: false); +} + +class _LaunchOptions { + const _LaunchOptions({ + required this.requestedDevice, + required this.flavor, + required this.target, + required this.dartDefines, + required this.projectRoot, + required this.open, + }); + + final String? requestedDevice; + final String? flavor; + final String? target; + final List dartDefines; + final String? projectRoot; + final bool open; + + static _LaunchOptions parse(List args) { + if (args.isEmpty || args.first != 'launch') { + throw const _LaunchValidationError(); + } + + String? requestedDevice; + String? flavor; + String? target; + final List dartDefines = []; + String? projectRoot; + bool open = true; + + for (var index = 1; index < args.length; index += 1) { + final String arg = args[index]; + if (arg == '--device' && index + 1 < args.length) { + index += 1; + requestedDevice = args[index]; + } else if (arg == '--flavor' && index + 1 < args.length) { + index += 1; + flavor = args[index]; + } else if (arg == '--target' && index + 1 < args.length) { + index += 1; + target = args[index]; + } else if (arg == '--dart-define' && index + 1 < args.length) { + index += 1; + dartDefines.add(args[index]); + } else if (arg == '--project-root' && index + 1 < args.length) { + index += 1; + projectRoot = args[index]; + } else if (arg == '--no-open') { + open = false; + } else { + throw const _LaunchValidationError(); + } + } + + return _LaunchOptions( + requestedDevice: _emptyToNull(requestedDevice), + flavor: _emptyToNull(flavor), + target: _emptyToNull(target), + dartDefines: List.unmodifiable(dartDefines), + projectRoot: _emptyToNull(projectRoot), + open: open, + ); + } + + Map toJson() { + return { + 'device': requestedDevice, + 'flavor': flavor, + 'target': target, + 'dartDefines': dartDefines, + 'projectRoot': projectRoot, + 'open': open, + }; + } + + List flutterRunArguments(String deviceId) { + final List arguments = [ + 'run', + '--device-id', + deviceId, + ]; + + if (flavor != null) { + arguments.addAll(['--flavor', flavor!]); + } + if (target != null) { + arguments.addAll(['--target', target!]); + } + for (final String dartDefine in dartDefines) { + arguments.addAll(['--dart-define', dartDefine]); + } + + return arguments; + } + + List rerunArguments(String deviceId) { + final List arguments = [ + 'dart', + 'run', + 'ask_ui_bridge', + 'launch', + '--device', + deviceId, + ]; + + if (flavor != null) { + arguments.addAll(['--flavor', flavor!]); + } + if (target != null) { + arguments.addAll(['--target', target!]); + } + for (final String dartDefine in dartDefines) { + arguments.addAll(['--dart-define', dartDefine]); + } + if (projectRoot != null) { + arguments.addAll(['--project-root', projectRoot!]); + } + if (!open) { + arguments.add('--no-open'); + } + + return arguments; + } +} + +class _FlutterDeviceDiscovery { + const _FlutterDeviceDiscovery({ + required this.listDevices, + }); + + final FlutterDevicesRunner listDevices; + + Future> discoverUsableDevices() async { + final ProcessResult result = await listDevices( + 'flutter', + const ['devices', '--machine'], + ); + if (result.exitCode != 0) { + throw const _DeviceDiscoveryException(); + } + + late final Object? decoded; + try { + decoded = jsonDecode(result.stdout.toString()); + } on FormatException { + throw const _DeviceDiscoveryException(); + } + + if (decoded is! List) { + throw const _DeviceDiscoveryException(); + } + + final List<_LaunchDevice> devices = <_LaunchDevice>[]; + for (final Object? rawDevice in decoded) { + final _LaunchDevice? device = _LaunchDevice.fromJson(rawDevice); + if (device != null && device.isUsable) { + devices.add(device); + } + } + + return List<_LaunchDevice>.unmodifiable(devices); + } +} + +class _LaunchDevice { + const _LaunchDevice({ + required this.id, + required this.name, + required this.targetPlatform, + required this.isSupported, + }); + + final String id; + final String name; + final String targetPlatform; + final bool isSupported; + + bool get isUsable { + return isSupported && targetPlatform.toLowerCase().startsWith('android'); + } + + static _LaunchDevice? fromJson(Object? rawDevice) { + if (rawDevice is! Map) { + return null; + } + + final Object? rawId = rawDevice['id']; + final Object? rawTargetPlatform = rawDevice['targetPlatform']; + if (rawId is! String || rawTargetPlatform is! String) { + return null; + } + + final Object? rawName = rawDevice['name']; + return _LaunchDevice( + id: rawId, + name: rawName is String ? rawName : rawId, + targetPlatform: rawTargetPlatform, + isSupported: rawDevice['isSupported'] != false, + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'targetPlatform': targetPlatform, + }; + } +} + +class _LaunchOutput { + _LaunchOutput._(); + + static LaunchCommandResult ready( + _LaunchOptions options, + _LaunchDevice selectedDevice, + ) { + return LaunchCommandResult( + exitCode: 0, + stdout: jsonEncode({ + 'status': 'ready', + 'selectedDevice': selectedDevice.toJson(), + 'launchIntent': options.toJson(), + 'flutterRunArguments': options.flutterRunArguments(selectedDevice.id), + 'nextStep': + 'Launch Flutter app for selected device ${selectedDevice.id}.', + }), + ); + } + + static LaunchCommandResult needsDeviceSelection( + _LaunchOptions options, + List<_LaunchDevice> devices, + ) { + return LaunchCommandResult( + exitCode: 0, + stdout: jsonEncode({ + 'status': 'needs_device_selection', + 'devices': devices + .map((device) => { + ...device.toJson(), + 'suggestedCommand': _commandString( + options.rerunArguments(device.id), + ), + }) + .toList(growable: false), + 'launchIntent': options.toJson(), + 'nextStep': + 'Ask the user to choose a device, then rerun launch with --device.', + }), + ); + } + + static LaunchCommandResult failure(String code) { + return LaunchCommandResult( + exitCode: 1, + stderr: jsonEncode({ + 'status': 'error', + 'error': code, + }), + ); + } +} + +String _commandString(List arguments) { + return arguments.map(_shellQuote).join(' '); +} + +String _shellQuote(String argument) { + if (RegExp(r'^[A-Za-z0-9_./:=@+-]+$').hasMatch(argument)) { + return argument; + } + + return "'${argument.replaceAll("'", r"'\''")}'"; +} + +String? _emptyToNull(String? value) { + final String? trimmed = value?.trim(); + if (trimmed == null || trimmed.isEmpty) { + return null; + } + + return trimmed; +} + +class _LaunchValidationError implements Exception { + const _LaunchValidationError(); +} + +class _DeviceDiscoveryException implements Exception { + const _DeviceDiscoveryException(); +} diff --git a/apps/bridge/test/launch/launch_command_test.dart b/apps/bridge/test/launch/launch_command_test.dart new file mode 100644 index 0000000..6994e50 --- /dev/null +++ b/apps/bridge/test/launch/launch_command_test.dart @@ -0,0 +1,291 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:ask_ui_bridge/launch/launch_command.dart'; +import 'package:test/test.dart'; + +void main() { + group('Launch command contract', () { + test('auto-selects one usable device and preserves Flutter launch intent', + () async { + final _FakeFlutterDevices devices = _FakeFlutterDevices([ + { + 'id': 'device-1', + 'name': 'Pixel 6', + 'targetPlatform': 'android-arm64', + }, + ]); + + final LaunchCommandResult result = await runLaunchCommand( + const [ + 'launch', + '--flavor', + 'staging', + '--target', + 'lib/main_staging.dart', + '--dart-define', + 'API_HOST=local', + '--dart-define', + 'FEATURE_X=true', + '--project-root', + '/workspace/app', + '--no-open', + ], + listDevices: devices.listDevices, + ); + + expect(result.exitCode, 0); + expect(result.stderr, isEmpty); + expect(jsonDecode(result.stdout), { + 'status': 'ready', + 'selectedDevice': { + 'id': 'device-1', + 'name': 'Pixel 6', + 'targetPlatform': 'android-arm64', + }, + 'launchIntent': { + 'device': null, + 'flavor': 'staging', + 'target': 'lib/main_staging.dart', + 'dartDefines': ['API_HOST=local', 'FEATURE_X=true'], + 'projectRoot': '/workspace/app', + 'open': false, + }, + 'flutterRunArguments': [ + 'run', + '--device-id', + 'device-1', + '--flavor', + 'staging', + '--target', + 'lib/main_staging.dart', + '--dart-define', + 'API_HOST=local', + '--dart-define', + 'FEATURE_X=true', + ], + 'nextStep': 'Launch Flutter app for selected device device-1.', + }); + expect(devices.calls, [ + ['devices', '--machine'], + ]); + }); + + test('returns one JSON error object when no usable devices exist', + () async { + final _FakeFlutterDevices devices = _FakeFlutterDevices([ + { + 'id': 'offline', + 'name': 'Offline Android', + 'targetPlatform': 'android-arm64', + 'isSupported': false, + }, + { + 'id': 'macos', + 'name': 'macOS', + 'targetPlatform': 'darwin-arm64', + }, + ]); + + final LaunchCommandResult result = await runLaunchCommand( + const ['launch'], + listDevices: devices.listDevices, + ); + + expect(result.exitCode, 1); + expect(result.stdout, isEmpty); + expect(jsonDecode(result.stderr), { + 'status': 'error', + 'error': 'no_usable_devices', + }); + }); + + test('returns device choices and preserved rerun commands for selection', + () async { + final _FakeFlutterDevices devices = _FakeFlutterDevices([ + { + 'id': 'emulator-5554', + 'name': 'Pixel API 35', + 'targetPlatform': 'android-x64', + }, + { + 'id': '19271FDF6007TY', + 'name': 'Pixel 6', + 'targetPlatform': 'android-arm64', + }, + ]); + + final LaunchCommandResult result = await runLaunchCommand( + const [ + 'launch', + '--flavor', + 'dev', + '--target', + 'lib/main_dev.dart', + '--dart-define', + 'A=B', + '--project-root', + '/workspace/app', + '--no-open', + ], + listDevices: devices.listDevices, + ); + + expect(result.exitCode, 0); + expect(result.stderr, isEmpty); + expect(jsonDecode(result.stdout), { + 'status': 'needs_device_selection', + 'devices': [ + { + 'id': 'emulator-5554', + 'name': 'Pixel API 35', + 'targetPlatform': 'android-x64', + 'suggestedCommand': + 'dart run ask_ui_bridge launch --device emulator-5554 --flavor dev --target lib/main_dev.dart --dart-define A=B --project-root /workspace/app --no-open', + }, + { + 'id': '19271FDF6007TY', + 'name': 'Pixel 6', + 'targetPlatform': 'android-arm64', + 'suggestedCommand': + 'dart run ask_ui_bridge launch --device 19271FDF6007TY --flavor dev --target lib/main_dev.dart --dart-define A=B --project-root /workspace/app --no-open', + }, + ], + 'launchIntent': { + 'device': null, + 'flavor': 'dev', + 'target': 'lib/main_dev.dart', + 'dartDefines': ['A=B'], + 'projectRoot': '/workspace/app', + 'open': false, + }, + 'nextStep': + 'Ask the user to choose a device, then rerun launch with --device.', + }); + }); + + test('selects an explicit device id from multiple usable devices', + () async { + final _FakeFlutterDevices devices = _FakeFlutterDevices([ + { + 'id': 'emulator-5554', + 'name': 'Pixel API 35', + 'targetPlatform': 'android-x64', + }, + { + 'id': '19271FDF6007TY', + 'name': 'Pixel 6', + 'targetPlatform': 'android-arm64', + }, + ]); + + final LaunchCommandResult result = await runLaunchCommand( + const ['launch', '--device', '19271FDF6007TY'], + listDevices: devices.listDevices, + ); + + expect(result.exitCode, 0); + expect(jsonDecode(result.stdout)['selectedDevice'], { + 'id': '19271FDF6007TY', + 'name': 'Pixel 6', + 'targetPlatform': 'android-arm64', + }); + }); + + test('selects an unambiguous device name and refuses ambiguous names', + () async { + final _FakeFlutterDevices devices = _FakeFlutterDevices([ + { + 'id': 'emulator-5554', + 'name': 'Pixel', + 'targetPlatform': 'android-x64', + }, + { + 'id': '19271FDF6007TY', + 'name': 'Pixel 6', + 'targetPlatform': 'android-arm64', + }, + ]); + + final LaunchCommandResult named = await runLaunchCommand( + const ['launch', '--device', 'Pixel 6'], + listDevices: devices.listDevices, + ); + final LaunchCommandResult ambiguous = await runLaunchCommand( + const ['launch', '--device', 'Pixel'], + listDevices: devices.listDevices, + ); + + expect(named.exitCode, 0); + expect( + jsonDecode(named.stdout)['selectedDevice']['id'], '19271FDF6007TY'); + expect(ambiguous.exitCode, 0); + expect(jsonDecode(ambiguous.stdout)['status'], 'needs_device_selection'); + }); + + test('reports invalid arguments without listing devices', () async { + final _FakeFlutterDevices devices = _FakeFlutterDevices([]); + + final LaunchCommandResult result = await runLaunchCommand( + const ['launch', '--flavor'], + listDevices: devices.listDevices, + ); + + expect(result.exitCode, 1); + expect(result.stdout, isEmpty); + expect(jsonDecode(result.stderr), { + 'status': 'error', + 'error': 'invalid_arguments', + }); + expect(devices.calls, isEmpty); + }); + + test('binary launch command writes JSON failure to stderr only', () async { + final ProcessResult result = await Process.run( + Platform.resolvedExecutable, + const ['bin/ask_ui_bridge.dart', 'launch', '--flavor'], + workingDirectory: Directory.current.path, + environment: const {}, + includeParentEnvironment: false, + ); + + expect(result.exitCode, 1); + expect(result.stdout, isEmpty); + expect(jsonDecode(result.stderr as String), { + 'status': 'error', + 'error': 'invalid_arguments', + }); + }); + + test('reports malformed flutter devices output as JSON', () async { + final LaunchCommandResult result = await runLaunchCommand( + const ['launch'], + listDevices: (executable, arguments) async { + return ProcessResult(1, 0, '{"devices":[]}', ''); + }, + ); + + expect(result.exitCode, 1); + expect(result.stdout, isEmpty); + expect(jsonDecode(result.stderr), { + 'status': 'error', + 'error': 'device_discovery_failed', + }); + }); + }); +} + +class _FakeFlutterDevices { + _FakeFlutterDevices(this.devices); + + final List> devices; + final List> calls = >[]; + + Future listDevices( + String executable, + List arguments, + ) async { + calls.add(arguments); + return ProcessResult(1, 0, jsonEncode(devices), ''); + } +} diff --git a/issues/0.0.4-ask-ui-launch.md b/issues/0.0.4-ask-ui-launch.md index 7430b25..464a544 100644 --- a/issues/0.0.4-ask-ui-launch.md +++ b/issues/0.0.4-ask-ui-launch.md @@ -73,22 +73,22 @@ The command should parse launch options, discover Flutter devices when needed, p ## Acceptance criteria -- [ ] The bridge entrypoint supports `launch` without breaking the existing server start path. -- [ ] The bridge entrypoint keeps the existing `agent poll` command behavior. -- [ ] `launch` accepts `--device`. -- [ ] `launch` accepts `--flavor`. -- [ ] `launch` accepts `--target` and maps it to Flutter target intent. -- [ ] `launch` accepts repeatable `--dart-define` values. -- [ ] `launch` accepts `--project-root`. -- [ ] `launch` accepts `--no-open`. -- [ ] `launch` discovers devices with Flutter's machine-readable device output when `--device` is omitted. -- [ ] No usable devices returns one JSON error object. -- [ ] Multiple usable devices returns `status: needs_device_selection` with device choices. -- [ ] A single usable device can be selected automatically. -- [ ] A provided device id or unambiguous device name selects that target. -- [ ] An ambiguous provided device name returns a device-selection result instead of guessing. -- [ ] Device-selection output preserves flavor, target, dart defines, and project root in the suggested next command. -- [ ] Tests cover argument parsing, device discovery outcomes, flavor argument preservation, and existing command compatibility. +- [x] The bridge entrypoint supports `launch` without breaking the existing server start path. +- [x] The bridge entrypoint keeps the existing `agent poll` command behavior. +- [x] `launch` accepts `--device`. +- [x] `launch` accepts `--flavor`. +- [x] `launch` accepts `--target` and maps it to Flutter target intent. +- [x] `launch` accepts repeatable `--dart-define` values. +- [x] `launch` accepts `--project-root`. +- [x] `launch` accepts `--no-open`. +- [x] `launch` discovers devices with Flutter's machine-readable device output when `--device` is omitted. +- [x] No usable devices returns one JSON error object. +- [x] Multiple usable devices returns `status: needs_device_selection` with device choices. +- [x] A single usable device can be selected automatically. +- [x] A provided device id or unambiguous device name selects that target. +- [x] An ambiguous provided device name returns a device-selection result instead of guessing. +- [x] Device-selection output preserves flavor, target, dart defines, and project root in the suggested next command. +- [x] Tests cover argument parsing, device discovery outcomes, flavor argument preservation, and existing command compatibility. ## Blocked by From bc76367a4c916110ab2a934c81be0aac173da63e Mon Sep 17 00:00:00 2001 From: drown0315 Date: Fri, 10 Jul 2026 14:51:33 +0800 Subject: [PATCH 6/8] feat: launch flutter app bridge session --- apps/bridge/lib/launch/launch_command.dart | 330 +++++++++++++++++- .../test/launch/launch_command_test.dart | 299 +++++++++++++++- issues/0.0.4-ask-ui-launch.md | 22 +- 3 files changed, 622 insertions(+), 29 deletions(-) diff --git a/apps/bridge/lib/launch/launch_command.dart b/apps/bridge/lib/launch/launch_command.dart index 6ec3c7f..6acd75b 100644 --- a/apps/bridge/lib/launch/launch_command.dart +++ b/apps/bridge/lib/launch/launch_command.dart @@ -1,11 +1,68 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'dart:isolate'; + +import '../app_controller/flutter_app_controller.dart'; +import '../inspector/flutter_inspector_client.dart'; +import '../logging/bridge_logger.dart'; +import '../server/ask_ui_bridge_server.dart'; +import '../sessions/session_store.dart'; typedef FlutterDevicesRunner = Future Function( String executable, List arguments, ); +/// Starts the target Flutter app and returns the VM Service URI it exposes. +abstract interface class LaunchAppLauncher { + Future launch({ + required List flutterRunArguments, + required String projectRoot, + }); +} + +/// VM Service details captured from a successful Flutter app startup. +class LaunchAppResult { + const LaunchAppResult({required this.vmServiceUri}); + + final String vmServiceUri; +} + +/// Normalized Flutter startup failure for launch command JSON output. +class LaunchAppException implements Exception { + const LaunchAppException(this.code); + + final String code; +} + +/// Starts or reuses the local Bridge Server and creates a Bridge Session. +abstract interface class LaunchBridgeLauncher { + Future createSession({ + required String vmServiceUri, + required String projectRoot, + required String deviceId, + }); +} + +/// Bridge Session connection details returned to the launcher. +class LaunchBridgeSession { + const LaunchBridgeSession({ + required this.bridgeUrl, + required this.sessionId, + }); + + final Uri bridgeUrl; + final String sessionId; +} + +/// Normalized Bridge startup/session failure for launch command JSON output. +class LaunchBridgeException implements Exception { + const LaunchBridgeException(this.code); + + final String code; +} + /// Result returned by the launch command runner. /// /// The binary writes `stdout`, `stderr`, and `exitCode` directly. Tests use the @@ -30,6 +87,8 @@ class LaunchCommandResult { Future runLaunchCommand( List args, { FlutterDevicesRunner listDevices = Process.run, + LaunchAppLauncher? appLauncher, + LaunchBridgeLauncher? bridgeLauncher, }) async { late final _LaunchOptions options; try { @@ -56,7 +115,12 @@ Future runLaunchCommand( options.requestedDevice, ); if (matchingDevices.length == 1) { - return _LaunchOutput.ready(options, matchingDevices.single); + return _launchSelectedDevice( + options: options, + selectedDevice: matchingDevices.single, + appLauncher: appLauncher ?? const _FlutterRunAppLauncher(), + bridgeLauncher: bridgeLauncher ?? _LocalBridgeLauncher(), + ); } if (options.requestedDevice != null && matchingDevices.isEmpty) { @@ -64,7 +128,12 @@ Future runLaunchCommand( } if (usableDevices.length == 1 && options.requestedDevice == null) { - return _LaunchOutput.ready(options, usableDevices.single); + return _launchSelectedDevice( + options: options, + selectedDevice: usableDevices.single, + appLauncher: appLauncher ?? const _FlutterRunAppLauncher(), + bridgeLauncher: bridgeLauncher ?? _LocalBridgeLauncher(), + ); } return _LaunchOutput.needsDeviceSelection( @@ -73,6 +142,44 @@ Future runLaunchCommand( ); } +Future _launchSelectedDevice({ + required _LaunchOptions options, + required _LaunchDevice selectedDevice, + required LaunchAppLauncher appLauncher, + required LaunchBridgeLauncher bridgeLauncher, +}) async { + final String projectRoot = + options.projectRoot ?? Directory.current.absolute.path; + late final LaunchAppResult appResult; + try { + appResult = await appLauncher.launch( + flutterRunArguments: options.flutterRunArguments(selectedDevice.id), + projectRoot: projectRoot, + ); + } on LaunchAppException catch (error) { + return _LaunchOutput.failure(error.code); + } + + late final LaunchBridgeSession bridgeSession; + try { + bridgeSession = await bridgeLauncher.createSession( + vmServiceUri: appResult.vmServiceUri, + projectRoot: projectRoot, + deviceId: selectedDevice.id, + ); + } on LaunchBridgeException catch (error) { + return _LaunchOutput.failure(error.code); + } + + return _LaunchOutput.ready( + options: options, + selectedDevice: selectedDevice, + appResult: appResult, + bridgeSession: bridgeSession, + projectRoot: projectRoot, + ); +} + List<_LaunchDevice> _matchingDevices( List<_LaunchDevice> usableDevices, String? requestedDevice, @@ -260,6 +367,181 @@ class _FlutterDeviceDiscovery { } } +class _FlutterRunAppLauncher implements LaunchAppLauncher { + const _FlutterRunAppLauncher(); + + @override + Future launch({ + required List flutterRunArguments, + required String projectRoot, + }) async { + late final Process process; + try { + process = await Process.start( + 'flutter', + flutterRunArguments, + workingDirectory: projectRoot, + ); + } catch (_) { + throw const LaunchAppException('flutter_run_failed'); + } + + final Completer vmServiceCompleter = + Completer(); + final StringBuffer startupOutput = StringBuffer(); + + void observeOutput(List data) { + final String text = utf8.decode(data, allowMalformed: true); + startupOutput.write(text); + final String? vmServiceUri = + parseFlutterVmServiceUriFromOutput(startupOutput.toString()); + if (vmServiceUri != null && !vmServiceCompleter.isCompleted) { + vmServiceCompleter + .complete(LaunchAppResult(vmServiceUri: vmServiceUri)); + } + } + + process.stdout.listen(observeOutput); + process.stderr.listen(observeOutput); + process.exitCode.then((int exitCode) { + if (!vmServiceCompleter.isCompleted) { + vmServiceCompleter.completeError( + const LaunchAppException('flutter_run_failed'), + ); + } + }); + + try { + return await vmServiceCompleter.future.timeout( + const Duration(minutes: 2), + ); + } on LaunchAppException { + rethrow; + } on TimeoutException { + process.kill(); + throw const LaunchAppException('flutter_vm_service_not_found'); + } catch (_) { + throw const LaunchAppException('flutter_run_failed'); + } + } +} + +/// Extract the VM Service WebSocket URI from Flutter startup output. +/// +/// Flutter commonly prints an HTTP service URI ending in the auth-code path, +/// while the existing Bridge Session contract stores the WebSocket URI. Already +/// normalized `ws` and `wss` URIs are returned unchanged. +String? parseFlutterVmServiceUriFromOutput(String output) { + final RegExp uriPattern = RegExp(r'(wss?|https?)://[^\s]+'); + for (final RegExpMatch match in uriPattern.allMatches(output)) { + final String rawUri = match.group(0)!.replaceFirst(RegExp(r'[),.;]+$'), ''); + final Uri? uri = Uri.tryParse(rawUri); + if (uri == null || uri.host.isEmpty) { + continue; + } + if (uri.scheme == 'ws' || uri.scheme == 'wss') { + return uri.toString(); + } + if (uri.scheme == 'http' || uri.scheme == 'https') { + final String websocketScheme = uri.scheme == 'https' ? 'wss' : 'ws'; + final String normalizedPath = uri.path.endsWith('/ws') + ? uri.path + : '${uri.path.endsWith('/') ? uri.path : '${uri.path}/'}ws'; + return uri + .replace( + scheme: websocketScheme, + path: normalizedPath, + ) + .toString(); + } + } + + return null; +} + +class _LocalBridgeLauncher implements LaunchBridgeLauncher { + _LocalBridgeLauncher({HttpClient? httpClient}) + : _httpClient = httpClient ?? HttpClient(); + + final HttpClient _httpClient; + AskUiBridgeServer? _server; + + @override + Future createSession({ + required String vmServiceUri, + required String projectRoot, + required String deviceId, + }) async { + final Uri bridgeUrl = await _startOrReuseServer(); + final Uri sessionUri = bridgeUrl.resolve('/api/sessions'); + + try { + final HttpClientRequest request = await _httpClient.postUrl(sessionUri); + request.headers.contentType = ContentType.json; + request.write( + jsonEncode({ + 'vmServiceUri': vmServiceUri, + 'projectRoot': projectRoot, + 'deviceId': deviceId, + }), + ); + final HttpClientResponse response = await request.close(); + final String responseBody = await utf8.decodeStream(response); + final Object? decoded = jsonDecode(responseBody); + if (decoded is! Map) { + throw const FormatException('Expected session JSON object'); + } + if (response.statusCode != HttpStatus.ok) { + throw const LaunchBridgeException('session_creation_failed'); + } + final Object? sessionId = decoded['sessionId']; + if (sessionId is! String || sessionId.trim().isEmpty) { + throw const LaunchBridgeException('session_creation_failed'); + } + return LaunchBridgeSession( + bridgeUrl: bridgeUrl, + sessionId: sessionId, + ); + } on LaunchBridgeException { + rethrow; + } catch (_) { + throw const LaunchBridgeException('session_creation_failed'); + } + } + + Future _startOrReuseServer() async { + final Uri bridgeUrl = Uri.parse('http://127.0.0.1:8787'); + if (_server != null) { + return bridgeUrl; + } + + final BridgeLogger logger = BridgeLogger(write: stderr.writeln); + final Directory packagedWebRoot = await _resolvePackagedWebRoot(); + final AskUiBridgeServer server = AskUiBridgeServer( + sessionStore: SessionStore(), + inspectorClient: VmServiceFlutterInspectorClient( + vmServiceFactory: VmServiceFactory(), + ), + appController: VmServiceFlutterAppController( + vmServiceFactory: VmServiceFactory(), + logger: logger, + ), + packagedWebRoot: packagedWebRoot, + logger: logger, + ); + + try { + await server.start(host: '127.0.0.1', port: 8787); + _server = server; + return bridgeUrl; + } on SocketException { + return bridgeUrl; + } catch (_) { + throw const LaunchBridgeException('bridge_start_failed'); + } + } +} + class _LaunchDevice { const _LaunchDevice({ required this.id, @@ -309,19 +591,38 @@ class _LaunchDevice { class _LaunchOutput { _LaunchOutput._(); - static LaunchCommandResult ready( - _LaunchOptions options, - _LaunchDevice selectedDevice, - ) { + static LaunchCommandResult ready({ + required _LaunchOptions options, + required _LaunchDevice selectedDevice, + required LaunchAppResult appResult, + required LaunchBridgeSession bridgeSession, + required String projectRoot, + }) { + final String agentCommand = _commandString([ + 'dart', + 'run', + 'ask_ui_bridge', + 'agent', + 'poll', + '--base-url', + bridgeSession.bridgeUrl.toString(), + '--session-id', + bridgeSession.sessionId, + ]); return LaunchCommandResult( exitCode: 0, stdout: jsonEncode({ 'status': 'ready', 'selectedDevice': selectedDevice.toJson(), 'launchIntent': options.toJson(), - 'flutterRunArguments': options.flutterRunArguments(selectedDevice.id), - 'nextStep': - 'Launch Flutter app for selected device ${selectedDevice.id}.', + 'bridgeUrl': bridgeSession.bridgeUrl.toString(), + 'sessionId': bridgeSession.sessionId, + 'vmServiceUri': appResult.vmServiceUri, + 'projectRoot': projectRoot, + 'flavor': options.flavor, + 'target': options.target, + 'agentCommand': agentCommand, + 'nextStep': 'Run the returned agent poll command.', }), ); } @@ -388,3 +689,14 @@ class _LaunchValidationError implements Exception { class _DeviceDiscoveryException implements Exception { const _DeviceDiscoveryException(); } + +Future _resolvePackagedWebRoot() async { + final Uri? serverLibraryUri = await Isolate.resolvePackageUri( + Uri.parse('package:ask_ui_bridge/server/ask_ui_bridge_server.dart'), + ); + if (serverLibraryUri == null) { + return Directory('web'); + } + + return Directory.fromUri(serverLibraryUri.resolve('../../web')); +} diff --git a/apps/bridge/test/launch/launch_command_test.dart b/apps/bridge/test/launch/launch_command_test.dart index 6994e50..eed07ad 100644 --- a/apps/bridge/test/launch/launch_command_test.dart +++ b/apps/bridge/test/launch/launch_command_test.dart @@ -32,6 +32,13 @@ void main() { '--no-open', ], listDevices: devices.listDevices, + appLauncher: _RecordingAppLauncher( + vmServiceUri: 'ws://127.0.0.1:12345/ws', + ), + bridgeLauncher: _RecordingBridgeLauncher( + bridgeUrl: Uri.parse('http://127.0.0.1:8787'), + sessionId: 'session-1', + ), ); expect(result.exitCode, 0); @@ -51,26 +58,189 @@ void main() { 'projectRoot': '/workspace/app', 'open': false, }, - 'flutterRunArguments': [ - 'run', - '--device-id', + 'bridgeUrl': 'http://127.0.0.1:8787', + 'sessionId': 'session-1', + 'vmServiceUri': 'ws://127.0.0.1:12345/ws', + 'projectRoot': '/workspace/app', + 'flavor': 'staging', + 'target': 'lib/main_staging.dart', + 'agentCommand': + 'dart run ask_ui_bridge agent poll --base-url http://127.0.0.1:8787 --session-id session-1', + 'nextStep': 'Run the returned agent poll command.', + }); + expect(devices.calls, [ + ['devices', '--machine'], + ]); + }); + + test('launches Flutter and creates a Bridge Session for a selected device', + () async { + final _FakeFlutterDevices devices = _FakeFlutterDevices([ + { + 'id': 'device-1', + 'name': 'Pixel 6', + 'targetPlatform': 'android-arm64', + }, + ]); + final _RecordingAppLauncher appLauncher = _RecordingAppLauncher( + vmServiceUri: 'ws://127.0.0.1:44444/ws', + ); + final _RecordingBridgeLauncher bridgeLauncher = _RecordingBridgeLauncher( + bridgeUrl: Uri.parse('http://127.0.0.1:9876'), + sessionId: 'session-7', + ); + + final LaunchCommandResult result = await runLaunchCommand( + const [ + 'launch', + '--device', 'device-1', '--flavor', - 'staging', + 'dev', '--target', - 'lib/main_staging.dart', + 'lib/main_dev.dart', '--dart-define', 'API_HOST=local', - '--dart-define', - 'FEATURE_X=true', + '--project-root', + '/workspace/app', ], - 'nextStep': 'Launch Flutter app for selected device device-1.', + listDevices: devices.listDevices, + appLauncher: appLauncher, + bridgeLauncher: bridgeLauncher, + ); + + expect(result.exitCode, 0); + expect(result.stderr, isEmpty); + expect(jsonDecode(result.stdout), { + 'status': 'ready', + 'selectedDevice': { + 'id': 'device-1', + 'name': 'Pixel 6', + 'targetPlatform': 'android-arm64', + }, + 'launchIntent': { + 'device': 'device-1', + 'flavor': 'dev', + 'target': 'lib/main_dev.dart', + 'dartDefines': ['API_HOST=local'], + 'projectRoot': '/workspace/app', + 'open': true, + }, + 'bridgeUrl': 'http://127.0.0.1:9876', + 'sessionId': 'session-7', + 'vmServiceUri': 'ws://127.0.0.1:44444/ws', + 'projectRoot': '/workspace/app', + 'flavor': 'dev', + 'target': 'lib/main_dev.dart', + 'agentCommand': + 'dart run ask_ui_bridge agent poll --base-url http://127.0.0.1:9876 --session-id session-7', + 'nextStep': 'Run the returned agent poll command.', }); - expect(devices.calls, [ - ['devices', '--machine'], + expect(appLauncher.requests, hasLength(1)); + expect(appLauncher.requests.single.projectRoot, '/workspace/app'); + expect(appLauncher.requests.single.arguments, [ + 'run', + '--device-id', + 'device-1', + '--flavor', + 'dev', + '--target', + 'lib/main_dev.dart', + '--dart-define', + 'API_HOST=local', + ]); + expect(bridgeLauncher.requests, [ + ( + vmServiceUri: 'ws://127.0.0.1:44444/ws', + projectRoot: '/workspace/app', + deviceId: 'device-1', + ), ]); }); + test('reports Flutter startup failures before session creation', () async { + final _FakeFlutterDevices devices = _FakeFlutterDevices([ + { + 'id': 'device-1', + 'name': 'Pixel 6', + 'targetPlatform': 'android-arm64', + }, + ]); + final _FailingAppLauncher appLauncher = _FailingAppLauncher( + const LaunchAppException('flutter_run_failed'), + ); + final _RecordingBridgeLauncher bridgeLauncher = _RecordingBridgeLauncher( + bridgeUrl: Uri.parse('http://127.0.0.1:9876'), + sessionId: 'session-7', + ); + + final LaunchCommandResult result = await runLaunchCommand( + const ['launch', '--device', 'device-1'], + listDevices: devices.listDevices, + appLauncher: appLauncher, + bridgeLauncher: bridgeLauncher, + ); + + expect(result.exitCode, 1); + expect(result.stdout, isEmpty); + expect(jsonDecode(result.stderr), { + 'status': 'error', + 'error': 'flutter_run_failed', + }); + expect(bridgeLauncher.requests, isEmpty); + }); + + test('reports Bridge Session creation failures', () async { + final _FakeFlutterDevices devices = _FakeFlutterDevices([ + { + 'id': 'device-1', + 'name': 'Pixel 6', + 'targetPlatform': 'android-arm64', + }, + ]); + final _RecordingAppLauncher appLauncher = _RecordingAppLauncher( + vmServiceUri: 'ws://127.0.0.1:44444/ws', + ); + final _FailingBridgeLauncher bridgeLauncher = _FailingBridgeLauncher( + const LaunchBridgeException('session_creation_failed'), + ); + + final LaunchCommandResult result = await runLaunchCommand( + const ['launch', '--device', 'device-1'], + listDevices: devices.listDevices, + appLauncher: appLauncher, + bridgeLauncher: bridgeLauncher, + ); + + expect(result.exitCode, 1); + expect(result.stdout, isEmpty); + expect(jsonDecode(result.stderr), { + 'status': 'error', + 'error': 'session_creation_failed', + }); + }); + + test('parses Flutter VM Service output into WebSocket URIs', () { + expect( + parseFlutterVmServiceUriFromOutput( + 'A Dart VM Service is available at: ' + 'http://127.0.0.1:51234/abc_def=/', + ), + 'ws://127.0.0.1:51234/abc_def=/ws', + ); + expect( + parseFlutterVmServiceUriFromOutput( + 'The Dart VM Service is listening on ' + 'ws://127.0.0.1:51234/abc_def=/ws', + ), + 'ws://127.0.0.1:51234/abc_def=/ws', + ); + expect( + parseFlutterVmServiceUriFromOutput('Build failed before service.'), + isNull, + ); + }); + test('returns one JSON error object when no usable devices exist', () async { final _FakeFlutterDevices devices = _FakeFlutterDevices([ @@ -90,6 +260,8 @@ void main() { final LaunchCommandResult result = await runLaunchCommand( const ['launch'], listDevices: devices.listDevices, + appLauncher: _FailingAppLauncher.unused(), + bridgeLauncher: _FailingBridgeLauncher.unused(), ); expect(result.exitCode, 1); @@ -129,6 +301,8 @@ void main() { '--no-open', ], listDevices: devices.listDevices, + appLauncher: _FailingAppLauncher.unused(), + bridgeLauncher: _FailingBridgeLauncher.unused(), ); expect(result.exitCode, 0); @@ -182,6 +356,13 @@ void main() { final LaunchCommandResult result = await runLaunchCommand( const ['launch', '--device', '19271FDF6007TY'], listDevices: devices.listDevices, + appLauncher: _RecordingAppLauncher( + vmServiceUri: 'ws://127.0.0.1:12345/ws', + ), + bridgeLauncher: _RecordingBridgeLauncher( + bridgeUrl: Uri.parse('http://127.0.0.1:8787'), + sessionId: 'session-1', + ), ); expect(result.exitCode, 0); @@ -210,10 +391,19 @@ void main() { final LaunchCommandResult named = await runLaunchCommand( const ['launch', '--device', 'Pixel 6'], listDevices: devices.listDevices, + appLauncher: _RecordingAppLauncher( + vmServiceUri: 'ws://127.0.0.1:12345/ws', + ), + bridgeLauncher: _RecordingBridgeLauncher( + bridgeUrl: Uri.parse('http://127.0.0.1:8787'), + sessionId: 'session-1', + ), ); final LaunchCommandResult ambiguous = await runLaunchCommand( const ['launch', '--device', 'Pixel'], listDevices: devices.listDevices, + appLauncher: _FailingAppLauncher.unused(), + bridgeLauncher: _FailingBridgeLauncher.unused(), ); expect(named.exitCode, 0); @@ -229,6 +419,8 @@ void main() { final LaunchCommandResult result = await runLaunchCommand( const ['launch', '--flavor'], listDevices: devices.listDevices, + appLauncher: _FailingAppLauncher.unused(), + bridgeLauncher: _FailingBridgeLauncher.unused(), ); expect(result.exitCode, 1); @@ -263,6 +455,8 @@ void main() { listDevices: (executable, arguments) async { return ProcessResult(1, 0, '{"devices":[]}', ''); }, + appLauncher: _FailingAppLauncher.unused(), + bridgeLauncher: _FailingBridgeLauncher.unused(), ); expect(result.exitCode, 1); @@ -275,6 +469,91 @@ void main() { }); } +class _RecordingAppLauncher implements LaunchAppLauncher { + _RecordingAppLauncher({required this.vmServiceUri}); + + final String vmServiceUri; + final List<({List arguments, String projectRoot})> requests = + <({List arguments, String projectRoot})>[]; + + @override + Future launch({ + required List flutterRunArguments, + required String projectRoot, + }) async { + requests.add(( + arguments: List.from(flutterRunArguments), + projectRoot: projectRoot, + )); + return LaunchAppResult(vmServiceUri: vmServiceUri); + } +} + +class _FailingAppLauncher implements LaunchAppLauncher { + const _FailingAppLauncher(this.exception); + + const _FailingAppLauncher.unused() + : exception = const LaunchAppException('unexpected_app_launch'); + + final LaunchAppException exception; + + @override + Future launch({ + required List flutterRunArguments, + required String projectRoot, + }) async { + throw exception; + } +} + +class _RecordingBridgeLauncher implements LaunchBridgeLauncher { + _RecordingBridgeLauncher({ + required this.bridgeUrl, + required this.sessionId, + }); + + final Uri bridgeUrl; + final String sessionId; + final List<({String vmServiceUri, String projectRoot, String deviceId})> + requests = + <({String vmServiceUri, String projectRoot, String deviceId})>[]; + + @override + Future createSession({ + required String vmServiceUri, + required String projectRoot, + required String deviceId, + }) async { + requests.add(( + vmServiceUri: vmServiceUri, + projectRoot: projectRoot, + deviceId: deviceId, + )); + return LaunchBridgeSession( + bridgeUrl: bridgeUrl, + sessionId: sessionId, + ); + } +} + +class _FailingBridgeLauncher implements LaunchBridgeLauncher { + const _FailingBridgeLauncher(this.exception); + + const _FailingBridgeLauncher.unused() + : exception = const LaunchBridgeException('unexpected_bridge_launch'); + + final LaunchBridgeException exception; + + @override + Future createSession({ + required String vmServiceUri, + required String projectRoot, + required String deviceId, + }) async { + throw exception; + } +} + class _FakeFlutterDevices { _FakeFlutterDevices(this.devices); diff --git a/issues/0.0.4-ask-ui-launch.md b/issues/0.0.4-ask-ui-launch.md index 464a544..23d2808 100644 --- a/issues/0.0.4-ask-ui-launch.md +++ b/issues/0.0.4-ask-ui-launch.md @@ -98,6 +98,8 @@ None - can start immediately. Type: AFK +Status: Done + ## What to build Make `launch` start the target Flutter app and create the Bridge Session needed by the workbench and Agent Session Command. @@ -106,16 +108,16 @@ For a selected device, launch should run Flutter with the requested device, flav ## Acceptance criteria -- [ ] `launch --device ` starts the Flutter app on the selected device. -- [ ] `--flavor`, `--target`, and repeatable `--dart-define` are passed to Flutter run. -- [ ] `launch` captures the Flutter VM service URI from Flutter run output. -- [ ] `launch` fails with a JSON error when Flutter run fails before exposing a VM service URI. -- [ ] `launch` starts or reuses the local Bridge Server. -- [ ] `launch` creates a Bridge Session with `vmServiceUri`, `projectRoot`, and `deviceId`. -- [ ] Bridge Session creation failures are surfaced as JSON launch errors. -- [ ] Successful output includes bridge URL, session id, selected device, VM service URI, project root, flavor metadata, and target metadata. -- [ ] Successful output includes an executable `agent poll` command using the returned bridge URL and session id. -- [ ] Tests cover Flutter run command construction, VM service parsing, bridge session creation, success output, and failure output. +- [x] `launch --device ` starts the Flutter app on the selected device. +- [x] `--flavor`, `--target`, and repeatable `--dart-define` are passed to Flutter run. +- [x] `launch` captures the Flutter VM service URI from Flutter run output. +- [x] `launch` fails with a JSON error when Flutter run fails before exposing a VM service URI. +- [x] `launch` starts or reuses the local Bridge Server. +- [x] `launch` creates a Bridge Session with `vmServiceUri`, `projectRoot`, and `deviceId`. +- [x] Bridge Session creation failures are surfaced as JSON launch errors. +- [x] Successful output includes bridge URL, session id, selected device, VM service URI, project root, flavor metadata, and target metadata. +- [x] Successful output includes an executable `agent poll` command using the returned bridge URL and session id. +- [x] Tests cover Flutter run command construction, VM service parsing, bridge session creation, success output, and failure output. ## Blocked by From 9bfa8354428d1ed0902a48efa5be9825cd72b521 Mon Sep 17 00:00:00 2001 From: drown0315 Date: Fri, 10 Jul 2026 15:53:29 +0800 Subject: [PATCH 7/8] feat: open packaged workbench from launch --- apps/bridge/lib/launch/launch_command.dart | 115 +++++++++++++++++- .../test/launch/launch_command_test.dart | 87 +++++++++++++ issues/0.0.4-ask-ui-launch.md | 20 +-- 3 files changed, 209 insertions(+), 13 deletions(-) diff --git a/apps/bridge/lib/launch/launch_command.dart b/apps/bridge/lib/launch/launch_command.dart index 6acd75b..3df971b 100644 --- a/apps/bridge/lib/launch/launch_command.dart +++ b/apps/bridge/lib/launch/launch_command.dart @@ -63,6 +63,18 @@ class LaunchBridgeException implements Exception { final String code; } +/// Opens the packaged workbench URL after a launch creates a Bridge Session. +abstract interface class LaunchBrowserOpener { + Future open(Uri url); +} + +/// Normalized browser-open failure recorded in successful launch output. +class LaunchBrowserOpenException implements Exception { + const LaunchBrowserOpenException(this.code); + + final String code; +} + /// Result returned by the launch command runner. /// /// The binary writes `stdout`, `stderr`, and `exitCode` directly. Tests use the @@ -79,16 +91,17 @@ class LaunchCommandResult { final String stderr; } -/// Parses the first Ask UI launch contract and selects a Flutter device. +/// Runs the Ask UI launch command and returns machine-readable JSON. /// -/// This slice does not start Flutter yet. It returns machine-readable launch -/// intent and device-selection output so later launch phases can continue from -/// the same CLI contract. +/// The command selects a usable Flutter device, starts the app, creates a +/// Bridge Session, builds the packaged workbench attach URL, and opens it +/// unless `--no-open` is present. Future runLaunchCommand( List args, { FlutterDevicesRunner listDevices = Process.run, LaunchAppLauncher? appLauncher, LaunchBridgeLauncher? bridgeLauncher, + LaunchBrowserOpener? browserOpener, }) async { late final _LaunchOptions options; try { @@ -120,6 +133,7 @@ Future runLaunchCommand( selectedDevice: matchingDevices.single, appLauncher: appLauncher ?? const _FlutterRunAppLauncher(), bridgeLauncher: bridgeLauncher ?? _LocalBridgeLauncher(), + browserOpener: browserOpener ?? const _PlatformBrowserOpener(), ); } @@ -133,6 +147,7 @@ Future runLaunchCommand( selectedDevice: usableDevices.single, appLauncher: appLauncher ?? const _FlutterRunAppLauncher(), bridgeLauncher: bridgeLauncher ?? _LocalBridgeLauncher(), + browserOpener: browserOpener ?? const _PlatformBrowserOpener(), ); } @@ -147,6 +162,7 @@ Future _launchSelectedDevice({ required _LaunchDevice selectedDevice, required LaunchAppLauncher appLauncher, required LaunchBridgeLauncher bridgeLauncher, + required LaunchBrowserOpener browserOpener, }) async { final String projectRoot = options.projectRoot ?? Directory.current.absolute.path; @@ -171,12 +187,67 @@ Future _launchSelectedDevice({ return _LaunchOutput.failure(error.code); } + final Uri workbenchUrl = buildLaunchWorkbenchUrl( + bridgeUrl: bridgeSession.bridgeUrl, + sessionId: bridgeSession.sessionId, + selectedDeviceId: selectedDevice.id, + projectRoot: projectRoot, + flavor: options.flavor, + target: options.target, + ); + bool browserOpened = false; + String? browserOpenError; + if (options.open) { + try { + await browserOpener.open(workbenchUrl); + browserOpened = true; + } on LaunchBrowserOpenException catch (error) { + browserOpenError = error.code; + } catch (_) { + browserOpenError = 'browser_open_failed'; + } + } + return _LaunchOutput.ready( options: options, selectedDevice: selectedDevice, appResult: appResult, bridgeSession: bridgeSession, projectRoot: projectRoot, + workbenchUrl: workbenchUrl, + browserOpened: browserOpened, + browserOpenError: browserOpenError, + ); +} + +/// Build the packaged workbench URL that attaches to an existing session. +/// +/// The bridge server serves the React workbench from `/`, while the query +/// parameters select Web attach mode with the already-created Bridge Session. +Uri buildLaunchWorkbenchUrl({ + required Uri bridgeUrl, + required String sessionId, + required String selectedDeviceId, + required String projectRoot, + required String? flavor, + required String? target, +}) { + final Map queryParameters = { + 'bridgeUrl': _withoutTrailingSlash(bridgeUrl.toString()), + 'sessionId': sessionId, + 'deviceId': selectedDeviceId, + 'projectRoot': projectRoot, + }; + if (flavor != null) { + queryParameters['flavor'] = flavor; + } + if (target != null) { + queryParameters['target'] = target; + } + + return bridgeUrl.replace( + path: '/', + queryParameters: queryParameters, ); } @@ -542,6 +613,30 @@ class _LocalBridgeLauncher implements LaunchBridgeLauncher { } } +class _PlatformBrowserOpener implements LaunchBrowserOpener { + const _PlatformBrowserOpener(); + + @override + Future open(Uri url) async { + final List command = _browserOpenCommand(url); + try { + await Process.start(command.first, command.sublist(1)); + } catch (_) { + throw const LaunchBrowserOpenException('browser_open_failed'); + } + } + + List _browserOpenCommand(Uri url) { + if (Platform.isMacOS) { + return ['open', url.toString()]; + } + if (Platform.isWindows) { + return ['cmd', '/c', 'start', '', url.toString()]; + } + return ['xdg-open', url.toString()]; + } +} + class _LaunchDevice { const _LaunchDevice({ required this.id, @@ -597,6 +692,9 @@ class _LaunchOutput { required LaunchAppResult appResult, required LaunchBridgeSession bridgeSession, required String projectRoot, + required Uri workbenchUrl, + required bool browserOpened, + required String? browserOpenError, }) { final String agentCommand = _commandString([ 'dart', @@ -622,6 +720,9 @@ class _LaunchOutput { 'flavor': options.flavor, 'target': options.target, 'agentCommand': agentCommand, + 'workbenchUrl': workbenchUrl.toString(), + 'browserOpened': browserOpened, + if (browserOpenError != null) 'browserOpenError': browserOpenError, 'nextStep': 'Run the returned agent poll command.', }), ); @@ -665,6 +766,8 @@ String _commandString(List arguments) { return arguments.map(_shellQuote).join(' '); } + + String _shellQuote(String argument) { if (RegExp(r'^[A-Za-z0-9_./:=@+-]+$').hasMatch(argument)) { return argument; @@ -682,6 +785,10 @@ String? _emptyToNull(String? value) { return trimmed; } +String _withoutTrailingSlash(String value) { + return value.replaceFirst(RegExp(r'/+$'), ''); +} + class _LaunchValidationError implements Exception { const _LaunchValidationError(); } diff --git a/apps/bridge/test/launch/launch_command_test.dart b/apps/bridge/test/launch/launch_command_test.dart index eed07ad..60ba54a 100644 --- a/apps/bridge/test/launch/launch_command_test.dart +++ b/apps/bridge/test/launch/launch_command_test.dart @@ -15,6 +15,7 @@ void main() { 'targetPlatform': 'android-arm64', }, ]); + final _RecordingBrowserOpener browserOpener = _RecordingBrowserOpener(); final LaunchCommandResult result = await runLaunchCommand( const [ @@ -39,6 +40,7 @@ void main() { bridgeUrl: Uri.parse('http://127.0.0.1:8787'), sessionId: 'session-1', ), + browserOpener: browserOpener, ); expect(result.exitCode, 0); @@ -66,8 +68,12 @@ void main() { 'target': 'lib/main_staging.dart', 'agentCommand': 'dart run ask_ui_bridge agent poll --base-url http://127.0.0.1:8787 --session-id session-1', + 'workbenchUrl': + 'http://127.0.0.1:8787/?bridgeUrl=http%3A%2F%2F127.0.0.1%3A8787&sessionId=session-1&deviceId=device-1&projectRoot=%2Fworkspace%2Fapp&flavor=staging&target=lib%2Fmain_staging.dart', + 'browserOpened': false, 'nextStep': 'Run the returned agent poll command.', }); + expect(browserOpener.openedUrls, isEmpty); expect(devices.calls, [ ['devices', '--machine'], ]); @@ -89,6 +95,7 @@ void main() { bridgeUrl: Uri.parse('http://127.0.0.1:9876'), sessionId: 'session-7', ); + final _RecordingBrowserOpener browserOpener = _RecordingBrowserOpener(); final LaunchCommandResult result = await runLaunchCommand( const [ @@ -107,6 +114,7 @@ void main() { listDevices: devices.listDevices, appLauncher: appLauncher, bridgeLauncher: bridgeLauncher, + browserOpener: browserOpener, ); expect(result.exitCode, 0); @@ -134,8 +142,16 @@ void main() { 'target': 'lib/main_dev.dart', 'agentCommand': 'dart run ask_ui_bridge agent poll --base-url http://127.0.0.1:9876 --session-id session-7', + 'workbenchUrl': + 'http://127.0.0.1:9876/?bridgeUrl=http%3A%2F%2F127.0.0.1%3A9876&sessionId=session-7&deviceId=device-1&projectRoot=%2Fworkspace%2Fapp&flavor=dev&target=lib%2Fmain_dev.dart', + 'browserOpened': true, 'nextStep': 'Run the returned agent poll command.', }); + expect(browserOpener.openedUrls, [ + Uri.parse( + 'http://127.0.0.1:9876/?bridgeUrl=http%3A%2F%2F127.0.0.1%3A9876&sessionId=session-7&deviceId=device-1&projectRoot=%2Fworkspace%2Fapp&flavor=dev&target=lib%2Fmain_dev.dart', + ), + ]); expect(appLauncher.requests, hasLength(1)); expect(appLauncher.requests.single.projectRoot, '/workspace/app'); expect(appLauncher.requests.single.arguments, [ @@ -158,6 +174,59 @@ void main() { ]); }); + test('workbench URL uses attach bootstrap parameters', () async { + final Uri workbenchUrl = buildLaunchWorkbenchUrl( + bridgeUrl: Uri.parse('http://127.0.0.1:8787/'), + sessionId: 'session-1', + selectedDeviceId: '19271FDF6007TY', + projectRoot: '/Users/example app', + flavor: 'qa flavor', + target: null, + ); + + expect(workbenchUrl.origin, 'http://127.0.0.1:8787'); + expect(workbenchUrl.path, '/'); + expect(workbenchUrl.queryParameters, { + 'bridgeUrl': 'http://127.0.0.1:8787', + 'sessionId': 'session-1', + 'deviceId': '19271FDF6007TY', + 'projectRoot': '/Users/example app', + 'flavor': 'qa flavor', + }); + }); + + test('reports browser open failures without losing launch details', + () async { + final _FakeFlutterDevices devices = _FakeFlutterDevices([ + { + 'id': 'device-1', + 'name': 'Pixel 6', + 'targetPlatform': 'android-arm64', + }, + ]); + + final LaunchCommandResult result = await runLaunchCommand( + const ['launch', '--device', 'device-1'], + listDevices: devices.listDevices, + appLauncher: _RecordingAppLauncher( + vmServiceUri: 'ws://127.0.0.1:12345/ws', + ), + bridgeLauncher: _RecordingBridgeLauncher( + bridgeUrl: Uri.parse('http://127.0.0.1:8787'), + sessionId: 'session-1', + ), + browserOpener: const _FailingBrowserOpener(), + ); + + final decoded = jsonDecode(result.stdout) as Map; + expect(result.exitCode, 0); + expect(result.stderr, isEmpty); + expect(decoded['status'], 'ready'); + expect(decoded['workbenchUrl'], isA()); + expect(decoded['browserOpened'], false); + expect(decoded['browserOpenError'], 'browser_open_failed'); + }); + test('reports Flutter startup failures before session creation', () async { final _FakeFlutterDevices devices = _FakeFlutterDevices([ { @@ -554,6 +623,24 @@ class _FailingBridgeLauncher implements LaunchBridgeLauncher { } } +class _RecordingBrowserOpener implements LaunchBrowserOpener { + final List openedUrls = []; + + @override + Future open(Uri url) async { + openedUrls.add(url); + } +} + +class _FailingBrowserOpener implements LaunchBrowserOpener { + const _FailingBrowserOpener(); + + @override + Future open(Uri url) async { + throw const LaunchBrowserOpenException('browser_open_failed'); + } +} + class _FakeFlutterDevices { _FakeFlutterDevices(this.devices); diff --git a/issues/0.0.4-ask-ui-launch.md b/issues/0.0.4-ask-ui-launch.md index 23d2808..bae2a2b 100644 --- a/issues/0.0.4-ask-ui-launch.md +++ b/issues/0.0.4-ask-ui-launch.md @@ -127,6 +127,8 @@ For a selected device, launch should run Flutter with the requested device, flav Type: AFK +Status: Done + ## What to build Open the packaged Ask UI workbench from the launch command and wire it to the Bridge Session created by launch. @@ -135,15 +137,15 @@ After the Bridge Session exists, launch should construct a workbench URL served ## Acceptance criteria -- [ ] `launch` constructs a workbench URL served by the Bridge Server. -- [ ] The workbench URL includes `bridgeUrl` and `sessionId` attach parameters. -- [ ] The workbench URL includes useful launch metadata such as device id and flavor when present. -- [ ] The browser opens by default after launch succeeds. -- [ ] `--no-open` suppresses browser opening without changing the generated URL. -- [ ] Successful output includes the workbench URL. -- [ ] Successful output includes a `nextStep` telling the agent to run the returned agent command. -- [ ] The opened Web workbench attaches to the existing session instead of creating a duplicate session. -- [ ] Tests cover URL construction, no-open behavior, browser open failure handling, and attach-mode integration. +- [x] `launch` constructs a workbench URL served by the Bridge Server. +- [x] The workbench URL includes `bridgeUrl` and `sessionId` attach parameters. +- [x] The workbench URL includes useful launch metadata such as device id and flavor when present. +- [x] The browser opens by default after launch succeeds. +- [x] `--no-open` suppresses browser opening without changing the generated URL. +- [x] Successful output includes the workbench URL. +- [x] Successful output includes a `nextStep` telling the agent to run the returned agent command. +- [x] The opened Web workbench attaches to the existing session instead of creating a duplicate session. +- [x] Tests cover URL construction, no-open behavior, browser open failure handling, and attach-mode integration. ## Blocked by From 47890ea3c411c214fdfbd7e2bf93d73e58636f46 Mon Sep 17 00:00:00 2001 From: drown0315 Date: Fri, 10 Jul 2026 16:03:58 +0800 Subject: [PATCH 8/8] fix: allow web scripts to use node globals --- apps/web/eslint.config.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/web/eslint.config.js b/apps/web/eslint.config.js index ac6e08c..9e238c1 100644 --- a/apps/web/eslint.config.js +++ b/apps/web/eslint.config.js @@ -31,4 +31,13 @@ export default tseslint.config( ], }, }, + { + files: ['scripts/**/*.mjs'], + languageOptions: { + ecmaVersion: 2022, + globals: { + ...globals.node, + }, + }, + }, );