diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 3ea53a75..a6e01465 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -277,11 +277,13 @@ jobs: --target bun-darwin-arm64 \ --outfile bridge/dist/antgrid-bridge-arm64 \ --define 'process.env.LICENSE_API_URL="${{ env.LICENSE_API_URL }}"' \ + --define 'process.env.SENTRY_DSN="${{ secrets.SENTRY_DSN_BRIDGE }}"' \ --define 'process.env.ANTGRID_BRIDGE_COMPILED="1"' bun build bridge/src/index.ts --compile \ --target bun-darwin-x64 \ --outfile bridge/dist/antgrid-bridge-x64 \ --define 'process.env.LICENSE_API_URL="${{ env.LICENSE_API_URL }}"' \ + --define 'process.env.SENTRY_DSN="${{ secrets.SENTRY_DSN_BRIDGE }}"' \ --define 'process.env.ANTGRID_BRIDGE_COMPILED="1"' lipo -create -output bridge/dist/antgrid-bridge \ bridge/dist/antgrid-bridge-arm64 \ @@ -640,7 +642,7 @@ jobs: run: | $ErrorActionPreference = 'Stop' New-Item -ItemType Directory -Force bridge/dist | Out-Null - bun build bridge/src/index.ts --compile --target bun-windows-x64 --outfile bridge/dist/antgrid-bridge.exe --define 'process.env.LICENSE_API_URL="${{ env.LICENSE_API_URL }}"' --define 'process.env.ANTGRID_BRIDGE_COMPILED="1"' + bun build bridge/src/index.ts --compile --target bun-windows-x64 --outfile bridge/dist/antgrid-bridge.exe --define 'process.env.LICENSE_API_URL="${{ env.LICENSE_API_URL }}"' --define 'process.env.SENTRY_DSN="${{ secrets.SENTRY_DSN_BRIDGE }}"' --define 'process.env.ANTGRID_BRIDGE_COMPILED="1"' - name: Smoke test compiled bridge hook shell: pwsh @@ -844,6 +846,7 @@ jobs: --target bun-linux-x64 \ --outfile bridge/dist/antgrid-bridge \ --define 'process.env.LICENSE_API_URL="${{ env.LICENSE_API_URL }}"' \ + --define 'process.env.SENTRY_DSN="${{ secrets.SENTRY_DSN_BRIDGE }}"' \ --define 'process.env.ANTGRID_BRIDGE_COMPILED="1"' - name: Smoke test compiled bridge hook diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index aaf28b1b..896b2b10 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -506,6 +506,12 @@ The `.env.example` files are documentation and drift. `GITHUB_CLIENT_SECRET`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `CORS_ORIGINS`. +`CORS_ORIGINS` must list the **marketing site's** origin in staging and +production, not just the app's: `site/` is a static build on another host and its +founding-price capture POSTs to `/api/waitlist` here (`WEB_URL` in +`site/src/config.ts`). Omit it and every submit fails in the browser as a network +error, with nothing in the web service's logs to say why. + **`web/.env` — defaulted, safe to omit:** `NODE_ENV` (`development`), `EMAIL_FROM`, `PORT` (8787). `BETTER_AUTH_URL` auto-derives to `http://localhost:${PORT}` in development and test; it is required only in diff --git a/README.md b/README.md index a20df567..fc760b2b 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,50 @@ # Antgrid -**Your agent says it's done. Make it prove it.** +**Your machines. Your agents. One control plane.** -Evidence-gated supervision for the CLI coding agents you already run — on your own -hardware, end-to-end encrypted. +Every CLI coding agent you run, on every machine you own, in one place — end-to-end +encrypted, on hardware you control. +[![CI](https://github.com/antgrid-ai/antgrid/actions/workflows/ci.yml/badge.svg)](https://github.com/antgrid-ai/antgrid/actions/workflows/ci.yml) [![License: Elastic License 2.0](https://img.shields.io/badge/license-Elastic%20License%202.0-4b5563?style=flat)](LICENSE.md) [![Latest release](https://img.shields.io/github/v/release/antgrid-ai/antgrid?style=flat&logo=github&label=release)](https://github.com/antgrid-ai/antgrid/releases/latest) [![Stars](https://img.shields.io/github/stars/antgrid-ai/antgrid?style=flat&logo=github)](https://github.com/antgrid-ai/antgrid/stargazers) Antgrid runs the coding agents you already use — Claude Code, Codex, Cursor and others — -in real terminals on your own hardware. Arm its supervisor on a session and it watches the -agent's attention signals, answers what it can, escalates what it can't, and calls a task -done only on concrete evidence — test output, exit codes, a diff — rather than the agent's -own report. - -Around each agent it puts the context you need to check that work yourself: multi-session -terminals, a file tree, git review with diffs, and a live browser preview. The same -workspace opens on a phone, over a relay that is end-to-end encrypted and cannot read a -byte of what passes through it. +in real terminals on your own hardware, and puts one screen over all of them: every +session on every machine you have signed in, grouped by the machine it is on. Around each +agent it puts the context you need to check the work yourself — multi-session terminals, a +file tree, git review with diffs, and a live browser preview. The same workspace opens on +a phone, over a relay that is end-to-end encrypted and cannot read a byte of what passes +through it. + +Arm its supervisor on a session and it goes further: it watches the agent's attention +signals, answers what it can, escalates what it can't, and calls a task done only on +concrete evidence — test output, exit codes, a diff — rather than the agent's own report. +That part is opt-in and it is the paid tier — `CAPABILITIES` in +[`bridge/src/entitlement.ts`](bridge/src/entitlement.ts) is the whole capability gate. The +only other paid line is how many machines one account may run agents on +(`FREE_WORKER_LIMIT` in [`web/src/billing/plans.ts`](web/src/billing/plans.ts)); everything +else is free. Antgrid does not replace your agent and ships no model of its own. -> Status: pre-release, working towards v1. +> [!NOTE] +> **Pre-release, working towards v1.** +> +> **Licence** — source-available under [Elastic License 2.0](#licence): free to read, +> fork, modify and self-host, including commercially. Not OSI open source. +> +> **Contributing** — bug reports are welcome; pull requests are not open yet +> ([CONTRIBUTING.md](CONTRIBUTING.md)). ## Features -- **Supervisor.** Arm it on a session and it watches the agent's attention signals, - answers what it can, escalates what it can't, and calls a task done only on concrete - evidence — test output, exit codes — rather than the agent's own report. You can also - give it follow-up steps to carry out once the task is done; it works through them in - order and stays armed until each one is satisfied. +- **Supervisor** *(paid tier)*. Arm it on a session and it watches the agent's attention + signals, answers what it can, escalates what it can't, and calls a task done only on + concrete evidence — test output, exit codes — rather than the agent's own report. You + can also give it follow-up steps to carry out once the task is done; it works through + them in order and stays armed until each one is satisfied. - **Bring your own agent.** Claude Code, Codex, opencode, Cursor, GitHub Copilot, Antigravity, Kilo, Kimi and Mistral Vibe are wired for notifications and session naming — the current set is `AGENTS` in [`bridge/src/agents/registry.ts`](bridge/src/agents/registry.ts). @@ -83,6 +97,16 @@ feature flag. Encryption protects the transport. It does not sandbox the agent, and it cannot make an untrusted agent safe to run on your machine. +And two things the list above is not. It is not an audit: there has been no external +penetration test and no certification. And it does not empty the trust boundary — it moves +the relay out of it, not our account service. Your phone learns a machine's Ed25519 +identity from your account's device inventory, which `app.antgrid.ai` serves, so that +service is trusted to hand you the right key even though the relay never is. + +None of this needs taking on trust. The handshake specification, both implementations and +the relay itself are linked above and in this repo; [SECURITY.md](SECURITY.md) is the +reporting policy if you find something wrong with them. + ## Architecture | Component | Path | Stack | Role | diff --git a/app/lib/analytics/crash_reporting.dart b/app/lib/analytics/crash_reporting.dart index f1e3cf68..dbe9eaa0 100644 --- a/app/lib/analytics/crash_reporting.dart +++ b/app/lib/analytics/crash_reporting.dart @@ -1,5 +1,12 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart' show kReleaseMode, visibleForTesting; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; +import '../util/ab_log.dart'; + final _pathLike = RegExp(r'([a-zA-Z]:)?[\\/][^\s"]+'); String _redact(String input) => input.replaceAll(_pathLike, ''); @@ -35,15 +42,22 @@ Object? _redactDeep(Object? value) { // than mutated in place: 9.x makes absPath/contextLine settable, but // preContext/postContext/vars stay getter-only, so a frame is the one object // here that assignment alone can't neutralize. +// +// module/package are redacted alongside absPath because the frames +// sentry-native contributes carry an absolute module path there; on a Dart +// frame both are null, so it costs nothing. fileName deliberately is NOT, and +// that is where this diverges from the bridge's twin on purpose: a Dart frame's +// fileName is a `package:`/`dart:` URI naming our own source, and _pathLike +// would eat everything after its first slash — identity lost, nothing gained. SentryStackFrame _scrubFrame(SentryStackFrame f) => SentryStackFrame( absPath: _redactNullable(f.absPath), fileName: f.fileName, function: f.function, - module: f.module, + module: _redactNullable(f.module), lineNo: f.lineNo, colNo: f.colNo, inApp: f.inApp, - package: f.package, + package: _redactNullable(f.package), native: f.native, platform: f.platform, imageAddr: f.imageAddr, @@ -136,6 +150,51 @@ SentryEvent? scrubCrashEvent(SentryEvent event) { return event; } +/// Whether this platform's native Sentry SDK is the sentry-native C library. +/// Only that one reads [SentryFlutterOptions.nativeDatabasePath]; the Apple and +/// Android SDKs pick their own location and ignore it. +@visibleForTesting +bool get usesNativeCrashDatabase => Platform.isWindows || Platform.isLinux; + +/// Where sentry-native keeps its crash database, given the app's per-user +/// support directory. +@visibleForTesting +String nativeCrashDatabasePath(String supportDir) => + p.join(supportDir, '.sentry-native'); + +/// Resolves a WRITABLE crash-database directory, or null to leave the SDK on +/// its own default. +/// +/// `sentry_flutter` never assigns `nativeDatabasePath` itself, and sentry-native +/// then falls back to `.sentry-native` relative to the CURRENT WORKING +/// DIRECTORY. That is unwritable in exactly the configuration we ship on +/// Windows: a Store-launched MSIX gets `C:\Windows\System32` as its cwd, and +/// its own install dir under `WindowsApps` is read-only. `sentry_init` then +/// fails and native crash capture is absent with NO symptom to notice it by — +/// no handler process, no database, and (because auto-session-tracking is a +/// native option) no release-health sessions either, which is what would +/// otherwise have shown the pipeline was dead. Anchoring the path to the +/// support directory is what makes native capture work in a packaged build. +Future _resolveNativeDatabasePath() async { + if (!usesNativeCrashDatabase) return null; + try { + final dir = await getApplicationSupportDirectory(); + return nativeCrashDatabasePath(dir.path); + } catch (e) { + // Crash reporting must never be the reason the app fails to start; the SDK + // falls back to the cwd-relative default, which is today's behaviour. Logged + // rather than swallowed because that fallback IS the bug this function + // exists to fix, and it has no other symptom — no handler, no database, no + // release-health session, nothing that looks like a failure. + AbLog.warn( + 'crashReporting', + 'support dir unresolvable; leaving nativeDatabasePath at the SDK default', + fields: {'error': '$e'}, + ); + return null; + } +} + Future initCrashReporting({ required bool enabled, required String dsn, @@ -145,12 +204,39 @@ Future initCrashReporting({ await runApp(); return; } + final nativeDatabasePath = await _resolveNativeDatabasePath(); await SentryFlutter.init((options) { options.dsn = dsn; options.sendDefaultPii = false; options.attachScreenshot = false; + // The SDK reports its OWN failures at debug level and nowhere else, so a + // release build that cannot initialise its native layer looks exactly like + // one that simply never crashed. Costly to leave on in production (every + // envelope is logged), so it is on everywhere else instead. + options.debug = !kReleaseMode; + if (nativeDatabasePath != null) { + options.nativeDatabasePath = nativeDatabasePath; + } // attachViewHierarchy is @experimental and defaults to false; no explicit // set needed. options.beforeSend = (event, hint) => scrubCrashEvent(event); + // beforeSend is NOT the whole story on the platforms that have a native + // layer. A native crash is written and posted by that layer itself, and the + // C binding only ever sets dsn/release/database_path and friends — it never + // calls `sentry_options_set_before_send` — so nothing sent from there passes + // through the callback above. Breadcrumbs are the part of that envelope we + // still control: NativeScopeObserver mirrors the Dart scope down, and + // beforeBreadcrumb runs before the observers are notified, so scrubbing here + // is what keeps a path out of the copy the native layer holds. The frames + // and contexts of a native crash remain outside our reach by construction. + options.beforeBreadcrumb = (breadcrumb, hint) { + if (breadcrumb == null) return null; + breadcrumb.message = _redactNullable(breadcrumb.message); + final data = breadcrumb.data; + if (data != null) { + breadcrumb.data = Map.from(_redactDeep(data) as Map); + } + return breadcrumb; + }; }, appRunner: runApp); } diff --git a/app/lib/demo/demo_transport.dart b/app/lib/demo/demo_transport.dart index 05cbd309..0f7e7267 100644 --- a/app/lib/demo/demo_transport.dart +++ b/app/lib/demo/demo_transport.dart @@ -351,6 +351,26 @@ class DemoTransport extends BufferedAgentTransport { case 'git:list-branches': return >[kDemoGitBranches]; + // No fixture curates a commit log, and fabricating SHAs/dates that + // nothing else in the demo can act on risks looking broken rather than + // read-only — an empty page (renders as "No commits yet") is the + // honest answer, consistent with every other git verb here refusing to + // mutate anything real. Answered explicitly rather than falling into + // the fire-and-forget default: unlike a mutation, the History tab is + // WAITING on this reply and would otherwise sit on its spinner for the + // full gitActionTimeout. + case 'git:log': + return >[ + { + 'type': 'git:log-result', + 'projectId': kDemoProjectId, + 'checkoutId': 'main', + 'commits': const [], + 'skip': message['skip'] as int? ?? 0, + 'hasMore': false, + }, + ]; + case 'git:checkout': return >[ { @@ -381,6 +401,34 @@ class DemoTransport extends BufferedAgentTransport { _gitFailure('git:unstage-result', files: message['files']), ]; + case 'git:sync': + return >[ + { + ..._gitFailure('git:sync-result'), + 'op': message['op'] as String? ?? 'push', + 'branch': kDemoBranch, + 'failureKind': 'unknown', + }, + ]; + + // Non-zero counts on purpose: the demo should show the sync control in + // the state worth looking at, not greyed out with nothing to do. + case 'git:sync-status': + return >[ + { + 'type': 'git:sync-state', + 'projectId': kDemoProjectId, + 'checkoutId': 'main', + 'branch': kDemoBranch, + 'remote': 'origin', + 'remoteBranch': kDemoBranch, + 'ahead': 2, + 'behind': 1, + 'hasUpstream': true, + 'hasRemote': true, + }, + ]; + case 'file:search': return _search( query: message['query'] as String? ?? '', diff --git a/app/lib/demo/fixtures/demo_workspace_fixtures.dart b/app/lib/demo/fixtures/demo_workspace_fixtures.dart index 8e995823..322cf9ef 100644 --- a/app/lib/demo/fixtures/demo_workspace_fixtures.dart +++ b/app/lib/demo/fixtures/demo_workspace_fixtures.dart @@ -12,7 +12,7 @@ library; import '../demo_identity.dart'; -const String kDemoBranch = 'feature/checkout-validation'; +const String kDemoBranch = 'checkout'; /// Shared by the wire frame below and the New Session picker's branch catalog, /// which cannot go to a bridge for the sample project's branches. diff --git a/app/lib/design/ab_icons.dart b/app/lib/design/ab_icons.dart index 5c03076c..b24c6feb 100644 --- a/app/lib/design/ab_icons.dart +++ b/app/lib/design/ab_icons.dart @@ -102,6 +102,8 @@ abstract final class AbIcons { static const bell = Codicon.bell; static const gitCommit = Codicon.git_commit; static const gitBranch = Codicon.source_control; + static const gitPush = Codicon.repo_push; + static const gitPull = Codicon.repo_pull; static const code = Codicon.code; // Unchecked-state indicator for toggle rows (outline only, no fill). static const circle = Codicon.circle_large_outline; diff --git a/app/lib/design/widgets/ab_branch_pill.dart b/app/lib/design/widgets/ab_branch_pill.dart index 081a7c9c..a058517f 100644 --- a/app/lib/design/widgets/ab_branch_pill.dart +++ b/app/lib/design/widgets/ab_branch_pill.dart @@ -10,11 +10,13 @@ class AbBranchPill extends StatelessWidget { super.key, required this.branch, this.ahead = 0, + this.behind = 0, this.onTap, }); final String branch; final int ahead; + final int behind; final VoidCallback? onTap; @override @@ -42,12 +44,31 @@ class AbBranchPill extends StatelessWidget { maxLines: 1, softWrap: false, overflow: TextOverflow.ellipsis, + // Default TextWidthBasis.parent reports the FULL Flexible share + // as this Text's width regardless of how short `branch` is, so + // a one-word branch still claims the whole cap and leaves a + // sibling with nothing to shrink into. longestLine reports the + // actual ink width instead — unchanged once ellipsis is + // actually clipping (that already fills the share). + textWidthBasis: TextWidthBasis.longestLine, style: AbTokens.monoStyle( fontSize: AbTokens.fontXs, color: palette.textMuted, ), ), ), + // Behind before ahead, the order every SCM status line uses. + if (behind > 0) ...[ + const SizedBox(width: AbTokens.space4), + Text( + '↓$behind', + style: AbTokens.monoStyle( + fontSize: AbTokens.fontXs, + color: palette.textMuted, + fontWeight: FontWeight.w500, + ), + ), + ], if (ahead > 0) ...[ const SizedBox(width: AbTokens.space4), Text( diff --git a/app/lib/design/widgets/ab_breadcrumb.dart b/app/lib/design/widgets/ab_breadcrumb.dart index 83635928..b34d84b8 100644 --- a/app/lib/design/widgets/ab_breadcrumb.dart +++ b/app/lib/design/widgets/ab_breadcrumb.dart @@ -45,18 +45,47 @@ class AbBreadcrumb extends StatelessWidget { color: isLast ? palette.textPrimary : palette.textMuted, fontWeight: isLast ? FontWeight.w500 : FontWeight.w400, ); + final segment = isLast && leafOverride != null + ? DefaultTextStyle.merge(style: style, child: leafOverride!) + : Text( + segments[i], + maxLines: 1, + overflow: TextOverflow.ellipsis, + // Default TextWidthBasis.parent reports the full incoming + // constraint as this Text's width regardless of content length — + // for the CAPPED leading segment below that means a short + // project name still claims the entire 140px ConstrainedBox, + // leaving the leaf nothing to grow into. longestLine reports the + // actual ink width instead (unchanged once ellipsis is actually + // clipping, since that already fills the available width). + textWidthBasis: TextWidthBasis.longestLine, + style: style, + ); + // Only the LAST segment (the leaf — a session or file name, the thing + // the user is actually looking for) is Flexible. A leading segment + // (the project/agent name) is usually short but was given an EQUAL + // flex share under `mainAxisSize.min` — Flutter's single-pass flex + // layout hands each flex child its own slice of the free space and + // never redistributes what a shorter sibling didn't use, so the leaf + // ellipsized at half the row while the other half sat empty next to + // it. + // + // A leading segment is capped with ConstrainedBox instead of left + // unflexed: RenderFlex hands a NON-flex Row child an UNBOUNDED main-axis + // constraint in its first layout pass (it's meant to report its own + // natural size before free space is split among flex children) — so an + // unflexed segment would render at its full, uncapped width and could + // overflow the row outright for a long project/agent name, instead of + // ellipsizing. The cap keeps it non-flex (natural width, no wasted + // share) while still bounded; the leaf, still the sole flex child, + // claims 100% of whatever's left after it. children.add( - Flexible( - fit: FlexFit.loose, - child: isLast && leafOverride != null - ? DefaultTextStyle.merge(style: style, child: leafOverride!) - : Text( - segments[i], - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: style, - ), - ), + isLast + ? Flexible(fit: FlexFit.loose, child: segment) + : ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 140), + child: segment, + ), ); } return Row(mainAxisSize: MainAxisSize.min, children: children); diff --git a/app/lib/design/widgets/ab_icon_button.dart b/app/lib/design/widgets/ab_icon_button.dart index 8a689964..057d8444 100644 --- a/app/lib/design/widgets/ab_icon_button.dart +++ b/app/lib/design/widgets/ab_icon_button.dart @@ -1,6 +1,9 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart' show Tooltip; import 'package:flutter/widgets.dart'; +import '../../utils/platform_utils.dart'; import '../ab_tokens.dart'; import '../ab_colors.dart'; import 'ab_focus_ring.dart'; @@ -65,6 +68,31 @@ class AbIconButton extends StatefulWidget { /// Glyph size. Defaults to [AbTokens.iconButtonGlyph]. final double? glyphSize; + /// Edge of a button's visual box in [context]. + /// + /// The authority on the button's own size, so a row anchoring its height on + /// a button — or a cell aligning against one — reads the value from the + /// widget that owns it instead of re-deriving a constant that the text + /// scaler beats above UI Size 1.0. [boxSize] mirrors the instance field of + /// the same name: a caller measuring a button that overrides its box has to + /// pass that override, or it reserves the default and the button overflows. + static double boxExtent(BuildContext context, {double? boxSize}) => + MediaQuery.textScalerOf(context).scale(boxSize ?? AbTokens.iconButtonBox); + + /// Width a button occupies in [context], tap inflation included. + /// + /// [AbTapTarget] raises minWidth to [AbTokens.tapTargetMin] on mobile + /// unconditionally (`compact` gates only minHeight), and that floor does not + /// scale — so above ~1.84x the box overtakes it and the mobile width changes + /// character. Both regimes are in the max. + static double footprintWidth(BuildContext context, {double? boxSize}) => + isMobilePlatform + ? math.max( + AbTokens.tapTargetMin, + boxExtent(context, boxSize: boxSize), + ) + : boxExtent(context, boxSize: boxSize); + @override State createState() => _AbIconButtonState(); } @@ -113,7 +141,7 @@ class _AbIconButtonState extends State { // target height — the target stops growing with the row. A bounded host // (AbToolbar's fixed row height) clamps the box on its own. final scaler = MediaQuery.textScalerOf(context); - final boxSize = scaler.scale(widget.boxSize ?? AbTokens.iconButtonBox); + final boxSize = AbIconButton.boxExtent(context, boxSize: widget.boxSize); final glyphSize = scaler.scale( widget.glyphSize ?? AbTokens.iconButtonGlyph, ); diff --git a/app/lib/design/widgets/ab_list_row.dart b/app/lib/design/widgets/ab_list_row.dart index 1559a75a..84d3190f 100644 --- a/app/lib/design/widgets/ab_list_row.dart +++ b/app/lib/design/widgets/ab_list_row.dart @@ -35,6 +35,16 @@ class AbRowAction { final AbIconButtonTone tone; } +/// Minimum content height for a row that reveals an affordance on hover. +/// +/// An [AbIconButton] is the tallest thing in an [AbRowDensity.sm] row, so +/// mounting one on pointer-enter grows the row ~10px and shoves the list below +/// it down. Anchoring the content instead is what lets the affordance be +/// mounted and unmounted freely. An enum, not a `double`: the value is +/// scaler-dependent, so any literal a caller could pass is right at exactly one +/// UI Size. +enum AbRowContentFloor { none, iconButton } + /// Canonical list row: optional leading, title, optional subtitle, /// optional trailing actions or arbitrary trailing widget. /// @@ -77,6 +87,8 @@ class AbListRow extends StatefulWidget { this.enabled = true, this.hoverable = false, this.leadingGapOverride, + this.contentFloor = AbRowContentFloor.none, + this.onFocusChange, }) : assert( actions == null || trailing == null, 'AbListRow: pass actions or trailing, not both.', @@ -141,6 +153,14 @@ class AbListRow extends StatefulWidget { /// (unless [selected]). Opt-in so list flavors stay flat by default. final bool hoverable; + /// Floor under the row's content height. + final AbRowContentFloor contentFloor; + + /// Reports the focus highlight to the caller. A row that collapses its + /// hover-revealed actions has to know it can be reached by keyboard as well + /// as by pointer, or those actions become unreachable without a mouse. + final ValueChanged? onFocusChange; + @override State createState() => _AbListRowState(); } @@ -166,6 +186,29 @@ class _AbListRowState extends State { bool get _isSelected => widget.selected && widget.selectionStyle != AbRowSelection.none; + /// Whether this build will mount the detector that owns the focus highlight. + bool get _tracksFocus => + widget.enabled && + (widget.onTap != null || + widget.onDoubleTap != null || + widget.onLongPress != null); + + @override + void didUpdateWidget(AbListRow oldWidget) { + super.didUpdateWidget(oldWidget); + // Dropping the detector is silent: it reports no final `false`, so a row + // that goes disabled or non-interactive while focused would leave both the + // ring and any focus-revealed affordance latched on with nothing focused. + // Deferred because the caller answers with `setState`, and this runs inside + // the parent's build. + if (_focused && !_tracksFocus) { + _focused = false; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) widget.onFocusChange?.call(false); + }); + } + } + @override Widget build(BuildContext context) { final children = [ @@ -223,6 +266,11 @@ class _AbListRowState extends State { ], ]; + final rowChild = Row( + crossAxisAlignment: widget.crossAxisAlignment, + children: children, + ); + final showHover = widget.hoverable && _hovered && !_isSelected; Widget inner = Container( padding: _padding, @@ -242,10 +290,15 @@ class _AbListRowState extends State { // touch dimension. Declared unconditionally so an informational row // keeps the same height as its interactive neighbours in the same list. child: AbCompactTapTargets( - child: Row( - crossAxisAlignment: widget.crossAxisAlignment, - children: children, - ), + child: switch (widget.contentFloor) { + AbRowContentFloor.none => rowChild, + AbRowContentFloor.iconButton => ConstrainedBox( + constraints: BoxConstraints( + minHeight: AbIconButton.boxExtent(context), + ), + child: rowChild, + ), + }, ), ); @@ -285,11 +338,7 @@ class _AbListRowState extends State { return Opacity(opacity: 0.4, child: content); } - final interactive = - widget.onTap != null || - widget.onDoubleTap != null || - widget.onLongPress != null; - if (interactive) { + if (_tracksFocus) { final focusChild = AbFocusRing(focused: _focused, child: content); // With a double-tap handler, drive taps through a // [SerialTapGestureRecognizer] so a single tap fires IMMEDIATELY @@ -334,6 +383,7 @@ class _AbListRowState extends State { mouseCursor: SystemMouseCursors.click, onShowFocusHighlight: (v) { if (_focused != v) setState(() => _focused = v); + widget.onFocusChange?.call(v); }, // Only tracked when it can be seen: `_hovered` feeds nothing but the // `showHover` fill, so on a flat row — which every drawer row is — the diff --git a/app/lib/design/widgets/ab_menu.dart b/app/lib/design/widgets/ab_menu.dart index 3fcc581b..3a2ca4a0 100644 --- a/app/lib/design/widgets/ab_menu.dart +++ b/app/lib/design/widgets/ab_menu.dart @@ -1,3 +1,4 @@ +import 'dart:math' as math; import 'dart:ui' show ImageFilter, lerpDouble; import 'package:flutter/material.dart'; @@ -98,20 +99,7 @@ class AbMenu extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ - if (header != null) - Padding( - padding: const EdgeInsets.fromLTRB(10, 8, 10, 6), - child: Text( - header!.toUpperCase(), - // Antgrid spec: menu header is mono — the slot is usually - // a session/branch/ref identifier ("SESSION · refactor-…"). - style: AbTokens.monoStyle( - fontSize: AbTokens.fontXs, - letterSpacing: 0.66, - color: p.textMuted, - ), - ), - ), + if (header != null) AbMenuHeaderLabel(header!), // `FocusTraversalGroup` keeps Tab/Shift-Tab cycling inside the // menu rather than escaping to the page beneath while the // popup route is on top. @@ -156,6 +144,130 @@ class AbMenu extends StatelessWidget { } } +/// Menu-row metrics, shared by [AbLiveMenuRow] and `_MenuItemTile`. The two +/// row kinds sit in the same popup — a live row next to a static one — so any +/// drift between them reads as two different controls rather than one list. +const _menuRowPadding = EdgeInsets.symmetric(horizontal: 8, vertical: 6); +const double _menuRowIconSize = 13; +const double _menuRowIconGap = 9; + +/// Header-row padding, shared by [AbMenu]'s own `header` and the standalone +/// [AbMenuHeaderLabel] that reproduces it for a [showAbPanel] popup. +const _menuHeaderPadding = EdgeInsets.fromLTRB(10, 8, 10, 6); + +/// A [AbMenu] header row's chrome (uppercase mono label, muted), as a +/// standalone widget — for a popup opened via [showAbPanel] rather than +/// [showAbMenu]: that route's content is a live `builder`, not [AbMenu]'s +/// static `items`, so it cannot use [AbMenu.header] and instead composes this +/// directly above its own rows. +class AbMenuHeaderLabel extends StatelessWidget { + const AbMenuHeaderLabel(this.text, {super.key}); + + final String text; + + @override + Widget build(BuildContext context) { + return Padding( + padding: _menuHeaderPadding, + child: Text( + text.toUpperCase(), + // Antgrid spec: menu header is mono — the slot is usually a + // session/branch/ref identifier ("SESSION · refactor-…"). + style: AbTokens.monoStyle( + fontSize: AbTokens.fontXs, + letterSpacing: 0.66, + color: context.antgrid.textMuted, + ), + ), + ); + } +} + +/// One row of a live popup, styled to match [AbMenuItem]'s rendering +/// (`_MenuItemTile` below) — for a [showAbPanel] popup, where a row's label +/// or enabled state must react to a provider rather than being fixed at +/// menu-open time the way [AbMenuItem]/[showAbMenu]'s static entries are. +/// +/// Plain text + optional leading icon, no button chrome (border, fill, +/// segmented cells) — the menu-row look every other kebab in the app already +/// uses, so a popup built from live widgets doesn't read as a different kind +/// of control just because it has to watch a provider. +class AbLiveMenuRow extends StatelessWidget { + const AbLiveMenuRow({ + super.key, + required this.label, + required this.onTap, + this.icon, + this.enabled = true, + this.disabledReason, + this.tooltip, + }); + + final String label; + + /// Null renders the row inert (dimmed, no tap) with no way to reach it — + /// use [enabled]/[disabledReason] instead when the row should stay + /// reachable so its reason can surface. + final VoidCallback? onTap; + + final String? icon; + + /// False dims the row and, on tap, surfaces [disabledReason] as a snack bar + /// instead of calling [onTap] — same contract as [AbSegment.disabledReason]. + final bool enabled; + final String? disabledReason; + + /// Always-available hover/long-press hint, independent of [enabled] — for a + /// row that stays fully tappable but wants to explain itself first (e.g. an + /// agent Handler can't observe). + final String? tooltip; + + @override + Widget build(BuildContext context) { + final p = context.antgrid; + final live = enabled && onTap != null; + final fg = live ? p.textSecondary : p.textDisabled; + final iconFg = live ? p.textMuted : p.textDisabled; + + void activate() { + if (!live) { + final reason = enabled ? null : disabledReason; + if (reason != null) showAbSnackBar(context, reason); + return; + } + onTap!(); + } + + Widget tile = MouseRegion( + cursor: live ? SystemMouseCursors.click : SystemMouseCursors.basic, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: activate, + child: Container( + padding: _menuRowPadding, + child: Row( + children: [ + if (icon != null) ...[ + AbIcon(icon!, size: _menuRowIconSize, color: iconFg), + const SizedBox(width: _menuRowIconGap), + ], + Expanded( + child: Text( + label, + style: TextStyle(fontSize: AbTokens.fontSm, color: fg), + ), + ), + ], + ), + ), + ), + ); + final hint = tooltip ?? (enabled ? null : disabledReason); + if (hint != null) tile = AbTooltip(message: hint, child: tile); + return tile; + } +} + /// How far a fully receded popup lets its ground through — see /// [AbPopupSurface.quiet]. Kept above the point where the transcript underneath /// starts competing with the popup's own labels for the eye. @@ -299,8 +411,10 @@ enum AbMenuPlacement { below, above } /// /// [bounds] (overlay coordinates) optionally restricts the area the /// menu may occupy — useful when the anchor lives inside a drawer or -/// other sub-region that the popup shouldn't visually escape. When -/// null, the menu is clamped only by the overlay's [SafeArea] insets. +/// other sub-region that the popup shouldn't visually escape. It is always +/// intersected with [safeMenuBounds] — the screen inset by its safe-area +/// padding, in the same absolute frame as [anchorRect] — which is the whole +/// clamp when [bounds] is null. /// Pass `MenuBoundsScope.maybeOf(context)` to auto-pick up the nearest /// scope. /// @@ -350,6 +464,52 @@ Rect? abMenuAnchorRect(BuildContext context) { return box.localToGlobal(Offset.zero, ancestor: overlay) & box.size; } +/// The overlay's full extent, inset by the device's safe-area padding +/// (notch/status bar/home indicator) — in the SAME overlay-absolute frame +/// [abMenuAnchorRect] returns. Used as the default clamp region for both +/// popup routes below. +/// +/// Deliberately NOT a `SafeArea` widget wrapping the route's content: a +/// `SafeArea` shifts its child's own coordinate origin by the inset, and +/// [_AbMenuLayoutDelegate] positions its child using [anchorRect] — already +/// expressed in absolute overlay coordinates. Nesting the layout inside a +/// `SafeArea` re-applied that same top inset a SECOND time on top of an +/// anchor that was already safely below it, so a menu anchored near the top +/// of the screen (a phone header's kebab) opened a whole status-bar's-height +/// below the button it belonged to, reading as a stray gap rather than a +/// popup hanging off its trigger. Feeding the inset into the delegate's own +/// `bounds` clamp keeps content off the unsafe edges without moving the +/// coordinate frame the anchor math depends on. +Rect safeMenuBounds(BuildContext context) { + final padding = MediaQuery.paddingOf(context); + final size = MediaQuery.sizeOf(context); + return Rect.fromLTWH( + padding.left, + padding.top, + math.max(0, size.width - padding.left - padding.right), + math.max(0, size.height - padding.top - padding.bottom), + ); +} + +/// The region a popup may actually occupy: a caller's scoped [bounds] +/// INTERSECTED with [safeMenuBounds], never one or the other. +/// +/// The two answer different questions — a scope says which sub-region of the +/// screen the popup belongs to (a drawer, a rail) and knows nothing about the +/// notch — so treating them as alternatives silently drops the inset for every +/// scoped caller. Every `MenuBoundsScope` in the app is a full-height drawer +/// whose rect reaches both screen edges, which is exactly where the home +/// indicator and the status bar are: its footer menu's last row would sit +/// under them. The `SafeArea` this replaced was additive for the same reason. +Rect _resolveMenuBounds(BuildContext context, Rect? bounds) { + final safe = safeMenuBounds(context); + if (bounds == null) return safe; + final clamped = bounds.intersect(safe); + // A scope lying wholly outside the safe area has no honest intersection; + // the safe rect is at least on screen. + return clamped.isEmpty ? safe : clamped; +} + /// Show an arbitrary LIVE widget in the AbMenu popup chrome, anchored like /// [showAbMenu]. Unlike showAbMenu's static entries, [builder] runs inside the /// route, so a ConsumerWidget child keeps watching providers while open @@ -444,17 +604,15 @@ class _AbPanelRoute extends PopupRoute { ), ), ); - return SafeArea( - child: CustomSingleChildLayout( - delegate: _AbMenuLayoutDelegate( - anchorRect: anchorRect, - preferred: preferred, - gap: gap, - bounds: bounds, - ), - child: capturedThemes.wrap( - FadeTransition(opacity: animation, child: keyboard), - ), + return CustomSingleChildLayout( + delegate: _AbMenuLayoutDelegate( + anchorRect: anchorRect, + preferred: preferred, + gap: gap, + bounds: _resolveMenuBounds(context, bounds), + ), + child: capturedThemes.wrap( + FadeTransition(opacity: animation, child: keyboard), ), ); } @@ -615,17 +773,15 @@ class _AbMenuRoute extends PopupRoute { child: menu, ), ); - return SafeArea( - child: CustomSingleChildLayout( - delegate: _AbMenuLayoutDelegate( - anchorRect: anchorRect, - preferred: preferred, - gap: gap, - bounds: bounds, - ), - child: capturedThemes.wrap( - FadeTransition(opacity: animation, child: keyboard), - ), + return CustomSingleChildLayout( + delegate: _AbMenuLayoutDelegate( + anchorRect: anchorRect, + preferred: preferred, + gap: gap, + bounds: _resolveMenuBounds(context, bounds), + ), + child: capturedThemes.wrap( + FadeTransition(opacity: animation, child: keyboard), ), ); } @@ -781,7 +937,7 @@ class _MenuItemTileState extends State<_MenuItemTile> { child: GestureDetector( onTap: _activate, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + padding: _menuRowPadding, decoration: BoxDecoration( color: active ? p.bgHover : Colors.transparent, borderRadius: AbTokens.borderRadius3, @@ -790,8 +946,8 @@ class _MenuItemTileState extends State<_MenuItemTile> { children: [ if (i.icon != null) Padding( - padding: const EdgeInsets.only(right: 9), - child: AbIcon(i.icon!, size: 13, color: iconFg), + padding: const EdgeInsets.only(right: _menuRowIconGap), + child: AbIcon(i.icon!, size: _menuRowIconSize, color: iconFg), ), Expanded( child: Text( diff --git a/app/lib/design/widgets/ab_prompt_field.dart b/app/lib/design/widgets/ab_prompt_field.dart new file mode 100644 index 00000000..8f68d89a --- /dev/null +++ b/app/lib/design/widgets/ab_prompt_field.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; + +import '../ab_colors.dart'; +import '../ab_tokens.dart'; + +/// Multiline prompt input styled with the same tokened chrome as +/// [AbTextField] (which is single-line only, hence a bare field here). +/// `maxLines: null` grows with content; Enter/Shift+Enter handling lives on +/// the caller-owned [focusNode], so each host decides what a return key means +/// on its own surface. +/// +/// The chromeless [InputDecoration] recipe below is the load-bearing part: a +/// missing `isCollapsed` or `contentPadding` grows Material's 48px minimum box +/// inside whatever bordered shell the host drew around this field, which no +/// test catches and which reads as a design-system violation on screen. +class AbPromptField extends StatelessWidget { + const AbPromptField({ + super.key, + required this.controller, + required this.focusNode, + required this.hintText, + this.onChanged, + this.enabled = true, + this.readOnly = false, + this.minLines = 3, + }); + + final TextEditingController controller; + final FocusNode focusNode; + + /// Dimmed AND inert — the field is not the user's to touch at all. Distinct + /// from [readOnly], which stays fully legible. + final bool enabled; + + /// Frozen but undimmed, unlike [enabled]: a prompt already on the wire is + /// still the thing the user is waiting on, so it has to stay readable — and + /// "busy" must not look like the custom-agent "this field is not yours". + final bool readOnly; + + final String hintText; + final ValueChanged? onChanged; + + /// Opening height in lines. The host caps GROWTH with its own + /// [ConstrainedBox]; this only decides how much room the empty field claims. + final int minLines; + + @override + Widget build(BuildContext context) { + final field = TextField( + controller: controller, + focusNode: focusNode, + enabled: enabled, + readOnly: readOnly, + // A caret blinking in a field that cannot take the keystroke invites + // exactly the edit this lock exists to refuse. + showCursor: !readOnly, + maxLines: null, + minLines: minLines, + onChanged: onChanged, + style: AbTokens.sansStyle(color: context.antgrid.textPrimary), + cursorColor: context.antgrid.accent, + decoration: InputDecoration( + isCollapsed: true, + filled: false, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + disabledBorder: InputBorder.none, + hintText: hintText, + hintStyle: AbTokens.sansStyle(color: context.antgrid.textMuted), + contentPadding: EdgeInsets.zero, + ), + ); + // Disabled-state contract: opacity 0.4, no interaction. + if (!enabled) { + return IgnorePointer(child: Opacity(opacity: 0.4, child: field)); + } + return field; + } +} diff --git a/app/lib/design/widgets/ab_row_trailing.dart b/app/lib/design/widgets/ab_row_trailing.dart new file mode 100644 index 00000000..b23e3f8b --- /dev/null +++ b/app/lib/design/widgets/ab_row_trailing.dart @@ -0,0 +1,144 @@ +import 'package:flutter/widgets.dart'; + +import '../../utils/platform_utils.dart'; +import '../ab_tokens.dart'; +import 'ab_cross_fade.dart'; +import 'ab_icon_button.dart'; + +/// One cell of a row's trailing kit, sized to an [AbIconButton]'s own +/// footprint in this context. +/// +/// Right-anchoring a box-edge against the gutter is not optical alignment: a +/// 24px button pads a 14px glyph, a status dot is a bare 6px circle, so the two +/// centres land 9px apart. Centring both in a cell of the button's own width +/// puts every outermost glyph in the panel — dot, trash, kebab, refresh — in +/// one column, and keeps it there at any UI Size and on either platform, +/// because the width is [AbIconButton.footprintWidth] rather than a constant. +/// +/// The width is a floor, not a cap: a tight box would paint a larger tenant as +/// a squashed circle in an off-centre cell instead of overflowing where it can +/// be seen. Height is left unconstrained — row height is `AbRowContentFloor`'s +/// job. +class AbRowTrailingCell extends StatelessWidget { + const AbRowTrailingCell({super.key, this.child}); + + /// Null lays out a reserved, EMPTY cell: footprint wide, zero high. + final Widget? child; + + /// Assembles a trailing kit. Nulls are dropped BEFORE layout, so an absent + /// child costs no gap — a child that decides its own emptiness inside `build` + /// and returns a zero-width widget is still charged one, so pass `null` and + /// let the kit drop it. Returns null when nothing survives, so the caller can + /// pass `trailing: null` and reclaim `AbListRow`'s pre-trailing gap too. + /// + /// [ownsColumn] is false for a kit assembled as one ELEMENT of another kit: + /// the panel-edge column belongs to the outer one, so an inner kit must + /// neither claim a cell nor be held to the rule that it ends in one. + static Widget? kit(List cells, {bool ownsColumn = true}) { + final survivors = [for (final cell in cells) ?cell]; + if (survivors.isEmpty) return null; + assert( + !ownsColumn || + survivors.last is AbRowTrailingCell || + survivors.last is AbRowTrailingSwap, + 'AbRowTrailingCell.kit: the outermost element of a trailing kit must be ' + 'an AbRowTrailingCell or AbRowTrailingSwap, so every row in the panel ' + 'shares one trailing column.', + ); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < survivors.length; i++) ...[ + if (i > 0) const SizedBox(width: AbTokens.space4), + survivors[i], + ], + ], + ); + } + + @override + Widget build(BuildContext context) => ConstrainedBox( + constraints: BoxConstraints(minWidth: AbIconButton.footprintWidth(context)), + child: Center(widthFactor: 1, heightFactor: 1, child: child), + ); +} + +/// A terminal cell with two tenants: a permanent status glyph at rest, an +/// action in its place once [revealed]. +/// +/// For the one row shape where the status glyph lives at the panel edge +/// permanently — a machine band. Reserving a second cell for the action would +/// push the trash one slot inboard of every other row's, and collapsing the +/// action would slide the dot 28px on pointer-enter. Sharing the cell does +/// neither, and the action is never unmounted, so its in-flight state survives +/// its own modal. +/// +/// Touch has no pointer to reveal anything with, so [revealed] is permanently +/// true there and a swap would hide the status glyph forever. Mobile therefore +/// renders both: the resting glyph in a [AbTokens.dotSizeSm] slot inboard, the +/// action in the cell. +class AbRowTrailingSwap extends StatelessWidget { + const AbRowTrailingSwap({ + super.key, + required this.revealed, + required this.action, + this.resting, + }); + + final bool revealed; + final Widget action; + + /// Renders `SizedBox.shrink()` for "nothing to report" — the cell reserves + /// its own width, so an absent glyph moves nothing. + final Widget? resting; + + /// [AbCrossFade] keeps its child laid out, which is the whole point of the + /// shared cell — so the faded-out tenant needs the pointer taken off it by + /// hand, or a 0-opacity trash still takes hits. + /// + /// [announce] is what separates the two tenants. An invisible ACTION must + /// leave the semantics tree, or it is offered to a reader who cannot see it. + /// A resting STATUS glyph must not: [revealed] is driven by focus as well as + /// hover, so excluding it would delete the label at the exact moment a + /// screen-reader user arrives — the only report the row has for anyone who + /// cannot read the dot's hue. It reports a state, not a control, so it costs + /// nothing to leave announced under the action that covers it. + Widget _fade(bool visible, Widget child, {required bool announce}) => + AbCrossFade( + visible: visible, + duration: AbTokens.motionSnap, + child: IgnorePointer( + ignoring: !visible, + child: ExcludeSemantics(excluding: !announce, child: child), + ), + ); + + @override + Widget build(BuildContext context) { + final resting = this.resting; + if (isMobilePlatform) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (resting != null) ...[ + ConstrainedBox( + constraints: const BoxConstraints(minWidth: AbTokens.dotSizeSm), + child: resting, + ), + const SizedBox(width: AbTokens.space4), + ], + AbRowTrailingCell(child: action), + ], + ); + } + return AbRowTrailingCell( + child: Stack( + alignment: Alignment.center, + children: [ + if (resting != null) _fade(!revealed, resting, announce: true), + _fade(revealed, action, announce: revealed), + ], + ), + ); + } +} diff --git a/app/lib/design/widgets/ab_segmented.dart b/app/lib/design/widgets/ab_segmented.dart index 51012e89..5ee3d1b3 100644 --- a/app/lib/design/widgets/ab_segmented.dart +++ b/app/lib/design/widgets/ab_segmented.dart @@ -57,6 +57,7 @@ class AbSegmented extends StatelessWidget { required this.onSelect, this.onDisabledTap, this.iconOnly = false, + this.inactive = false, }); final List> segments; @@ -72,6 +73,16 @@ class AbSegmented extends StatelessWidget { /// options, one chosen", which is the part a lone icon button cannot say. final bool iconOnly; + /// Paints the selected cell as chosen-but-not-in-effect: the accent fill and + /// accent label give way to a neutral selection, so a control whose value is + /// stored and does nothing yet cannot read as one that is running. + /// + /// Every cell stays live — the choice is still the user's to make, and it + /// starts working the moment whatever parked it is fixed. That is what + /// separates this from `AbSegment.enabled: false`, which refuses the choice + /// outright and says nothing about whether the chosen one is in force. + final bool inactive; + /// Tap on a disabled cell. Defaults to surfacing [AbSegment.disabledReason] /// as a snack bar — hover can't be relied on for the reason (touch /// platforms, clicks before the tooltip dwell). Provide this to override. @@ -109,6 +120,7 @@ class AbSegmented extends StatelessWidget { onSelect: onSelect, onDisabledTap: onDisabledTap, iconOnly: iconOnly, + inactive: inactive, ), ], ], @@ -127,6 +139,7 @@ class _SegmentCell extends StatefulWidget { required this.onSelect, required this.onDisabledTap, required this.iconOnly, + required this.inactive, }); final AbSegment segment; @@ -134,6 +147,7 @@ class _SegmentCell extends StatefulWidget { final ValueChanged onSelect; final ValueChanged>? onDisabledTap; final bool iconOnly; + final bool inactive; @override State<_SegmentCell> createState() => _SegmentCellState(); @@ -165,7 +179,7 @@ class _SegmentCellState extends State<_SegmentCell> { final fg = !s.enabled ? p.textDisabled : widget.selected - ? p.accent + ? (widget.inactive ? p.textSecondary : p.accent) : _hovered ? p.textSecondary : p.textMuted; @@ -179,7 +193,9 @@ class _SegmentCellState extends State<_SegmentCell> { // in height rather than shipping a 29x18 target to phones. vertical: widget.iconOnly ? AbTokens.space6 : AbTokens.space4, ), - color: widget.selected ? p.accent.withAlpha(40) : null, + color: widget.selected + ? (widget.inactive ? p.bgSelected : p.accent.withAlpha(40)) + : null, alignment: Alignment.center, child: widget.iconOnly ? AbIcon(s.icon!, size: 13, color: fg) diff --git a/app/lib/design/widgets/ab_toast.dart b/app/lib/design/widgets/ab_toast.dart index 4ca12c1a..ebd820ac 100644 --- a/app/lib/design/widgets/ab_toast.dart +++ b/app/lib/design/widgets/ab_toast.dart @@ -125,7 +125,20 @@ void showAbToastOverlay( }) { final overlay = Overlay.maybeOf(context); if (overlay == null) return; + showAbToastOn(overlay, toast: toast, duration: duration); +} +/// [showAbToastOverlay] for a caller holding the [OverlayState] itself. +/// +/// `Overlay.maybeOf` resolves through an inherited marker that each overlay +/// ENTRY plants, so it answers only from inside a mounted route — a caller +/// working from a navigator key (no widget of its own, firing long after the +/// screen that started it) has no such context and would silently show nothing. +void showAbToastOn( + OverlayState overlay, { + required AbToast toast, + Duration duration = const Duration(seconds: 4), +}) { late final OverlayEntry entry; var removed = false; void remove() { diff --git a/app/lib/launcher/host_control_client.dart b/app/lib/launcher/host_control_client.dart index c2d38392..8a4864f7 100644 --- a/app/lib/launcher/host_control_client.dart +++ b/app/lib/launcher/host_control_client.dart @@ -540,6 +540,7 @@ class HostControlClient { required String projectPath, required String branch, bool allowActiveSessions = false, + bool stashIfDirty = false, Duration timeout = const Duration(seconds: 10), }) async { final m = await _post({ @@ -548,6 +549,7 @@ class HostControlClient { 'projectPath': projectPath, 'branch': branch, 'allowActiveSessions': allowActiveSessions, + 'stashIfDirty': stashIfDirty, }, timeout: timeout); final current = m['current']; if (current is! String) { diff --git a/app/lib/launcher/local_agent_launcher.dart b/app/lib/launcher/local_agent_launcher.dart index 4e23c12e..2911318f 100644 --- a/app/lib/launcher/local_agent_launcher.dart +++ b/app/lib/launcher/local_agent_launcher.dart @@ -33,6 +33,7 @@ class BootstrapPayload { this.licenseApiUrl, this.relayUrl, this.ownerPid, + this.telemetryEnabled = false, String? ownerBuild, }) : ownerBuild = ownerBuild ?? BuildInfo.summary; @@ -45,6 +46,7 @@ class BootstrapPayload { this.licenseApiUrl, this.relayUrl, this.ownerPid, + this.telemetryEnabled = false, String? ownerBuild, }) : ownerBuild = ownerBuild ?? BuildInfo.summary, projectId = null, @@ -68,6 +70,18 @@ class BootstrapPayload { /// literal nobody bumps, so it is identical across every release. final String ownerBuild; + /// The user's telemetry consent, carried so the host can decide whether to + /// bring up its own crash reporting (`bridge/src/crash-reporting.ts`). Read + /// from the same `telemetryEnabled` setting that gates the app's own Sentry, + /// so one install never reports from one half and not the other. + /// + /// Defaults to FALSE, and every caller passes it explicitly: the host reads + /// its bootstrap once, so a call site that forgets this should fall silent, + /// never report without being asked to. The host's consent is likewise fixed + /// for its lifetime — the same restart-scoped gate the app applies to itself, + /// since `initCrashReporting` wraps `runApp` and is never re-run. + final bool telemetryEnabled; + /// First-core mode. The app only ever spawns `local`; the field stays a /// parameter because the bridge's `BootstrapPayloadSchema` also accepts /// `remote`, which additionally requires a `machine` block. @@ -86,6 +100,7 @@ class BootstrapPayload { }, if (ownerPid != null) 'ownerPid': ownerPid, 'ownerBuild': ownerBuild, + 'telemetryEnabled': telemetryEnabled, }; final d = device; if (d != null && licenseApiUrl != null && relayUrl != null) { @@ -160,6 +175,7 @@ class LocalAgentLauncher { DeviceRecord? device, String? licenseApiUrl, String? relayUrl, + bool telemetryEnabled = false, }) async { // A host computes repository identity because only it can correctly fold a // linked worktree into its primary checkout. Older hosts predate this @@ -169,6 +185,7 @@ class LocalAgentLauncher { licenseApiUrl: licenseApiUrl, relayUrl: relayUrl, ownerPid: pid, + telemetryEnabled: telemetryEnabled, ); final host = await _host.ensureHost(); final resolveClient = HostControlClient( @@ -199,6 +216,7 @@ class LocalAgentLauncher { device, licenseApiUrl, relayUrl, + telemetryEnabled, ); _inFlight[projectId] = fut; try { @@ -231,12 +249,14 @@ class LocalAgentLauncher { String? licenseApiUrl, String? relayUrl, bool forceRespawn = false, + bool telemetryEnabled = false, }) async { _host.bootstrapBuilder = () => BootstrapPayload.machineOnly( device: device, licenseApiUrl: licenseApiUrl, relayUrl: relayUrl, ownerPid: pid, + telemetryEnabled: telemetryEnabled, ); if (forceRespawn) { // Let any concurrent spawn settle first so the teardown+respawn below @@ -256,6 +276,7 @@ class LocalAgentLauncher { DeviceRecord? device, String? licenseApiUrl, String? relayUrl, + bool telemetryEnabled, ) async { // The host's stdin bootstrap, consumed only if ensureHost must spawn fresh. // `??=`: the FIRST project to open wins, so whichever device record was @@ -272,6 +293,7 @@ class LocalAgentLauncher { // dart:io `pid` — this app process; the host watches it and self-exits // when we die, so it can't outlive the app on any exit path. ownerPid: pid, + telemetryEnabled: telemetryEnabled, ); final host = await _host.ensureHost(); diff --git a/app/lib/main.dart b/app/lib/main.dart index 00dc5ccb..c8980ffc 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -40,7 +40,6 @@ import 'providers/projects.dart'; import 'providers/provider_retry.dart'; import 'providers/push.dart'; import 'providers/recent_agents.dart'; -import 'providers/recent_ports.dart'; import 'navigation/nav_console.dart'; import 'navigation/nav_controller.dart'; import 'navigation/nav_serialization.dart'; @@ -60,7 +59,6 @@ import 'storage/drawer_order_store.dart'; import 'storage/first_run_store.dart'; import 'storage/project_store.dart'; import 'storage/recent_agents_store.dart'; -import 'storage/recent_ports_store.dart'; import 'storage/update_handoff_store.dart'; import 'update/update_gate.dart'; import 'util/ab_log.dart'; @@ -140,7 +138,6 @@ Future main() async { final ( projectStore, recentAgentsStore, - recentPortsStore, drawerOrderStore, drawerCollapsedStore, cachedSessionsStore, @@ -150,7 +147,6 @@ Future main() async { ) = await ( ProjectStore.open(), RecentAgentsStore.open(), - RecentPortsStore.open(), DrawerOrderStore.open(), DrawerCollapsedStore.open(), CachedSessionsStore.open(), @@ -195,7 +191,6 @@ Future main() async { overrides: [ projectStoreProvider.overrideWithValue(projectStore), recentAgentsStoreProvider.overrideWithValue(recentAgentsStore), - recentPortsStoreProvider.overrideWithValue(recentPortsStore), drawerOrderStoreProvider.overrideWithValue(drawerOrderStore), drawerCollapsedStoreProvider.overrideWithValue(drawerCollapsedStore), cachedSessionsStoreProvider.overrideWithValue(cachedSessionsStore), @@ -321,7 +316,7 @@ Future main() async { ); await initCrashReporting( - enabled: container.read(appSettingsServiceProvider).telemetryEnabled, + enabled: container.read(telemetryEnabledProvider), dsn: AppEnvironment.sentryDsn, runApp: () async { runApp( @@ -363,7 +358,7 @@ class _TelemetryLifecycleObserver extends WidgetsBindingObserver { @visibleForTesting bool telemetryAllowed(ProviderContainer container) => !container.read(demoModeProvider) && - container.read(appSettingsServiceProvider).telemetryEnabled; + container.read(telemetryEnabledProvider); /// System-bar overlay style for [palette]: transparent bars (the app draws /// edge-to-edge on mobile, see main()) with icon brightness flipped off the diff --git a/app/lib/models/ab_message.dart b/app/lib/models/ab_message.dart index 07468475..d3d8fb87 100644 --- a/app/lib/models/ab_message.dart +++ b/app/lib/models/ab_message.dart @@ -3,6 +3,7 @@ import 'package:uuid/uuid.dart'; import 'agent_event.dart' show parseAgentEvent; import 'agent_hello.dart'; import 'file_tree_models.dart'; +import 'git_sync_state.dart'; import 'handler_state.dart' show HandlerEscalationChoice; import 'layout_models.dart'; import 'preview_models.dart'; @@ -105,12 +106,30 @@ class ProxyStatusInfo { class GitInfo { final String branch; - const GitInfo({required this.branch}); + /// Against the upstream REF, so as fresh as the last fetch — the same + /// contract `GitSyncState` documents. Both default to 0 rather than being + /// nullable: a bridge that predates the fields reports nothing, and "no + /// commits either way" is the right thing to render for an unknown answer. + final int ahead; + final int behind; + final bool hasUpstream; + + const GitInfo({ + required this.branch, + this.ahead = 0, + this.behind = 0, + this.hasUpstream = false, + }); static GitInfo? fromJson(Map json) { final branch = json['branch']; if (branch is! String) return null; - return GitInfo(branch: branch); + return GitInfo( + branch: branch, + ahead: json['ahead'] is int ? json['ahead'] as int : 0, + behind: json['behind'] is int ? json['behind'] as int : 0, + hasUpstream: json['hasUpstream'] == true, + ); } } @@ -273,6 +292,12 @@ class HandlerStatusMessage { /// the wrap-up and the read. final List> wrapUps; + /// Why this machine refuses Handler, present ONLY while it does — so absent + /// covers both the ordinary entitled machine and a bridge predating the + /// field, which want the same rendering. Left raw like [sessions]: the + /// parse lives with the model it builds ([HandlerEntitlement.fromWire]). + final Map? entitlement; + const HandlerStatusMessage({ required this.id, required this.timestamp, @@ -281,6 +306,7 @@ class HandlerStatusMessage { required this.sessions, this.snapshots = const [], this.wrapUps = const [], + this.entitlement, }); } @@ -587,6 +613,245 @@ class GitUnstageResultMessage { }); } +/// One `git stash` entry, as `git:stash-list-result` reports it. +class GitStashEntry { + /// e.g. `stash@{0}` — stable only until the next pop/drop shifts the list. + final String ref; + + /// The branch HEAD pointed at when this stash was created; "" if the + /// bridge couldn't parse it back off git's own reflog subject. + final String branch; + final String message; + + /// Unix seconds. + final int createdAt; + + const GitStashEntry({ + required this.ref, + required this.branch, + required this.message, + required this.createdAt, + }); +} + +class GitStashListResultMessage { + final String id; + final int timestamp; + final String projectId; + final List stashes; + final String? error; + + const GitStashListResultMessage({ + required this.id, + required this.timestamp, + required this.projectId, + required this.stashes, + this.error, + }); +} + +class GitStashPopResultMessage { + final String id; + final int timestamp; + final String projectId; + final String ref; + final bool success; + final String? error; + + const GitStashPopResultMessage({ + required this.id, + required this.timestamp, + required this.projectId, + required this.ref, + required this.success, + this.error, + }); +} + +class GitStashDropResultMessage { + final String id; + final int timestamp; + final String projectId; + final String ref; + final bool success; + final String? error; + + const GitStashDropResultMessage({ + required this.id, + required this.timestamp, + required this.projectId, + required this.ref, + required this.success, + this.error, + }); +} + +/// One row of `git:log-result` — a commit as the History tab lists it. +class GitLogEntry { + final String sha; + final String shortSha; + final String subject; + final String authorName; + final String authorEmail; + final String authorDate; + + const GitLogEntry({ + required this.sha, + required this.shortSha, + required this.subject, + required this.authorName, + required this.authorEmail, + required this.authorDate, + }); +} + +class GitLogResultMessage { + final String id; + final int timestamp; + final String projectId; + final List commits; + final int skip; + final bool hasMore; + final String? error; + + const GitLogResultMessage({ + required this.id, + required this.timestamp, + required this.projectId, + required this.commits, + required this.skip, + required this.hasMore, + this.error, + }); +} + +/// One file changed within a single commit — `git:commit-files-result`'s +/// per-path entry. No `staged` field (unlike [GitFileStatusEntry]): a commit +/// has no index/worktree split, only what it changed. +class GitCommitFileEntry { + final String path; + final String status; + final String? oldPath; + final int additions; + final int deletions; + + const GitCommitFileEntry({ + required this.path, + required this.status, + this.oldPath, + required this.additions, + required this.deletions, + }); +} + +class GitCommitFilesResultMessage { + final String id; + final int timestamp; + final String projectId; + final String sha; + final List files; + final String? error; + + const GitCommitFilesResultMessage({ + required this.id, + required this.timestamp, + required this.projectId, + required this.sha, + required this.files, + this.error, + }); +} + +class GitCommitDiffContentMessage { + final String id; + final int timestamp; + final String projectId; + final String sha; + final String path; + final String? diff; + final int additions; + final int deletions; + + const GitCommitDiffContentMessage({ + required this.id, + required this.timestamp, + required this.projectId, + required this.sha, + required this.path, + this.diff, + required this.additions, + required this.deletions, + }); +} + +class GitSyncResultMessage { + final String id; + final int timestamp; + final String projectId; + final GitSyncOp op; + final bool success; + + /// Null on a detached HEAD — the one shape with no branch to name. + final String? branch; + final String? remote; + final String? remoteBranch; + final String? summary; + final String? error; + final GitSyncFailureKind? failureKind; + + /// Git's own invocation and stderr, present only on failure. Carried whole + /// because they are what the agent handoff forwards. + final String? command; + final String? stderr; + + const GitSyncResultMessage({ + required this.id, + required this.timestamp, + required this.projectId, + required this.op, + required this.success, + this.branch, + this.remote, + this.remoteBranch, + this.summary, + this.error, + this.failureKind, + this.command, + this.stderr, + }); + + /// The failure this result describes, or null when it succeeded. Folds the + /// wire fields into the shape the toast and the agent handoff both read, so + /// neither has to know which of `error`/`summary` carries the message. + GitSyncFailure? get failure { + if (success) return null; + return GitSyncFailure( + op: op, + kind: failureKind ?? GitSyncFailureKind.unknown, + message: error ?? '${op.label} failed', + branch: branch, + remote: remote, + remoteBranch: remoteBranch, + command: command, + stderr: stderr, + ); + } +} + +class GitSyncStateMessage { + final String id; + final int timestamp; + final String projectId; + final GitSyncState state; + + const GitSyncStateMessage({ + required this.id, + required this.timestamp, + required this.projectId, + required this.state, + }); +} + class SearchMatchEntry { final String path; final int line; @@ -1056,6 +1321,19 @@ Object? parseAbMessage(Map json) { mimeType: json['mimeType'] as String?, ); + case 'file:resolve-path-result': + final projectId = json['projectId']; + final requestId = json['requestId']; + if (projectId is! String || requestId is! String) return null; + return FileResolvePathResultMessage( + id: id, + timestamp: timestamp, + projectId: projectId, + requestId: requestId, + relPath: json['relPath'] as String?, + isDirectory: json['isDirectory'] as bool? ?? false, + ); + case 'ports:update': final projectId = json['projectId']; if (projectId is! String) return null; @@ -1285,6 +1563,217 @@ Object? parseAbMessage(Map json) { error: json['error'] as String?, ); + case 'git:stash-list-result': + final stashProjectId = json['projectId']; + if (stashProjectId is! String) return null; + final stashesJson = json['stashes']; + final stashes = []; + if (stashesJson is List) { + for (final s in stashesJson) { + if (s is! Map) continue; + final ref = s['ref']; + final branch = s['branch']; + final message = s['message']; + final createdAt = s['createdAt']; + if (ref is! String || + branch is! String || + message is! String || + createdAt is! int) { + continue; + } + stashes.add( + GitStashEntry( + ref: ref, + branch: branch, + message: message, + createdAt: createdAt, + ), + ); + } + } + return GitStashListResultMessage( + id: id, + timestamp: timestamp, + projectId: stashProjectId, + stashes: stashes, + error: json['error'] as String?, + ); + + case 'git:stash-pop-result': + final popProjectId = json['projectId']; + final popRef = json['ref']; + final popSuccess = json['success']; + if (popProjectId is! String || popRef is! String || popSuccess is! bool) { + return null; + } + return GitStashPopResultMessage( + id: id, + timestamp: timestamp, + projectId: popProjectId, + ref: popRef, + success: popSuccess, + error: json['error'] as String?, + ); + + case 'git:stash-drop-result': + final dropProjectId = json['projectId']; + final dropRef = json['ref']; + final dropSuccess = json['success']; + if (dropProjectId is! String || + dropRef is! String || + dropSuccess is! bool) { + return null; + } + return GitStashDropResultMessage( + id: id, + timestamp: timestamp, + projectId: dropProjectId, + ref: dropRef, + success: dropSuccess, + error: json['error'] as String?, + ); + + case 'git:log-result': + final projectId = json['projectId']; + final skip = json['skip']; + final hasMore = json['hasMore']; + if (projectId is! String || skip is! int || hasMore is! bool) { + return null; + } + final commitsJson = json['commits']; + final commits = []; + if (commitsJson is List) { + for (final c in commitsJson) { + if (c is! Map) continue; + final sha = c['sha']; + final shortSha = c['shortSha']; + final subject = c['subject']; + final authorName = c['authorName']; + final authorEmail = c['authorEmail']; + final authorDate = c['authorDate']; + if (sha is! String || + shortSha is! String || + subject is! String || + authorName is! String || + authorEmail is! String || + authorDate is! String) { + continue; + } + commits.add( + GitLogEntry( + sha: sha, + shortSha: shortSha, + subject: subject, + authorName: authorName, + authorEmail: authorEmail, + authorDate: authorDate, + ), + ); + } + } + return GitLogResultMessage( + id: id, + timestamp: timestamp, + projectId: projectId, + commits: commits, + skip: skip, + hasMore: hasMore, + error: json['error'] as String?, + ); + + case 'git:commit-files-result': + final projectId = json['projectId']; + final sha = json['sha']; + if (projectId is! String || sha is! String) return null; + final filesJson = json['files']; + final files = []; + if (filesJson is List) { + for (final f in filesJson) { + if (f is! Map) continue; + final path = f['path']; + final status = f['status']; + if (path is! String || status is! String) continue; + files.add( + GitCommitFileEntry( + path: path, + status: status, + oldPath: f['oldPath'] as String?, + additions: f['additions'] as int? ?? 0, + deletions: f['deletions'] as int? ?? 0, + ), + ); + } + } + return GitCommitFilesResultMessage( + id: id, + timestamp: timestamp, + projectId: projectId, + sha: sha, + files: files, + error: json['error'] as String?, + ); + + case 'git:commit-diff-content': + final projectId = json['projectId']; + final sha = json['sha']; + final path = json['path']; + if (projectId is! String || sha is! String || path is! String) { + return null; + } + return GitCommitDiffContentMessage( + id: id, + timestamp: timestamp, + projectId: projectId, + sha: sha, + path: path, + diff: json['diff'] as String?, + additions: json['additions'] as int? ?? 0, + deletions: json['deletions'] as int? ?? 0, + ); + + case 'git:sync-result': + final syncProjectId = json['projectId']; + final syncSuccess = json['success']; + final syncOpRaw = json['op']; + if (syncProjectId is! String || + syncSuccess is! bool || + syncOpRaw is! String) { + return null; + } + // An unknown op is the one field with no safe fallback: a result the app + // cannot attribute to the button that is spinning would clear the wrong + // one. Rejecting the frame leaves the wall-clock latch to unstick it. + final syncOp = GitSyncOp.fromWire(syncOpRaw); + if (syncOp == null) return null; + final syncKindRaw = json['failureKind']; + return GitSyncResultMessage( + id: id, + timestamp: timestamp, + projectId: syncProjectId, + op: syncOp, + success: syncSuccess, + branch: json['branch'] as String?, + remote: json['remote'] as String?, + remoteBranch: json['remoteBranch'] as String?, + summary: json['summary'] as String?, + error: json['error'] as String?, + failureKind: syncKindRaw is String + ? GitSyncFailureKind.fromWire(syncKindRaw) + : null, + command: json['command'] as String?, + stderr: json['stderr'] as String?, + ); + + case 'git:sync-state': + final syncStateProjectId = json['projectId']; + if (syncStateProjectId is! String) return null; + return GitSyncStateMessage( + id: id, + timestamp: timestamp, + projectId: syncStateProjectId, + state: GitSyncState.fromJson(json), + ); + case 'file:search-result': final projectId = json['projectId']; final requestId = json['requestId']; @@ -1449,6 +1938,9 @@ Object? parseAbMessage(Map json) { sessions: sessions, snapshots: snapshots, wrapUps: wrapUps, + entitlement: json['entitlement'] is Map + ? json['entitlement'] as Map + : null, ); } diff --git a/app/lib/models/file_tree_models.dart b/app/lib/models/file_tree_models.dart index fdb37f70..d6b92069 100644 --- a/app/lib/models/file_tree_models.dart +++ b/app/lib/models/file_tree_models.dart @@ -1,4 +1,6 @@ -import 'ab_message.dart' show GitFileStatusEntry; +import 'ab_message.dart' + show GitFileStatusEntry, GitLogEntry, GitCommitFileEntry, GitStashEntry; +import 'git_sync_state.dart'; enum FileNodeType { file, directory } @@ -138,12 +140,83 @@ class FilesPaneState { } } +/// The History tab's commit list plus whatever per-commit file lists the user +/// has expanded. A `Set`, not a single "open commit" — the History tab lets +/// more than one commit's file list stay expanded at once (unlike an +/// accordion), and [collapseAll] (an empty [expandedShas]) is the explicit +/// action that folds all of them back up. +class GitHistoryState { + final List commits; + + /// True only while fetching the NEXT page (scroll-triggered); the first + /// page's own fetch is [initialLoad], since the list is empty either way and + /// the two need different placeholders (a full-pane spinner vs. a trailing + /// row). + final bool loadingMore; + final bool initialLoad; + final bool hasMore; + final String? error; + + /// Commits whose file list is expanded and showing. + final Set expandedShas; + + /// Per-commit file lists, once fetched — absent means "never requested", + /// distinct from an empty list (a commit with a message but no diff, e.g. an + /// empty merge commit). + final Map> filesBySha; + final Set filesLoadingShas; + final Map filesErrorBySha; + + const GitHistoryState({ + this.commits = const [], + this.loadingMore = false, + this.initialLoad = true, + this.hasMore = true, + this.error, + this.expandedShas = const {}, + this.filesBySha = const {}, + this.filesLoadingShas = const {}, + this.filesErrorBySha = const {}, + }); + + static const empty = GitHistoryState(); + + GitHistoryState copyWith({ + List? commits, + bool? loadingMore, + bool? initialLoad, + bool? hasMore, + String? error, + bool clearError = false, + Set? expandedShas, + Map>? filesBySha, + Set? filesLoadingShas, + Map? filesErrorBySha, + }) { + return GitHistoryState( + commits: commits ?? this.commits, + loadingMore: loadingMore ?? this.loadingMore, + initialLoad: initialLoad ?? this.initialLoad, + hasMore: hasMore ?? this.hasMore, + error: clearError ? null : (error ?? this.error), + expandedShas: expandedShas ?? this.expandedShas, + filesBySha: filesBySha ?? this.filesBySha, + filesLoadingShas: filesLoadingShas ?? this.filesLoadingShas, + filesErrorBySha: filesErrorBySha ?? this.filesErrorBySha, + ); + } +} + /// Per-tab right-pane state for the Git tab. /// /// Mutated only by Git-tab actions (requestDiff, clearDiff, gitViewFile, /// gitClearViewing). Reading these fields from the Files tab is a leak. /// -/// [diffPath]/[diffContent]/[diffLoading] back the DiffViewer. +/// [diffPath]/[diffContent]/[diffLoading] back the DiffViewer — for a working +/// -tree diff when [diffCommitSha] is null, or for that commit's diff of +/// [diffPath] when it is set. One shared slot rather than a second copy under +/// [GitHistoryState]: only one diff is ever open regardless of which tab it +/// was opened from, and the viewer itself renders the same either way. /// [viewingPath]/[viewingFile]/[viewingLoading] back the "View File from diff" /// mode that renders a FileContentViewer inside the Git pane. class GitPaneState { @@ -152,10 +225,20 @@ class GitPaneState { final int? diffAdditions; final int? diffDeletions; final bool diffLoading; + + /// Set when [diffPath] names a file WITHIN this commit rather than the + /// working tree — the History tab's file list opens a diff the same way the + /// Changes tab does, just scoped to a commit instead of HEAD. + final String? diffCommitSha; + final String? viewingPath; final FileContent? viewingFile; final bool viewingLoading; + /// The History tab's own state — commit list, pagination, and expanded + /// per-commit file lists. See [GitHistoryState]. + final GitHistoryState history; + /// Folders the user has collapsed in the changed-files tree. /// /// COLLAPSED, not expanded — the inverse of `FileTreeState.expandedPaths` — @@ -167,16 +250,45 @@ class GitPaneState { /// sitting on. final Set collapsedPaths; + /// How the branch stands against its upstream. Replayed on reconnect (it is + /// in `kCheckoutDurableReplayTypes`), so this is durable state rather than a + /// one-shot — an app that reconnects must not show a synced branch until the + /// next op. + final GitSyncState sync; + + /// The op currently in flight, if any. Null is idle; the two buttons are + /// disabled together while either runs, since both mutate the same branch. + final GitSyncOp? syncing; + + /// The last push/pull that failed, kept until the next sync attempt so the + /// panel can offer the agent handoff after the toast has gone. + final GitSyncFailure? lastSyncFailure; + + /// Every stash in the repository, most recent first — fetched lazily the + /// same way [history] is (see [FileService.claimStashLoad]), and re-fetched + /// after every checkout, pop, or drop rather than mutated locally: a stash + /// list is repo-wide (shared across every worktree), so anything else + /// risks drifting from a stash the user or agent created outside this + /// panel. Drives the Git panel's Restore/Discard banner — see + /// `git_panel.dart`'s `_StashBanner`. + final List stashes; + const GitPaneState({ this.diffPath, this.diffContent, this.diffAdditions, this.diffDeletions, this.diffLoading = false, + this.diffCommitSha, this.viewingPath, this.viewingFile, this.viewingLoading = false, this.collapsedPaths = const {}, + this.sync = GitSyncState.empty, + this.syncing, + this.lastSyncFailure, + this.history = GitHistoryState.empty, + this.stashes = const [], }); static const empty = GitPaneState(); @@ -187,12 +299,21 @@ class GitPaneState { int? diffAdditions, int? diffDeletions, bool? diffLoading, + String? diffCommitSha, + bool clearDiffCommitSha = false, bool clearDiff = false, String? viewingPath, FileContent? viewingFile, bool? viewingLoading, bool clearViewing = false, Set? collapsedPaths, + GitSyncState? sync, + GitSyncOp? syncing, + bool clearSyncing = false, + GitSyncFailure? lastSyncFailure, + bool clearSyncFailure = false, + GitHistoryState? history, + List? stashes, }) { return GitPaneState( diffPath: clearDiff ? null : (diffPath ?? this.diffPath), @@ -200,6 +321,9 @@ class GitPaneState { diffAdditions: clearDiff ? null : (diffAdditions ?? this.diffAdditions), diffDeletions: clearDiff ? null : (diffDeletions ?? this.diffDeletions), diffLoading: clearDiff ? false : (diffLoading ?? this.diffLoading), + diffCommitSha: (clearDiff || clearDiffCommitSha) + ? null + : (diffCommitSha ?? this.diffCommitSha), viewingPath: clearViewing ? null : (viewingPath ?? this.viewingPath), viewingFile: clearViewing ? null : (viewingFile ?? this.viewingFile), viewingLoading: clearViewing @@ -208,6 +332,13 @@ class GitPaneState { // Survives clearDiff/clearViewing: closing a diff is not a reason to // reopen every folder the user shut to find it. collapsedPaths: collapsedPaths ?? this.collapsedPaths, + sync: sync ?? this.sync, + syncing: clearSyncing ? null : (syncing ?? this.syncing), + lastSyncFailure: clearSyncFailure + ? null + : (lastSyncFailure ?? this.lastSyncFailure), + history: history ?? this.history, + stashes: stashes ?? this.stashes, ); } } @@ -390,3 +521,26 @@ class FileContentMessage { this.mimeType, }); } + +/// Reply to a `file:resolve-path` request — a path a terminal program printed +/// (an OSC 8 `file://` hyperlink target), resolved against the checkout the +/// request named. [relPath] is null when the path does not resolve inside +/// that checkout; the app never learns the checkout's absolute root, so only +/// the bridge can make this call. +class FileResolvePathResultMessage { + final String id; + final int timestamp; + final String projectId; + final String requestId; + final String? relPath; + final bool isDirectory; + + const FileResolvePathResultMessage({ + required this.id, + required this.timestamp, + required this.projectId, + required this.requestId, + this.relPath, + this.isDirectory = false, + }); +} diff --git a/app/lib/models/git_sync_state.dart b/app/lib/models/git_sync_state.dart new file mode 100644 index 00000000..de1401ac --- /dev/null +++ b/app/lib/models/git_sync_state.dart @@ -0,0 +1,200 @@ +import 'package:flutter/foundation.dart'; + +import 'branch_remote_status.dart'; + +/// Why a push or pull did not happen. Mirrors `GitSyncFailureKind` in +/// `bridge/src/git-sync.ts` BY HAND — the two drifting apart is silent. +/// +/// The app branches its COPY on this and never on the git output beside it: +/// git's prose is localized and reworded between versions, so parsing it here +/// would be a second, worse classifier. The raw stderr is forwarded to the +/// agent untouched instead. +enum GitSyncFailureKind { + noRemote, + noUpstream, + ambiguousRemote, + notFastForward, + rejected, + diverged, + auth, + conflict, + dirtyTree, + detached, + unknown; + + static GitSyncFailureKind fromWire(String raw) => switch (raw) { + 'no-remote' => GitSyncFailureKind.noRemote, + 'no-upstream' => GitSyncFailureKind.noUpstream, + 'ambiguous-remote' => GitSyncFailureKind.ambiguousRemote, + 'not-fast-forward' => GitSyncFailureKind.notFastForward, + 'rejected' => GitSyncFailureKind.rejected, + 'diverged' => GitSyncFailureKind.diverged, + 'auth' => GitSyncFailureKind.auth, + 'conflict' => GitSyncFailureKind.conflict, + 'dirty-tree' => GitSyncFailureKind.dirtyTree, + 'detached' => GitSyncFailureKind.detached, + // An unrecognized kind is a newer bridge, not a broken one. `unknown` + // already routes to the agent handoff with the stderr intact, which is the + // right answer for a failure this app cannot name. + _ => GitSyncFailureKind.unknown, + }; +} + +/// Which half of a sync is in flight, and which one a result describes. +enum GitSyncOp { + push, + pull; + + static GitSyncOp? fromWire(String raw) => switch (raw) { + 'push' => GitSyncOp.push, + 'pull' => GitSyncOp.pull, + _ => null, + }; + + String get label => this == GitSyncOp.push ? 'Push' : 'Pull'; +} + +/// How the checked-out branch stands against its upstream. +/// +/// The counts are LOCAL — measured against `refs/remotes`, so they are as +/// fresh as the last fetch. That is deliberate and is the same contract VS +/// Code's own indicator has: pulling is what refreshes them, and nothing in +/// the always-on path may reach the network. [state] is the exception, present +/// only when the app explicitly asked for a probe. +@immutable +class GitSyncState { + final String? branch; + final String? remote; + final String? remoteBranch; + final int ahead; + final int behind; + final bool hasUpstream; + final bool hasRemote; + + /// Result of an on-demand network probe, when one ran. Absent means the + /// counts above are the whole answer. + final BranchRemoteState? state; + + const GitSyncState({ + this.branch, + this.remote, + this.remoteBranch, + this.ahead = 0, + this.behind = 0, + this.hasUpstream = false, + this.hasRemote = false, + this.state, + }); + + static const empty = GitSyncState(); + + /// `origin/main` when both halves resolved, else null — used in copy, so it + /// must never render a dangling slash. + String? get remoteRefLabel { + final r = remote; + final b = remoteBranch; + if (r == null || r.isEmpty || b == null || b.isEmpty) return null; + return '$r/$b'; + } + + /// A branch with commits the remote does not have. The only condition under + /// which Push does anything — a branch with no upstream is [canPublish]. + bool get canPush => hasUpstream && ahead > 0; + + /// A branch that has never been pushed. Offered as Publish rather than Push, + /// matching what the bridge actually does (`push -u`). + bool get canPublish => hasRemote && branch != null && !hasUpstream; + + bool get canPull => hasUpstream && behind > 0; + + factory GitSyncState.fromJson(Map json) { + final rawState = json['state']; + return GitSyncState( + branch: json['branch'] as String?, + remote: json['remote'] as String?, + remoteBranch: json['remoteBranch'] as String?, + ahead: json['ahead'] is int ? json['ahead'] as int : 0, + behind: json['behind'] is int ? json['behind'] as int : 0, + hasUpstream: json['hasUpstream'] == true, + hasRemote: json['hasRemote'] == true, + state: rawState is String ? BranchRemoteState.fromWire(rawState) : null, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is GitSyncState && + other.branch == branch && + other.remote == remote && + other.remoteBranch == remoteBranch && + other.ahead == ahead && + other.behind == behind && + other.hasUpstream == hasUpstream && + other.hasRemote == hasRemote && + other.state == state; + + @override + int get hashCode => Object.hash( + branch, + remote, + remoteBranch, + ahead, + behind, + hasUpstream, + hasRemote, + state, + ); +} + +/// A push or pull that did not happen, kept whole so it can be handed to the +/// agent. +/// +/// [command] and [stderr] are the load-bearing fields and are carried verbatim +/// from git: they are what the agent needs to reconcile the branch, and +/// summarizing them here would throw away the only precise account of what +/// went wrong. +@immutable +class GitSyncFailure { + final GitSyncOp op; + final GitSyncFailureKind kind; + final String? branch; + final String? remote; + final String? remoteBranch; + + /// The bridge's own one-line message. Already user-readable — this is what + /// the toast shows. + final String message; + final String? command; + final String? stderr; + + const GitSyncFailure({ + required this.op, + required this.kind, + required this.message, + this.branch, + this.remote, + this.remoteBranch, + this.command, + this.stderr, + }); + + String? get remoteRefLabel { + final r = remote; + final b = remoteBranch; + if (r == null || r.isEmpty || b == null || b.isEmpty) return null; + return '$r/$b'; + } + + /// True when reconciling this needs judgement the app does not have — a + /// history to merge or rebase, a credential to find. These are what the + /// agent handoff is for. + /// + /// The rest ([GitSyncFailureKind.detached], [GitSyncFailureKind.noRemote]) + /// are states the user fixes directly, and offering the agent for them would + /// send it to do something a single tap already does. + bool get warrantsAgent => switch (kind) { + GitSyncFailureKind.noRemote || GitSyncFailureKind.detached => false, + _ => true, + }; +} diff --git a/app/lib/models/handler_state.dart b/app/lib/models/handler_state.dart index dfb43c23..c61ab58b 100644 --- a/app/lib/models/handler_state.dart +++ b/app/lib/models/handler_state.dart @@ -29,6 +29,69 @@ String handlerRunStateToWire(HandlerRunState s) { } } +/// How far Handler leans toward answering on the user's behalf. Mirrors the +/// bridge's `HandlerPersonalitySchema` (`bridge/src/protocol.ts`) and is carried +/// on every session snapshot. +/// +/// It moves one line only — where `handle` gives way to `escalate` — plus the +/// tone of the notification. It has no bearing on what a backlog item must cite +/// to close, which is why no surface may present it as a speed or quality dial. +enum HandlerPersonality { + /// Escalate freely; handle only the unambiguous. What a session judges under + /// until someone picks otherwise. + watchdog, + + /// Handle what the session itself settles; escalate genuine ambiguity. + closer, + + /// Handle wherever it can; escalate only where it must. + autopilot, +} + +/// The bridge resolves the default before sending, so a snapshot always names +/// one. Null here means a bridge too old to have the field — rendered as +/// "not reported", never as [HandlerPersonality.watchdog]: a picker showing a +/// preset the far end has never heard of is a control over nothing. +HandlerPersonality? handlerPersonalityFromWire(dynamic s) { + switch (s) { + case 'watchdog': + return HandlerPersonality.watchdog; + case 'closer': + return HandlerPersonality.closer; + case 'autopilot': + return HandlerPersonality.autopilot; + default: + return null; + } +} + +/// The wire spelling, the one place the enum is turned back into the string +/// `handler:configure` carries. +String handlerPersonalityToWire(HandlerPersonality p) => switch (p) { + HandlerPersonality.watchdog => 'watchdog', + HandlerPersonality.closer => 'closer', + HandlerPersonality.autopilot => 'autopilot', +}; + +/// Picker label. Sentence case, matching every other control in the app. +String handlerPersonalityLabel(HandlerPersonality p) => switch (p) { + HandlerPersonality.watchdog => 'Watchdog', + HandlerPersonality.closer => 'Closer', + HandlerPersonality.autopilot => 'Autopilot', +}; + +/// One line of what the preset actually does, shown under the picker. Phrased +/// as policy rather than personality: the user is choosing where a threshold +/// sits, and "cautious"/"bold" would describe a mood instead of a rule. +String handlerPersonalityBlurb(HandlerPersonality p) => switch (p) { + HandlerPersonality.watchdog => + 'Answers only what is unambiguous. Anything with two reasonable readings comes to you.', + HandlerPersonality.closer => + 'Answers what the session itself settles. Genuine ambiguity still comes to you.', + HandlerPersonality.autopilot => + 'Answers wherever it can, treating your goal as standing authority. It still escalates whatever it is unsure of.', +}; + /// How much of the Handler an armed session can actually get. Mirrors the /// bridge's `HandlerObservability` (`bridge/src/handler/engine.ts`), carried on /// each session snapshot. @@ -188,6 +251,10 @@ class HandlerSessionState { /// an agent, this describes a session (its live mode and its judge pick). final HandlerObservability? observability; + /// The posture this session judges under, as the bridge resolved it. Null + /// only from a bridge predating the field. + final HandlerPersonality? personality; + const HandlerSessionState({ required this.terminalId, required this.runState, @@ -201,6 +268,7 @@ class HandlerSessionState { this.parkKind, this.parkedUntil, this.observability, + this.personality, }); int get backlogTotal => backlog.length; @@ -227,6 +295,7 @@ class HandlerSessionState { parkKind: parkKind, parkedUntil: parkedUntil, observability: observability, + personality: personality, ); static HandlerSessionState? fromWire(dynamic json) { @@ -285,6 +354,7 @@ class HandlerSessionState { parkKind: parkKind is String ? parkKind : null, parkedUntil: parkedUntil is num ? parkedUntil.toInt() : null, observability: handlerObservabilityFromWire(json['observability']), + personality: handlerPersonalityFromWire(json['personality']), ); } } @@ -746,6 +816,63 @@ class HandlerActivityRecord { }); } +/// Why this machine will not run Handler. Mirrors the bridge's +/// `HandlerEntitlementWire` (`bridge/src/protocol.ts`), which carries only the +/// REFUSED half of its verdict — so an instance of this EXISTING is the +/// refusal, and there is no allowed case for a reader to check. +enum HandlerEntitlementReason { + /// The plan this machine is signed in on does not include Handler. The one + /// refusal the user can lift from inside the app, and so the only one that + /// carries an upgrade offer. + notEntitled, + + /// The machine holds credentials whose tier it cannot read — expired, + /// missing, or a label this bridge does not recognise. Not a paywall: an + /// upgrade buys nothing here, and offering one would sell a plan the user may + /// already have. + unreadable, +} + +/// Null for a reason this app has no sentence for, which a newer bridge can +/// send. Deliberately NOT folded into [HandlerEntitlementReason.unreadable]: +/// the two refusals prescribe opposite fixes, so a guess is worse than the +/// generic copy — and worse still is dropping the refusal, which is the silence +/// this whole field exists to end. +HandlerEntitlementReason? handlerEntitlementReasonFromWire(dynamic s) => + switch (s) { + 'not_entitled' => HandlerEntitlementReason.notEntitled, + 'unreadable' => HandlerEntitlementReason.unreadable, + _ => null, + }; + +/// The bridge's answer to "can this machine arm Handler at all", present only +/// when the answer is no. +class HandlerEntitlement { + const HandlerEntitlement({this.reason, this.tier}); + + /// Null when the bridge named a reason this app cannot speak to — see + /// [handlerEntitlementReasonFromWire]. + final HandlerEntitlementReason? reason; + + /// The plan the machine is actually on, when the bridge could read one, so + /// the upgrade copy can name it instead of talking around it. Absent + /// alongside [HandlerEntitlementReason.unreadable] by construction: that IS + /// the case of having no readable tier. + final String? tier; + + /// Null unless the payload is a refusal object — a `reason` that is not even + /// a string is malformed, and inventing a gate out of it would block arming + /// with nothing to say about why. + static HandlerEntitlement? fromWire(dynamic json) { + if (json is! Map || json['reason'] is! String) return null; + final tier = json['tier']; + return HandlerEntitlement( + reason: handlerEntitlementReasonFromWire(json['reason']), + tier: tier is String ? tier : null, + ); + } +} + /// Immutable app-side view of the bridge's Handler subsystem for one project. /// The bridge is the source of truth; this is rebuilt from `handler:*` /// messages and never persisted locally. @@ -778,6 +905,12 @@ class HandlerState { /// — so nothing that comes back can be matched to what went out. final Map> pendingInstructions; + /// Why this machine refuses Handler, or null when it does not. Project-scoped + /// because entitlement is an account fact rather than a session one, and the + /// surface that most needs it — the shield over a session that is not armed — + /// exists precisely where no session state does. + final HandlerEntitlement? entitlement; + const HandlerState({ this.defaultTool, required this.sessions, @@ -787,6 +920,7 @@ class HandlerState { this.wrapUps = const [], this.pendingUndo = const {}, this.pendingInstructions = const {}, + this.entitlement, }); const HandlerState.initial() @@ -797,7 +931,8 @@ class HandlerState { snapshots = const [], wrapUps = const [], pendingUndo = const {}, - pendingInstructions = const {}; + pendingInstructions = const {}, + entitlement = null; // Absence of any session is the wire's implicit 'off' — there is no // standalone off/on flag now that arming is per-terminal. @@ -806,6 +941,20 @@ class HandlerState { int get pendingEscalations => sessions.values.fold(0, (n, s) => n + s.pendingEscalations); + /// What a badge over the Handler tab counts. + /// + /// The larger of the sessions' own tally and the escalation rows actually + /// held, because the two fill from DIFFERENT frames: a `handler:escalation` + /// push appends a row without touching any session's `pendingEscalations` + /// (that only moves on the next `handler:status`), so folding the sessions + /// alone badges zero over a tab already rendering a NEEDS YOU card. A badge + /// and the surface it points at must never be able to answer differently + /// about what is waiting. + int get escalationBadgeCount => + pendingEscalations > escalations.length + ? pendingEscalations + : escalations.length; + // Folded on `at` rather than read off the tail: [escalations] is banded by // [compareEscalations], so `.last` is the newest NORMAL one and an urgent row // — the only kind anything asking for "the latest" would want to land on — @@ -823,6 +972,74 @@ class HandlerState { List pendingInstructionsFor(String terminalId) => pendingInstructions[terminalId] ?? const []; + /// This project's state narrowed to the one terminal the Handler tab shows. + /// + /// Only the tab narrows. The service and the bridge engine stay project-wide + /// — one HandlerService per project, one engine keyed by terminalId — because + /// escalations, undo offers and wrap-ups all have to keep arriving for + /// sessions nobody is looking at, and the agent bar's NEEDS YOU pill reads + /// the unnarrowed state to say so. + /// + /// [defaultTool] rides through unfiltered: it is the project's judge + /// fallback, and the session card resolves its judge label against it. + /// + /// A null [terminalId] narrows the per-session fields to nothing rather than + /// to everything: an unresolved focus names no session, and answering it with + /// the project's whole state would undo the narrowing at exactly that moment. + /// + /// [snapshots] and [wrapUps] are the exception, and are narrowed by OWNERSHIP + /// rather than by focus: both are project-scoped precisely because they + /// outlive the session that produced them (see their own docs), so filtering + /// them on terminalId alone hides an undo offer the moment its session + /// disarms — which for a wrap-up is always, since a wrap-up disarms the + /// session it reports on. What is kept is this terminal's own plus every + /// ORPHAN no live session can claim: the narrowing still holds for sessions + /// that exist, and the account of finished work stays reachable. + /// + /// Built through [copyWith] rather than the constructor so a field added to + /// this class later is CARRIED, not silently reset to its default on the one + /// surface that reads a narrowed state. Whether it then needs narrowing of + /// its own is a question a reader gets to see; a blank section is not. + HandlerState forTerminal(String? terminalId) { + bool ownedOrOrphaned(String owner) => + owner == terminalId || !sessions.containsKey(owner); + final mine = snapshots.where((s) => ownedOrOrphaned(s.terminalId)).toList(); + final mineIds = {for (final s in mine) s.snapshotId}; + // Filtered against the narrowed offers, not carried whole: a pending id + // naming a snapshot this state no longer holds marks a row that is not on + // screen. + final undo = pendingUndo.where(mineIds.contains).toSet(); + final orphanedWrapUps = wrapUps + .where((w) => ownedOrOrphaned(w.terminalId)) + .toList(); + if (terminalId == null) { + return HandlerState.initial().copyWith( + defaultTool: defaultTool, + // Carried for the same reason [defaultTool] is: it describes the + // project, not the session this narrowing failed to name. + entitlement: entitlement, + snapshots: mine, + wrapUps: orphanedWrapUps, + pendingUndo: undo, + ); + } + final session = sessions[terminalId]; + final instructions = pendingInstructions[terminalId]; + return copyWith( + sessions: session == null ? const {} : {terminalId: session}, + escalations: escalations + .where((e) => e.terminalId == terminalId) + .toList(), + activity: activity.where((a) => a.terminalId == terminalId).toList(), + snapshots: mine, + wrapUps: orphanedWrapUps, + pendingUndo: undo, + pendingInstructions: instructions == null + ? const {} + : {terminalId: instructions}, + ); + } + HandlerState copyWith({ String? defaultTool, Map? sessions, @@ -832,6 +1049,8 @@ class HandlerState { List? wrapUps, Set? pendingUndo, Map>? pendingInstructions, + HandlerEntitlement? entitlement, + bool clearEntitlement = false, }) { return HandlerState( defaultTool: defaultTool ?? this.defaultTool, @@ -842,6 +1061,10 @@ class HandlerState { wrapUps: wrapUps ?? this.wrapUps, pendingUndo: pendingUndo ?? this.pendingUndo, pendingInstructions: pendingInstructions ?? this.pendingInstructions, + // The one field here that has to be CLEARABLE: a refusal is lifted by an + // upgrade or a fresh sign-in, and a gate that only ever latches on would + // outlive the thing it describes with no frame able to correct it. + entitlement: clearEntitlement ? null : (entitlement ?? this.entitlement), ); } } diff --git a/app/lib/models/pending_nav.dart b/app/lib/models/pending_nav.dart index ff4524e8..a0f4169a 100644 --- a/app/lib/models/pending_nav.dart +++ b/app/lib/models/pending_nav.dart @@ -3,12 +3,13 @@ import 'session_target.dart'; /// A value a navigation named for a screen that could not take it yet, stamped /// with the project it was issued for. /// -/// The stamp is what makes the handover self-invalidating. `NavController._apply` -/// is the only writer, and in-app navigation never reaches it — switching -/// projects from the drawer records history through `commit` alone — so a value -/// whose destination never mounted would otherwise sit there until some other -/// project's screen mounted and consumed it. Each drain discards a stamp that is -/// not the focused target. +/// The stamp is what makes the handover self-invalidating. Most writes come +/// from `NavController._apply`, and in-app navigation never reaches it — +/// switching projects from the drawer records history through `commit` alone — +/// so a value whose destination never mounted would otherwise sit there until +/// some other project's screen mounted and consumed it. Each drain discards a +/// stamp that is not the focused target, which is also what makes it safe for +/// an in-app caller to hand a destination over this way. /// /// A surface-only location carries the target it overlays, not null: the /// settings screen a `nav/settings` link opens belongs to whatever project is diff --git a/app/lib/models/terminal_models.dart b/app/lib/models/terminal_models.dart index f02a0639..dcec0ea7 100644 --- a/app/lib/models/terminal_models.dart +++ b/app/lib/models/terminal_models.dart @@ -111,6 +111,12 @@ class TerminalState { final LayoutConfig? layout; final List? commands; final String? gitBranch; + + /// Ahead/behind for [gitBranch], carried on the same `agent:status` frame it + /// comes from. Local counts, so as fresh as the last fetch — see + /// `GitSyncState` for why nothing on this path may reach the network. + final int gitAhead; + final int gitBehind; final List gitBranches; final bool gitBranchesLoading; final String? gitBranchesError; @@ -125,6 +131,8 @@ class TerminalState { this.layout, this.commands, this.gitBranch, + this.gitAhead = 0, + this.gitBehind = 0, this.gitBranches = const [], this.gitBranchesLoading = false, this.gitBranchesError, @@ -152,6 +160,8 @@ class TerminalState { LayoutConfig? layout, List? commands, String? gitBranch, + int? gitAhead, + int? gitBehind, List? gitBranches, bool? gitBranchesLoading, String? gitBranchesError, @@ -171,6 +181,8 @@ class TerminalState { layout: layout ?? this.layout, commands: commands ?? this.commands, gitBranch: gitBranch ?? this.gitBranch, + gitAhead: gitAhead ?? this.gitAhead, + gitBehind: gitBehind ?? this.gitBehind, gitBranches: gitBranches ?? this.gitBranches, gitBranchesLoading: gitBranchesLoading ?? this.gitBranchesLoading, gitBranchesError: clearGitBranchesError diff --git a/app/lib/project/project_message_classification.dart b/app/lib/project/project_message_classification.dart index c7f4ad56..3cbe961c 100644 --- a/app/lib/project/project_message_classification.dart +++ b/app/lib/project/project_message_classification.dart @@ -39,6 +39,7 @@ enum MessageTier { const Set kCheckoutDurableReplayTypes = { 'agent:status', 'git:status', + 'git:sync-state', 'tree:full', }; @@ -62,6 +63,8 @@ const Set kCheckoutVariableMessageTypes = { 'tree:update', 'file:read', 'file:content', + 'file:resolve-path', + 'file:resolve-path-result', 'file:search', 'file:search-cancel', 'file:search-result', @@ -87,6 +90,22 @@ const Set kCheckoutVariableMessageTypes = { 'git:stage-result', 'git:unstage', 'git:unstage-result', + 'git:stash-list', + 'git:stash-list-result', + 'git:stash-pop', + 'git:stash-pop-result', + 'git:stash-drop', + 'git:stash-drop-result', + 'git:log', + 'git:log-result', + 'git:commit-files', + 'git:commit-files-result', + 'git:commit-diff', + 'git:commit-diff-content', + 'git:sync', + 'git:sync-result', + 'git:sync-status', + 'git:sync-state', 'command:run', 'command:output', 'command:done', @@ -158,8 +177,16 @@ const Set _statusTypes = { 'git:discard-result', 'git:stage-result', 'git:unstage-result', + 'git:stash-list-result', + 'git:stash-pop-result', + 'git:stash-drop-result', + 'git:sync-result', + 'git:sync-state', 'git:status', 'git:diff-content', + 'git:log-result', + 'git:commit-files-result', + 'git:commit-diff-content', 'handler:status', 'file:upload-ready', 'file:upload-ack', @@ -204,6 +231,7 @@ const Set _heavyTypes = { 'tree:update', 'file:tree:snapshot', 'file:content', + 'file:resolve-path-result', 'preview:url', 'preview:snapshot', 'command:output', diff --git a/app/lib/providers/agent_transport.dart b/app/lib/providers/agent_transport.dart index f4e0148b..b2bacad4 100644 --- a/app/lib/providers/agent_transport.dart +++ b/app/lib/providers/agent_transport.dart @@ -568,6 +568,7 @@ Future _buildLocalTransportFor( device: device, licenseApiUrl: device != null ? ref.read(licenseApiUrlProvider) : null, relayUrl: device != null ? ref.read(defaultRelayUrlProvider) : null, + telemetryEnabled: ref.read(telemetryEnabledProvider), ); // NOTE: we deliberately do NOT terminate the host on app quit, even when this // process spawned it (result.owned). The host is a machine-level singleton diff --git a/app/lib/providers/capability_catalog.dart b/app/lib/providers/capability_catalog.dart index 6955825e..759002c7 100644 --- a/app/lib/providers/capability_catalog.dart +++ b/app/lib/providers/capability_catalog.dart @@ -2,10 +2,12 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../models/agent_event.dart'; import '../models/capability_catalog.dart'; import '../models/session_target.dart'; import '../services/capability_catalog_cache.dart'; import '../util/device_id.dart'; +import 'agent_transport.dart'; /// Cache-key source segment for a focused target. Catalogs differ per machine /// (installed model set / auth), so remote targets key by bare deviceUuid; a @@ -57,6 +59,13 @@ class CapabilityCatalogNotifier // the read genuinely completed — mark it done so an absent file isn't // re-read on every rebuild. _read.add(key); + } catch (_) { + // A backstop, not the mechanism: read() holds the "never throws" contract + // itself, and this only catches a `state =` write failing on a notifier + // disposed mid-read. Every caller starts this from a build method and + // discards the future, so anything escaping here surfaces as an unhandled + // async failure in the zone rather than anywhere a user could see. + // Deliberately NOT latched into _read: a later build gets to try again. } finally { _hydrating.remove(key); } @@ -74,3 +83,21 @@ final capabilityCatalogProvider = NotifierProvider>( CapabilityCatalogNotifier.new, ); + +/// The models [tool] is known to offer on the focused target, or empty when +/// nobody has heard that CLI list them. +/// +/// Hydration is kicked off from build the way every other reader of this cache +/// does it — the notifier is idempotent and no-ops once a key has been read. +/// Empty is a real answer, not a loading state: a machine that has only ever +/// run this agent in a terminal has no catalog, which is why every caller owes +/// a free-text way to name a model. +List cachedModelsFor(WidgetRef ref, String tool) { + final key = capabilityCacheKey( + capabilitySourceKey(ref.watch(selectedTargetProvider)), + tool, + ); + ref.read(capabilityCatalogProvider.notifier).ensureHydrated(key); + return ref.watch(capabilityCatalogProvider)[key]?.models ?? + const []; +} diff --git a/app/lib/providers/entry_cleanup.dart b/app/lib/providers/entry_cleanup.dart index a4533e04..e2e97b23 100644 --- a/app/lib/providers/entry_cleanup.dart +++ b/app/lib/providers/entry_cleanup.dart @@ -7,7 +7,6 @@ import 'agent_catalog.dart'; import 'cached_sessions.dart'; import 'projects.dart' show projectsProvider; import 'providers.dart' show preferencesServiceProvider, storageServiceProvider; -import 'recent_ports.dart'; /// One purge step: a store name (for error reporting) plus the async clear /// itself. @@ -75,10 +74,6 @@ Future purgeEntryState( await sessions.flushNow(); }, ), - ( - store: 'recentPorts', - clear: () => ref.read(recentPortsStoreProvider).removeProject(id), - ), ( store: 'projectStatusCache', clear: () => ref.read(projectStatusCacheProvider).clear(id), @@ -101,8 +96,8 @@ void _logPurgeFailure(String store, Object error) { /// silent leak with no compile-time signal. /// /// Everything below describes machines the account made reachable — their -/// session lists, project labels and work status, the ports and file-tree state -/// of the projects on them, the agents they advertised. Left at rest, it all +/// session lists, project labels and work status, the file-tree state of the +/// projects on them, the agents they advertised. Left at rest, it all /// renders on the very next launch, before (or without) any sign-in: the drawer /// and the Recent list read straight from these stores. So the next person to /// use the install sees the previous account's work. @@ -135,10 +130,6 @@ Future purgeAccountCaches( store: 'cachedSessions', clear: () => ref.read(cachedSessionsStoreProvider).clear(), ), - ( - store: 'recentPorts', - clear: () => ref.read(recentPortsStoreProvider).clear(), - ), ( store: 'projectStatusCache', clear: () => ref.read(projectStatusCacheProvider).clearAll(), diff --git a/app/lib/providers/first_run.dart b/app/lib/providers/first_run.dart index ffc18036..355d149c 100644 --- a/app/lib/providers/first_run.dart +++ b/app/lib/providers/first_run.dart @@ -92,8 +92,8 @@ class FirstRunController extends Notifier { } /// Called on every bridge-CONFIRMED arm (idempotent; see - /// latchHandlerArmedOnConfirmation): the flag retires the first-arm - /// explainer, the labeled shield, and the away-moment hint in one write. + /// latchHandlerArmedOnConfirmation): the flag retires the labeled shield, the + /// away-moment hint, and the arm sheet's explanatory paragraph in one write. void markHandlerArmed() { if (state.handlerArmedOnce) return; _commit(state.copyWith(handlerArmedOnce: true)); diff --git a/app/lib/providers/handler_discovery.dart b/app/lib/providers/handler_discovery.dart index 0168ebb9..cd22ee01 100644 --- a/app/lib/providers/handler_discovery.dart +++ b/app/lib/providers/handler_discovery.dart @@ -99,9 +99,11 @@ class HandlerAwayAttentionSinceNotifier extends Notifier { /// thunk (an absent per-session tool means the project default). ONE provider /// so the header shield, the away hint, and the explainer can never answer the /// coverage question differently for the same session. -/// [judgeCapable] is null under exactly the same condition [observable] is — -/// both are read off one descriptor, so an agent the catalog has never -/// described answers neither question rather than half of one. +/// [judgeCapable] is null when the catalog has never described the tool that +/// would judge — which is [agent] until the session's own judge pick names +/// another. [observable] is null under the same condition for [agent] itself, +/// so a session whose judge is picked and whose agent is undescribed can answer +/// one and not the other. typedef FocusedSessionCoverage = ({ String? agent, String? agentLabel, @@ -116,6 +118,30 @@ final focusedSessionCoverageProvider = ref.watch(handlerStateProvider).value ?? const HandlerState.initial(); final agent = entry?.tool ?? handlerState.defaultTool; final catalog = ref.watch(agentCatalogProvider); + // The judge the bridge would actually run, not the session's agent: once + // a pick exists, warning about the agent's own headless reach describes a + // judge that is not going to be used. + // + // The ARMED session's own report comes first, because it is the only half + // of this that is reactive: `lastKnownSettings` is a plain mutable field + // no provider watches, so a pick this app made optimistically moves + // nothing and would leave the shield and the away hint reporting the + // previous judge's coverage until the next status frame — exactly the + // window in which the user is deciding whether to walk away. The cache is + // the fallback for a session the state does not list (disarmed, or a + // status frame not yet landed). + // + // Sourced through focusedSessionOrNull, never the handlerService facade: + // this provider is watched, and the facade throws in the windows where a + // project session is still resolving. + final judge = entry == null + ? agent + : handlerState.sessions[entry.id]?.judgeTool ?? + focusedSessionOrNull(ref) + ?.handlerService + .lastKnownSettings(entry.id) + ?.tool ?? + agent; return ( agent: agent, agentLabel: catalog[agent]?.label, @@ -124,13 +150,13 @@ final focusedSessionCoverageProvider = agent, chat: entry?.mode == 'chat', ), - // The bridge's own second question, asked the same way: an armed - // session resolves its judge as `storedJudge ?? the session's own tool` - // (observabilityFor, bridge/src/handler/engine.ts). Nothing writes a - // stored judge today, so the fallback IS the answer and the catalog - // already holds it — this predicts, it does not approximate. Whatever - // lands a judge picker owns keeping that true. - judgeCapable: catalog[agent]?.judgeCapable, + // The bridge's own second question, asked the same way: a session + // resolves its judge as `storedJudge ?? the session's own tool` + // (observabilityFor, bridge/src/handler/engine.ts). Both halves are + // mirrored above, so this predicts rather than approximates — and an + // armed session reports its own answer regardless, which is what this + // one has to agree with before the arm. + judgeCapable: catalog[judge]?.judgeCapable, ); }); diff --git a/app/lib/providers/local_host_warmup.dart b/app/lib/providers/local_host_warmup.dart index ec6ec489..d2deee44 100644 --- a/app/lib/providers/local_host_warmup.dart +++ b/app/lib/providers/local_host_warmup.dart @@ -2,7 +2,8 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../services/app_settings_service.dart' show defaultRelayUrlProvider; +import '../services/app_settings_service.dart' + show defaultRelayUrlProvider, telemetryEnabledProvider; import '../services/auth_service.dart' show CurrentUser; import '../services/keychain_device_store.dart' show DeviceRecord; import '../util/ab_log.dart'; @@ -60,6 +61,10 @@ final localHostWarmupProvider = Provider((ref) { licenseApiUrl: device != null ? ref.read(licenseApiUrlProvider) : null, relayUrl: device != null ? ref.read(defaultRelayUrlProvider) : null, forceRespawn: forceRespawn, + // Read here rather than carried down from main(): this warm-up runs + // BEFORE main()'s own initCrashReporting, so there is no decision to + // inherit yet — only the setting both of them read. + telemetryEnabled: ref.read(telemetryEnabledProvider), ); spawnedClientId = device?.clientId; warmedOnce = true; diff --git a/app/lib/providers/new_session_action.dart b/app/lib/providers/new_session_action.dart index f81a8155..060f4315 100644 --- a/app/lib/providers/new_session_action.dart +++ b/app/lib/providers/new_session_action.dart @@ -86,6 +86,23 @@ class ActiveSessionsBranchSwitchException implements Exception { 'ActiveSessionsBranchSwitchException($targetId, $branch)'; } +/// Thrown when the pre-start branch switch refuses because the folder's +/// working tree is dirty (`DIRTY_WORKTREE`) and the caller hasn't already +/// opted into stashing. The composer catches this, offers to stash, and +/// retries with `stashIfDirty: true` — see [startNewSession]. +class DirtyWorktreeBranchSwitchException implements Exception { + final String targetId; + final String branch; + const DirtyWorktreeBranchSwitchException({ + required this.targetId, + required this.branch, + }); + + @override + String toString() => + 'DirtyWorktreeBranchSwitchException($targetId, $branch)'; +} + /// Start action for the New Session page. /// /// Activates the picker-selected target project so `selectedRegistrationIdProvider` @@ -112,6 +129,7 @@ class ActiveSessionsBranchSwitchException implements Exception { Future startNewSession( ProviderContainer ref, { bool allowActiveSessions = false, + bool stashIfDirty = false, }) async { final target = ref.read(selectedTargetProjectProvider); if (target == null) return; @@ -207,6 +225,7 @@ Future startNewSession( projectPath: target.detail, branch: explicitBranch, allowActiveSessions: allowActiveSessions, + stashIfDirty: stashIfDirty, ); } finally { client.close(); @@ -225,6 +244,7 @@ Future startNewSession( projectId: target.projectId ?? target.id, branch: explicitBranch, allowActiveSessions: allowActiveSessions, + stashIfDirty: stashIfDirty, ); } } on HostControlException catch (e) { @@ -234,6 +254,12 @@ Future startNewSession( branch: explicitBranch, ); } + if (e.code == 'DIRTY_WORKTREE' && !stashIfDirty) { + throw DirtyWorktreeBranchSwitchException( + targetId: target.id, + branch: explicitBranch, + ); + } rethrow; } on RpcException catch (e) { if (e.code == 'ACTIVE_SESSIONS') { @@ -242,6 +268,12 @@ Future startNewSession( branch: explicitBranch, ); } + if (e.code == 'DIRTY_WORKTREE' && !stashIfDirty) { + throw DirtyWorktreeBranchSwitchException( + targetId: target.id, + branch: explicitBranch, + ); + } rethrow; } diff --git a/app/lib/providers/providers.dart b/app/lib/providers/providers.dart index 358d26fa..ef40496b 100644 --- a/app/lib/providers/providers.dart +++ b/app/lib/providers/providers.dart @@ -290,6 +290,22 @@ final handlerStateProvider = StreamProvider((ref) { return seededStream(() => service.currentState, service.stateStream); }); +/// [handlerStateProvider] narrowed to the focused session — what the Handler +/// tab renders. +/// +/// Session-scoped for the reason the files, git and terminals tabs are +/// checkout-scoped: the workspace panel answers for the session in focus, and a +/// tab mixing two sessions' rows makes the reader do the routing. +/// +/// The unnarrowed [handlerStateProvider] stays the source for every surface +/// that genuinely spans sessions — above all the agent bar's NEEDS YOU pill, +/// which is what says another session is waiting and so must never narrow. +final focusedSessionHandlerStateProvider = Provider((ref) { + final state = ref.watch(handlerStateProvider).value; + if (state == null) return const HandlerState.initial(); + return state.forTerminal(ref.watch(activeSessionIdProvider)); +}); + /// Per-project SessionsService façade. final sessionsServiceProvider = _focusedService( (s) => s.sessionsService, diff --git a/app/lib/providers/recent_ports.dart b/app/lib/providers/recent_ports.dart deleted file mode 100644 index ecc4e604..00000000 --- a/app/lib/providers/recent_ports.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'dart:async'; - -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../storage/recent_ports_store.dart'; - -/// Synchronous handle to the on-disk recent-ports store. Opened eagerly in -/// `main()` and injected via a Riverpod override; reading without that override -/// throws. -final recentPortsStoreProvider = Provider((_) { - throw StateError('recentPortsStoreProvider must be overridden in main()'); -}); - -/// Per-project remembered ports, most-recent-first. Seeds from the store and -/// follows its [RecentPortsStore.changes] for the matching project. -class RecentPortsNotifier extends Notifier> { - RecentPortsNotifier(this._projectId); - final String _projectId; - - @override - List build() { - final store = ref.watch(recentPortsStoreProvider); - final sub = store.changes.listen((c) { - if (c.projectId == _projectId) state = c.ports; - }); - ref.onDispose(sub.cancel); - return store.list(_projectId); - } - - Future add(int port, String scheme) => - ref.read(recentPortsStoreProvider).add(_projectId, port, scheme); - Future remove(int port) => - ref.read(recentPortsStoreProvider).remove(_projectId, port); -} - -final recentPortsProvider = - NotifierProvider.family, String>( - RecentPortsNotifier.new, - ); diff --git a/app/lib/providers/visible_surface.dart b/app/lib/providers/visible_surface.dart index 20a8af82..3a55f352 100644 --- a/app/lib/providers/visible_surface.dart +++ b/app/lib/providers/visible_surface.dart @@ -34,10 +34,17 @@ final visibleWorkspaceViewProvider = /// `pendingActiveSessionIdProvider` — the shell drains it on mount and on /// change, and clears it on consumption. /// +/// Also the only safe way to reveal a view in the same turn as a SESSION +/// switch, which is why the agent bar's NEEDS YOU pill writes here rather than +/// calling `revealHandlerTab`: a focus change arms the shell's per-session UI +/// restore, and that restore re-applies the target session's own saved tab +/// after any tab the caller selected first. The drain runs after it. +/// /// Null is a written value, not just an absence: a location naming no view /// writes null so a view left pending by an earlier one is dropped rather than /// applied to this destination. The [PendingNav] stamp covers the other half — -/// a project switch that never goes through the nav layer at all. +/// a project switch that never goes through the nav layer at all, and it is +/// what lets a second writer be added safely. final pendingWorkspaceViewProvider = NotifierProvider< ValueController?>, @@ -66,6 +73,12 @@ final pendingFilePathProvider = /// Counts the workspace views advertise on their tab: unstaged git files, and /// escalations the handler is waiting on. /// +/// Both are scoped to what their tab actually shows — the focused checkout for +/// git, the focused session for the handler. A handler badge counting the whole +/// project would send the user to a tab narrowed past the escalation it +/// promised; the agent bar's NEEDS YOU pill is what carries the project-wide +/// count, and it moves focus to the session it counted on the way in. +/// /// A provider rather than a WorkspaceShell method because the agent bar's /// workspace menu lists the same views from outside that State, and a menu that /// disagreed with the tab strip about how many files changed would be worse than @@ -80,8 +93,11 @@ final workspaceBadgesProvider = Provider>((ref) { final gitCount = ref.watch( fileTreeStateProvider.select((s) => s.value?.gitFileStatuses.length ?? 0), ); + // Off the narrowed state rather than a second `sessions[activeId]` lookup of + // its own: the tab and its badge must never be able to answer differently + // about what the tab holds, and one narrowing rule is what guarantees it. final pending = ref.watch( - handlerStateProvider.select((s) => s.value?.pendingEscalations ?? 0), + focusedSessionHandlerStateProvider.select((s) => s.escalationBadgeCount), ); return { if (gitCount > 0) WorkspaceView.git: gitCount, diff --git a/app/lib/screens/device_cap_dialog.dart b/app/lib/screens/device_cap_dialog.dart index df429078..64f13f61 100644 --- a/app/lib/screens/device_cap_dialog.dart +++ b/app/lib/screens/device_cap_dialog.dart @@ -7,13 +7,21 @@ import '../design/ab_icons.dart'; import '../design/ab_tokens.dart'; import '../design/widgets/ab_button.dart'; import '../design/widgets/ab_confirm_dialog.dart'; +import '../design/widgets/ab_icon.dart'; import '../design/widgets/ab_icon_button.dart'; import '../design/widgets/ab_list_row.dart'; -import '../design/widgets/ab_tooltip.dart'; import '../providers/auth.dart'; import '../providers/device_provisioning.dart'; import '../providers/post_signin_provisioning.dart'; import '../services/devices_api.dart'; +import '../util/detached.dart'; +import '../util/external_url.dart'; + +/// Where the worker-cap variant sends someone who wants more machines. There is +/// no checkout to send them to during the beta, so the ask is captured on the +/// site instead; no price is named here or on the way out, because none is +/// committed to yet. +const _foundingPricingUrl = 'https://antgrid.ai/pricing'; /// Shows the device-cap remediation dialog and resolves when it is dismissed. /// Always clears [deviceCapProvider] on close so a later provisioning attempt @@ -34,8 +42,7 @@ Future showDeviceCapDialog( /// same way: revoke one of the listed devices, then retry provisioning this /// machine. Only the copy differs — `appDeviceLimit` is an abuse ceiling that /// pricing never mentions, so its variant never offers upgrading, while the -/// worker cap is the paid axis and shows an upgrade affordance (disabled until -/// checkout ships). +/// worker cap is the paid axis and points at the founding-price waitlist. class DeviceCapDialog extends ConsumerStatefulWidget { const DeviceCapDialog({super.key, required this.info}); @@ -206,20 +213,30 @@ class _DeviceCapDialogState extends ConsumerState { runSpacing: AbTokens.space8, children: [ if (_isWorker) ...[ - // Checkout is not wired yet, so the paid path is shown and - // legibly shut rather than absent. The tooltip alone would - // leave the button unexplained on mobile (no hover), hence - // the inline label beside it. + // The machine slot cannot be bought during the beta, so the + // paid path leads somewhere that works instead of standing + // there disabled. The line says why the button is a + // waitlist and not a purchase; a tooltip could not, having + // no hover on mobile. Text( - 'Coming soon', + 'More machines aren\'t on sale yet.', style: AbTokens.sansStyle( fontSize: AbTokens.fontXs, color: p.textMuted, ), ), - const AbTooltip( - message: 'Coming soon', - child: AbButton(label: 'Upgrade'), + AbButton( + label: 'Join the waitlist', + leading: AbIcon( + AbIcons.openExternal, + size: AbTokens.iconButtonGlyph, + color: p.textSecondary, + ), + onTap: () => detached( + 'DeviceCapDialog', + 'open founding-pricing waitlist', + () => openExternalUrl(context, _foundingPricingUrl), + ), ), ], AbButton( diff --git a/app/lib/screens/preview_context_menu_script.dart b/app/lib/screens/preview_context_menu_script.dart new file mode 100644 index 00000000..84d9a57b --- /dev/null +++ b/app/lib/screens/preview_context_menu_script.dart @@ -0,0 +1,140 @@ +import 'dart:convert'; + +/// Vanilla-JS content script that reports right-click context back to Dart, +/// so the preview panel can show an Antgrid-styled menu in place of the +/// native one — WebView2's own default context menu is disabled at the +/// native-plugin level (`webview_all_windows`'s +/// `AreDefaultContextMenusEnabled(FALSE)`, with no Dart-side toggle), which +/// is the standard way an embedder hands the menu to its own UI rather than +/// leaving right-click doing nothing. +/// +/// Persistent, unlike [kElementPickerScript]: re-injected on every +/// `onPageFinished` (a real navigation tears down the JS world, taking the +/// listener with it) but never armed/disarmed on demand — right-click should +/// always work, not just while some tool is active. The guard flag still +/// matters: `onPageFinished` can fire more than once for the same document +/// (e.g. a same-page hash change), and a second listener would double-post. +/// +/// Posts one message via the `AntgridContextMenu` JS channel: +/// `{type: "contextmenu", href, imgSrc, selectionText, editable, pageUrl}`. +/// `href`/`imgSrc` are read off the element's DOM property (never the raw +/// attribute), which the browser already resolves to an absolute URL — +/// exactly the shape [parsePreviewTarget]-style local-port parsing and +/// [openContentLink]'s local/external split both expect. +const String kContextMenuScript = ''' +(function() { + if (window.__antgridContextMenuArmed) return; + window.__antgridContextMenuArmed = true; + + function onContextMenu(e) { + // The native menu is already off (see the doc above); this is + // belt-and-suspenders for any backend where it isn't, and stops the + // page's OWN custom context menu (if it installs one) from double-firing + // alongside ours. + e.preventDefault(); + + var link = e.target.closest ? e.target.closest('a[href]') : null; + var img = e.target.closest ? e.target.closest('img[src]') : null; + + var editable = false; + var cur = e.target; + while (cur) { + if (cur.tagName === 'INPUT' || cur.tagName === 'TEXTAREA' || cur.isContentEditable) { + editable = true; + break; + } + cur = cur.parentElement; + } + + var selectionText = ''; + try { + selectionText = (window.getSelection && window.getSelection().toString()) || ''; + } catch (err) {} + + var payload = { + type: 'contextmenu', + href: link ? link.href : null, + imgSrc: img ? img.src : null, + selectionText: selectionText, + editable: editable, + pageUrl: location.href + }; + if (window.AntgridContextMenu) { + window.AntgridContextMenu.postMessage(JSON.stringify(payload)); + } + } + + document.addEventListener('contextmenu', onContextMenu, false); +})(); +'''; + +/// Inserts [text] at the focused element's caret via the same mechanism a +/// real browser's own Paste uses — `execCommand('insertText', …)` fires a +/// proper `input` event, which is what a framework-controlled field (React, +/// Vue) listens for, unlike setting `.value` directly. Scoped to whatever +/// element the page itself currently has focused; there is no Flutter-side +/// caret to target since the webview is an opaque platform surface. +String buildContextMenuPasteScript(String text) { + return "document.execCommand('insertText', false, ${jsonEncode(text)});"; +} + +/// Deletes the current selection — the second half of Cut, after the text is +/// already copied to the OS clipboard Dart-side. Only ever sent when the +/// menu's own `editable` flag was true, so this never runs against read-only +/// selected text. +const String kContextMenuDeleteSelectionScript = "document.execCommand('delete');"; + +/// One right-click's worth of DOM context, decoded from the +/// `AntgridContextMenu` channel's `contextmenu` message. `href`/`imgSrc` are +/// normalized to `null` rather than an empty string so callers can test +/// presence with a plain null check. +class PreviewContextMenuInfo { + const PreviewContextMenuInfo({ + this.href, + this.imgSrc, + this.selectionText = '', + this.editable = false, + this.pageUrl, + }); + + /// No context reached Dart in time — see the fallback timer in + /// `PreviewScreen`. Still worth a menu (Reload / Copy page URL), just with + /// nothing element-specific to offer. + const PreviewContextMenuInfo.empty() : this(); + + final String? href; + final String? imgSrc; + final String selectionText; + final bool editable; + final String? pageUrl; +} + +/// Parses one `AntgridContextMenu` channel message, or null if it isn't a +/// well-formed `contextmenu` payload. [rawMessage] is untrusted — parsed +/// from a message posted by arbitrary web content running in the preview — +/// so every field is handled as possibly missing or the wrong type; this +/// never throws. +PreviewContextMenuInfo? parseContextMenuMessage(String rawMessage) { + Object? decoded; + try { + decoded = jsonDecode(rawMessage); + } on FormatException { + return null; + } + if (decoded is! Map) return null; + if (decoded['type'] != 'contextmenu') return null; + + String? nonEmptyString(Object? value) { + return value is String && value.isNotEmpty ? value : null; + } + + return PreviewContextMenuInfo( + href: nonEmptyString(decoded['href']), + imgSrc: nonEmptyString(decoded['imgSrc']), + selectionText: decoded['selectionText'] is String + ? decoded['selectionText'] as String + : '', + editable: decoded['editable'] == true, + pageUrl: nonEmptyString(decoded['pageUrl']), + ); +} diff --git a/app/lib/screens/preview_screen.dart b/app/lib/screens/preview_screen.dart index fdb69e68..201e6860 100644 --- a/app/lib/screens/preview_screen.dart +++ b/app/lib/screens/preview_screen.dart @@ -4,6 +4,7 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show Clipboard, ClipboardData; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:webview_all/webview_all.dart'; @@ -26,7 +27,6 @@ import '../providers/demo_mode.dart'; import '../services/preview_service.dart'; import '../providers/agent_transport.dart'; import '../providers/providers.dart'; -import '../providers/recent_ports.dart'; import '../providers/visible_surface.dart'; import '../util/detached.dart'; import '../util/external_url.dart'; @@ -35,21 +35,20 @@ import '../widgets/preview_tab_bar.dart'; import '../util/ab_log.dart'; import '../widgets/new_session/environment_menu.dart' show PanelHint, PanelRow, PanelSectionHeader; -import '../widgets/port_entry.dart'; -import '../widgets/port_list_widget.dart'; import '../util/image_thumbnail.dart'; import '../widgets/preview_draw_overlay.dart'; import '../widgets/send_capture_to_agent.dart'; import '../widgets/preview_empty_state.dart'; import '../widgets/send_to_agent_comment.dart'; import '../design/widgets/ab_loading.dart'; +import 'preview_context_menu_script.dart'; import 'preview_element_picker_script.dart'; import 'preview_screenshot_script.dart'; -/// The browser preview screen. Shows detected ports, a URL bar at the top of -/// the panel with refresh/external-browser/element-picker actions, a popup -/// tab switcher ([PreviewTabsButton]) over the open ports, and one embedded -/// webview per open port. +/// The browser preview screen. A URL bar at the top of the panel with +/// refresh/external-browser/element-picker actions, a popup tab switcher +/// ([PreviewTabsButton]) over the open ports, and one embedded webview per +/// open port. class PreviewScreen extends ConsumerStatefulWidget { const PreviewScreen({super.key}); @@ -140,6 +139,19 @@ class _PreviewScreenState extends ConsumerState { /// [_backFromPreview]. final GlobalKey _drawKey = GlobalKey(); + /// Port + Flutter-global anchor position of an in-flight right-click, from + /// [_onSecondaryPointerDown] until either [_onContextMenuMessage] resolves + /// it with real DOM context (a link, a selection, an editable field) or + /// [_contextMenuFallbackTimer] gives up and shows a generic menu anyway — + /// right-click doing nothing at all (the native WebView2 menu is disabled + /// at the plugin level with no Dart-side toggle) is the bug this exists to + /// fix, so SOME menu has to appear even when the page swallows the click or + /// the content script hasn't loaded yet. Desktop only; right-click has no + /// touch equivalent. + int? _pendingContextMenuPort; + Offset? _pendingContextMenuAnchor; + Timer? _contextMenuFallbackTimer; + /// True while the address bar is armed to open a NEW tab (via the "+" /// button) rather than navigate the active one — the two share the same /// field, so this is what disambiguates a submit between them. Irrelevant @@ -183,8 +195,11 @@ class _PreviewScreenState extends ConsumerState { } final id = ref.read(previewStateProvider).value?.activeTabId; final tabState = id != null ? _tabStates[id] : null; - if (tabState == null) return; - final display = _toDisplayUrl(tabState, tabState.currentUrl); + // No active tab — nothing to restore to, so discard the edit outright + // rather than leaving whatever was typed on screen. + final display = tabState == null + ? '' + : _toDisplayUrl(tabState, tabState.currentUrl); if (_addrController.text != display) { _addrController.text = display; } @@ -250,11 +265,23 @@ class _PreviewScreenState extends ConsumerState { 'AntgridScreenshotCapture', onMessageReceived: (msg) => _onScreenshotMessage(port, msg.message), ) + ..addJavaScriptChannel( + 'AntgridContextMenu', + onMessageReceived: (msg) => _onContextMenuMessage(port, msg.message), + ) ..setNavigationDelegate( NavigationDelegate( onPageFinished: (_) { _clearPickerIfArmedOn(port); _refreshHistoryFlags(port); + // Persistent (unlike the picker), so it's re-armed on every real + // navigation rather than only while some tool is active — see + // kContextMenuScript's doc. Touch platforms have no right-click. + if (!isMobilePlatform) { + unawaited( + _tabStates[port]?.controller?.runJavaScript(kContextMenuScript), + ); + } if (_refreshingPort == port && mounted) { setState(() => _refreshingPort = null); } @@ -316,6 +343,7 @@ class _PreviewScreenState extends ConsumerState { void dispose() { _addrController.dispose(); _addrFocus.dispose(); + _contextMenuFallbackTimer?.cancel(); _tabStates.clear(); super.dispose(); } @@ -396,10 +424,6 @@ class _PreviewScreenState extends ConsumerState { return; } final (port, scheme, path) = target; - final projectId = ref.read(selectedRegistrationIdProvider); - if (projectId != null) { - ref.read(recentPortsProvider(projectId).notifier).add(port, scheme); - } unawaited(_openPort(port, scheme, path: path)); _addrFocus.unfocus(); return; @@ -570,18 +594,25 @@ class _PreviewScreenState extends ConsumerState { if (_drawActiveForPort != null && !openPorts.contains(_drawActiveForPort)) { _drawActiveForPort = null; } + // No active tab (none open, or the last one just closed) — the address + // bar has no live URL to show, so it must not keep displaying whatever + // was last typed/loaded. Skipped while composing a new tab: that flow + // already owns the field (see [_startComposingNewTab]). + if (state.activeTabId == null && !_composingNewTab) { + _syncAddrField(''); + } // The address bar (and the rest of the toolbar chrome) is always on // screen from here on — like a real browser, not just once a tab is // open — so it's the one place to type a port whether that opens the // first tab, a later one, or navigates the active one. - return _buildPreviewView(state, openPorts); + return _buildPreviewView(state); } /// Content below the toolbar: the open tabs' webviews, a loading spinner - /// while the first tab's proxy binds, the detected-port list to reopen - /// one, or the truly-empty message when nothing's ever been detected. - Widget _buildBody(PreviewState state, Set openPorts) { + /// while the first tab's proxy binds, or the empty-state recent-ports + /// quick-pick once nothing's open. + Widget _buildBody(PreviewState state) { if (state.tabs.isNotEmpty) { final active = state.activeTab ?? state.tabs.first; // Tab open but proxy not ready yet. @@ -638,33 +669,12 @@ class _PreviewScreenState extends ConsumerState { ); } - // No tabs open — offer the recent-ports quick-pick as a fallback path - // when nothing has ever been detected either. Source the project id from - // the focus provider (not previewServiceProvider) so this build path - // stays cheap and doesn't construct the session/service. - if (state.ports.isEmpty) { - final projectId = ref.watch(selectedRegistrationIdProvider); - return _framed( - PreviewEmptyState( - action: projectId == null - ? null - : RecentPortsRow( - projectId: projectId, - onSelected: (port, scheme) => - unawaited(_openPort(port, scheme)), - ), - ), - ); - } - - // Ports known but every tab was closed -- show the port list to reopen. - return _framed( - PortListWidget( - ports: state.ports, - openPorts: openPorts, - onPortSelected: (port, scheme) => unawaited(_openPort(port, scheme)), - ), - ); + // No tabs open — just the plain empty state. Detected ports + // (`state.ports`) are shown only through the open-tabs UI now: bridge-side + // detection is a text heuristic over agent/process output and can list a + // port nothing is actually serving, so it's not worth surfacing as a + // standalone "closed tabs" reopen list either. + return _framed(const PreviewEmptyState()); } /// Insets [child] into a rounded, subtly-bordered browser-window frame — @@ -1070,7 +1080,7 @@ class _PreviewScreenState extends ConsumerState { /// one, and navigating the active tab are all just "type in the top bar /// and press Enter" (see [_handleAddressSubmit]) instead of three /// different surfaces (a centered form, a dialog, a field). - Widget _buildPreviewView(PreviewState state, Set openPorts) { + Widget _buildPreviewView(PreviewState state) { // [previewStateProvider] keeps its last value while it re-runs, so this // view can render one frame past a session that was invalidated (host // restart, LRU evict) — long enough for a raw façade read to throw during @@ -1191,7 +1201,7 @@ class _PreviewScreenState extends ConsumerState { ), ], ), - Expanded(child: _buildBody(state, openPorts)), + Expanded(child: _buildBody(state)), ], ); } @@ -1317,6 +1327,223 @@ class _PreviewScreenState extends ConsumerState { }); } + /// Right-click detection. A plain [Listener], not a gesture recognizer — + /// coexists with the [EagerGestureRecognizer] the webview itself claims + /// (see [_buildTabWebView]) the same way the mobile pull-to-refresh + /// [Listener] below already does: a `Listener` never enters the gesture + /// arena, so it can't take the click away from the page's own handling. + void _onSecondaryPointerDown(int port, PointerDownEvent event) { + if (event.buttons & kSecondaryMouseButton == 0) return; + _pendingContextMenuPort = port; + _pendingContextMenuAnchor = event.position; + _contextMenuFallbackTimer?.cancel(); + _contextMenuFallbackTimer = Timer(const Duration(milliseconds: 350), () { + if (_pendingContextMenuPort != port || !mounted) return; + final anchor = _pendingContextMenuAnchor; + _pendingContextMenuPort = null; + _pendingContextMenuAnchor = null; + if (anchor != null) { + _showContextMenu(port, anchor, const PreviewContextMenuInfo.empty()); + } + }); + } + + /// Handles the `AntgridContextMenu` channel's reply to a right-click. + /// [port] is bound at channel-registration time, same as the picker and + /// screenshot channels — a message from a backgrounded tab can never be + /// misattributed to whichever click is actually pending. + void _onContextMenuMessage(int port, String rawMessage) { + // The fallback timer guards this too, and for the same reason: the page's + // reply arrives on a platform channel that outlives a dispose, and + // [_showContextMenu] below reads `context`. + if (!mounted) return; + if (_pendingContextMenuPort != port) return; + final anchor = _pendingContextMenuAnchor; + _contextMenuFallbackTimer?.cancel(); + _pendingContextMenuPort = null; + _pendingContextMenuAnchor = null; + if (anchor == null) return; + final info = parseContextMenuMessage(rawMessage); + if (info == null) return; + _showContextMenu(port, anchor, info); + } + + Future _copyToClipboard(String text, [String? confirm]) async { + await Clipboard.setData(ClipboardData(text: text)); + if (confirm != null && mounted) showAbSnackBar(context, confirm); + } + + /// Opens a link/image address found by the content script — routed through + /// [openContentLink] rather than [_openPort] directly so an EXTERNAL link + /// (something other than this device's own dev server) still does the + /// right thing (system browser, with the same deceptive-link checks a + /// terminal hyperlink or a chat markdown link already gets) instead of + /// being silently dropped. + void _openContextMenuLink(String href) { + detached( + 'PreviewScreen', + 'open context-menu link', + () => openContentLink( + context, + href, + fileService: () => + focusedCheckoutServiceOrNull(ref.container, (s) => s.fileService), + previewService: () => focusedCheckoutServiceOrNull( + ref.container, + (s) => s.previewService, + ), + // Already on the Preview tab — this IS that surface. + revealView: (_) {}, + ), + ); + } + + /// Builds and shows the right-click menu for [port]'s tab at [anchor] — + /// Flutter GLOBAL coordinates from the [Listener] that caught the click, + /// not the DOM event's own CSS-pixel position, which would need a + /// scale-factor translation the Listener's coordinates never require. + /// + /// Every row's `onTap` always runs through [detached] where it does + /// anything async — see `util/detached.dart` — since [showAbMenu]'s own + /// `onTap` is exactly the void-callback boundary that rule exists for. + void _showContextMenu(int port, Offset anchor, PreviewContextMenuInfo info) { + final tabState = _tabStates[port]; + final controller = tabState?.controller; + final entries = []; + + if (info.href case final href?) { + final host = Uri.tryParse(href)?.host ?? ''; + entries.add( + AbMenuItem( + label: isLocalDevHost(host) + ? 'Open link in new tab' + : 'Open link in browser', + icon: AbIcons.openExternal, + onTap: () => _openContextMenuLink(href), + ), + ); + entries.add( + AbMenuItem( + label: 'Copy link', + icon: AbIcons.copy, + onTap: () => detached( + 'PreviewScreen', + 'copy link', + () => _copyToClipboard(href, 'Copied link'), + ), + ), + ); + } + + if (info.imgSrc case final imgSrc? when imgSrc != info.href) { + final host = Uri.tryParse(imgSrc)?.host ?? ''; + entries.add( + AbMenuItem( + label: isLocalDevHost(host) + ? 'Open image in new tab' + : 'Open image in browser', + icon: AbIcons.openExternal, + onTap: () => _openContextMenuLink(imgSrc), + ), + ); + entries.add( + AbMenuItem( + label: 'Copy image address', + icon: AbIcons.copy, + onTap: () => detached( + 'PreviewScreen', + 'copy image address', + () => _copyToClipboard(imgSrc, 'Copied image address'), + ), + ), + ); + } + + if (info.selectionText.isNotEmpty) { + if (entries.isNotEmpty) entries.add(const AbMenuDivider()); + entries.add( + AbMenuItem( + label: 'Copy', + icon: AbIcons.copy, + onTap: () => detached( + 'PreviewScreen', + 'copy selection', + () => _copyToClipboard(info.selectionText, 'Copied'), + ), + ), + ); + if (info.editable) { + entries.add( + AbMenuItem( + label: 'Cut', + onTap: () => detached('PreviewScreen', 'cut selection', () async { + await _copyToClipboard(info.selectionText, 'Cut'); + await controller?.runJavaScript( + kContextMenuDeleteSelectionScript, + ); + }), + ), + ); + } + } + if (info.editable) { + entries.add( + AbMenuItem( + label: 'Paste', + enabled: controller != null, + onTap: () => detached('PreviewScreen', 'paste into page', () async { + final ctrl = controller; + if (ctrl == null) return; + final data = await Clipboard.getData(Clipboard.kTextPlain); + final text = data?.text; + if (text == null || text.isEmpty) return; + await ctrl.runJavaScript(buildContextMenuPasteScript(text)); + }), + ), + ); + } + + if (entries.isNotEmpty) entries.add(const AbMenuDivider()); + entries.add( + AbMenuItem( + label: 'Reload', + icon: AbIcons.refresh, + enabled: controller != null, + onTap: () => detached( + 'PreviewScreen', + 'reload page', + () async => controller?.reload(), + ), + ), + ); + entries.add( + AbMenuItem( + label: 'Copy page URL', + icon: AbIcons.copy, + enabled: tabState != null, + onTap: () { + if (tabState == null) return; + detached( + 'PreviewScreen', + 'copy page url', + () => _copyToClipboard( + _toDisplayUrl(tabState, info.pageUrl ?? tabState.currentUrl), + 'Copied page URL', + ), + ); + }, + ), + ); + + unawaited( + showAbMenu( + context: context, + anchorRect: Rect.fromCenter(center: anchor, width: 1, height: 1), + entries: entries, + ), + ); + } + Widget _buildTabWebView(PreviewTab tab) { final controller = _tabStates[tab.port]?.controller; if (controller == null) return const SizedBox.shrink(); @@ -1337,7 +1564,15 @@ class _PreviewScreenState extends ConsumerState { }, ), ); - if (!isMobilePlatform) return webview; + if (!isMobilePlatform) { + // Same reasoning as the mobile Listener below — sees the raw + // right-click regardless of what the EagerGestureRecognizer above does + // with it, without taking the click away from the page. + return Listener( + onPointerDown: (e) => _onSecondaryPointerDown(tab.port, e), + child: webview, + ); + } // A Listener sees every raw pointer regardless of which gesture recognizer // wins the arena, so this coexists with the EagerGestureRecognizer above // (which still owns the drag for the page's own scrolling) without diff --git a/app/lib/screens/upgrade_screen.dart b/app/lib/screens/upgrade_screen.dart index 8cd74e61..0f7ae647 100644 --- a/app/lib/screens/upgrade_screen.dart +++ b/app/lib/screens/upgrade_screen.dart @@ -30,10 +30,13 @@ const _proYearlyFeatures = [ /// TEMP-PROMO: why the plan can't be bought, said in the CTA itself. "Coming /// soon" reads as half-built to someone who arrived from a site that told them -/// the beta is free. Same wording as web's `UNAVAILABLE_CTA_LABEL` and the -/// marketing site's `PlanCard.astro`. Carries no beta flag of its own: the -/// whole static block this belongs to is deleted when checkout opens — see the -/// TEMP-PROMO marker at the foot of this file. +/// the beta is free. This is now the only DEAD paid CTA left: web's pricing page +/// and the marketing site's plan cards capture an address in place, and +/// `device_cap_dialog.dart` — the app's own answer to the same problem — sends +/// the reader out to the site's capture rather than standing there disabled. +/// Carries no beta flag of its own: the whole static block this belongs to is +/// deleted when checkout opens — see the TEMP-PROMO marker at the foot of this +/// file. const _unavailableCtaLabel = 'Available after beta'; /// The machine count sits mid-sentence, so it has to agree with its noun — diff --git a/app/lib/services/app_settings_service.dart b/app/lib/services/app_settings_service.dart index 6cf673f0..429dca6f 100644 --- a/app/lib/services/app_settings_service.dart +++ b/app/lib/services/app_settings_service.dart @@ -273,6 +273,17 @@ final appSettingsServiceProvider = ), ); +/// The user's crash/telemetry consent, as a single boolean. +/// +/// Carried into the bridge host's stdin bootstrap on every spawn — the host has +/// no settings store of its own, so this read is the whole of its consent (see +/// `bridge/src/crash-reporting.ts`). Split out from the settings notifier so a +/// spawn path depends on the ANSWER rather than on the prefs-seeded service, +/// which is also what lets a test container override it with a plain value. +final telemetryEnabledProvider = Provider( + (ref) => ref.watch(appSettingsServiceProvider).telemetryEnabled, +); + /// Compile-time relay URL baked in via `--dart-define=RELAY_URL=...`. Lets a /// build point at a specific relay (e.g. staging) without anyone touching App /// Settings. Empty (the default) means "no compile-time default". diff --git a/app/lib/services/capability_catalog_cache.dart b/app/lib/services/capability_catalog_cache.dart index 864f0dc1..dde5c21b 100644 --- a/app/lib/services/capability_catalog_cache.dart +++ b/app/lib/services/capability_catalog_cache.dart @@ -69,9 +69,16 @@ class CapabilityCatalogCache { } Future read(String key) async { - final f = await _fileFor(key); - if (!await f.exists()) return null; try { + // Inside the try, not before it: resolving the app-support directory is + // itself a platform call that can fail (a host with no such directory, + // a plugin the test harness has not stubbed), and so is `exists()`. Left + // outside, they broke this method's "never throws on read" contract for + // the one case that is not the file being absent — and every caller + // starts it from a build method and discards the future, so the throw + // lands in the zone as an unhandled async error nobody can see. + final f = await _fileFor(key); + if (!await f.exists()) return null; final json = jsonDecode(await f.readAsString()) as Map; return CapabilityCatalog.fromJson(json); } on FormatException { @@ -80,6 +87,12 @@ class CapabilityCatalogCache { return null; } on TypeError { return null; + } catch (_) { + // The catch-all IS the contract: "never throws on read" is what every + // caller relies on to start this and walk away, so a platform channel + // failing in a way the clauses above do not name must still read as a + // missing catalog, which every reader already renders. + return null; } } diff --git a/app/lib/services/control_plane_client.dart b/app/lib/services/control_plane_client.dart index eb957826..38e18ba5 100644 --- a/app/lib/services/control_plane_client.dart +++ b/app/lib/services/control_plane_client.dart @@ -490,6 +490,7 @@ class ControlPlaneClient { required String projectId, required String branch, bool allowActiveSessions = false, + bool stashIfDirty = false, }) async { final res = await transport.request( 'git.checkout', @@ -497,6 +498,7 @@ class ControlPlaneClient { 'projectId': projectId, 'branch': branch, 'allowActiveSessions': allowActiveSessions, + 'stashIfDirty': stashIfDirty, }, ); final current = res['current']; diff --git a/app/lib/services/file_service.dart b/app/lib/services/file_service.dart index 77662c87..1d8e3d44 100644 --- a/app/lib/services/file_service.dart +++ b/app/lib/services/file_service.dart @@ -1,13 +1,16 @@ import 'dart:async'; import 'package:antgrid_relay_client/antgrid_relay_client.dart'; +import 'package:uuid/uuid.dart'; import '../analytics/events.dart'; import '../models/file_tree_models.dart'; import '../models/preferences_models.dart'; import '../models/ab_message.dart'; +import '../models/git_sync_state.dart'; import '../project/project_session.dart'; import '../util/detached.dart'; +import 'pending_reply.dart'; import 'reply_latch.dart'; /// Per-project file tree + git status + viewing-file service. @@ -50,10 +53,43 @@ class FileService { final Duration gitActionTimeout; ReplyLatch? _diffLatch; + /// Bounds a `git:log` page fetch the same way [_diffLatch] bounds + /// `git:diff` — one slot, superseded on the next fetch (a scroll-triggered + /// load is guarded against firing while one is already in flight, so there + /// is never more than one page request to bound at a time). + ReplyLatch? _historyLatch; + + /// The offset [_historyLatch] is waiting on. `git:log-result` carries no + /// request id, and the offset is the only thing that distinguishes one page + /// from another — see [_handleGitLogResult] for what a mismatched page costs. + int? _pendingLogSkip; + + /// Bounds `git:commit-files`, keyed by sha rather than a single slot like + /// [_historyLatch]: the History tab lets more than one commit's file list + /// stay expanded and loading at once (see [GitHistoryState]), so a dropped + /// send for one commit must not settle another's in-flight fetch. + final Map _commitFilesLatches = {}; + + /// Wall-clock bound for push/pull. Longer than [gitActionTimeout] because + /// these reach the network — and load-bearing beyond the usual dropped-send + /// case: a bridge predating `git:sync` DROPS the verb silently, and there is + /// no bridge-to-app feature negotiation to check instead, so this timeout is + /// the only thing that clears the spinner against an older host. + final Duration gitSyncTimeout; + ReplyLatch? _syncLatch; + + /// In-flight `file:resolve-path` round trips, keyed by requestId — plural + /// unlike [_diffLatch]/[_syncLatch] because more than one terminal link can + /// be clicked (or hovered-then-clicked from two terminals) before either + /// answer lands. + final Map> + _pendingResolves = {}; + FileService.fromSession( this.session, { this.checkoutId = 'main', this.gitActionTimeout = const Duration(seconds: 15), + this.gitSyncTimeout = const Duration(seconds: 150), }) : _state = FileTreeState(projectId: session.projectId) { _heavySub = session.checkoutHeavyStream(checkoutId).listen(_onHeavyJson); _statusSub = session.checkoutStatusStream(checkoutId).listen(_onStatusJson); @@ -64,6 +100,18 @@ class FileService { // file tree stayed empty for the life of the session. As a hydrator it also // re-pulls on every reconnect. session.hydrateCheckout(checkoutId, _treeHydratorKey, _hydrateTree); + // The bridge caches `git:sync-state` for replay, but only a checkout whose + // bundle existed at connect time receives that replay — an isolated + // session's does not, exactly as [_hydrateTree] above documents. Asking + // also re-fires on every reconnect, which is what keeps the indicator from + // sitting on counts from before a drop. + session.hydrateCheckout(checkoutId, _syncHydratorKey, _hydrateSyncState); + // History is deliberately NOT hydrated here the way the tree and sync + // state are: it has no consumer besides the Git panel (every FileService + // exists whether or not that panel is ever opened), so eager-on-construct + // hydration would cost every project session a `git:log` round trip for a + // view most never visit. `GitPanel` triggers the first load itself once + // it is actually built with an empty history — see its `_maybeLoadHistory`. // A hydrator covers re-ESTABLISHMENT; this covers the other window the // agent suppresses in, which re-establishes nothing. While the app is // backgrounded the agent DROPS every `tree:update` and keeps bumping its @@ -79,6 +127,7 @@ class FileService { } static const _treeHydratorKey = 'file:tree'; + static const _syncHydratorKey = 'git:sync-state'; Future _hydrateTree() => session.sendForCheckout( checkoutId, @@ -119,6 +168,10 @@ class FileService { _handleFileContent(parsed); return; } + if (parsed is FileResolvePathResultMessage) { + _pendingResolves.remove(parsed.requestId)?.complete(parsed); + return; + } } void _onStatusJson(Map json) { @@ -159,6 +212,82 @@ class FileService { if (!parsed.success) _emitOpFeedback(parsed.error ?? 'Unstage failed'); return; } + if (parsed is GitStashListResultMessage) { + if (parsed.error == null) { + _setState( + _state.copyWith( + git: _state.git.copyWith(stashes: parsed.stashes), + ), + ); + } + return; + } + // Neither result asks for the list back: the agent already follows every + // pop and drop with a fresh `git:stash-list-result` on both outcomes, so a + // request here is a second round trip for a list already on its way. + if (parsed is GitStashPopResultMessage) { + if (!parsed.success) { + _emitOpFeedback(parsed.error ?? 'Could not restore the stash'); + } + return; + } + if (parsed is GitStashDropResultMessage) { + if (!parsed.success) { + _emitOpFeedback(parsed.error ?? 'Could not discard the stash'); + } + return; + } + if (parsed is GitSyncResultMessage) { + _handleGitSyncResult(parsed); + return; + } + if (parsed is GitSyncStateMessage) { + _setState(_state.copyWith(git: _state.git.copyWith(sync: parsed.state))); + return; + } + if (parsed is GitLogResultMessage) { + _handleGitLogResult(parsed); + return; + } + if (parsed is GitCommitFilesResultMessage) { + _handleCommitFilesResult(parsed); + return; + } + if (parsed is GitCommitDiffContentMessage) { + _handleGitCommitDiffContent(parsed); + return; + } + } + + void _handleGitSyncResult(GitSyncResultMessage msg) { + // A result for an op we are not waiting on is stale — a push whose latch + // already timed out, landing after the user started a pull. Settling the + // pull's latch on it would clear `syncing`, toast "Push complete" and + // re-enable both buttons while the pull is still running, and the pull's + // own reply would then arrive with nothing left to settle. A result with + // NO op in flight still lands: that is the other device having synced, and + // its outcome is the honest state for this one too. + final syncing = _state.git.syncing; + if (syncing != null && msg.op != syncing) return; + _syncLatch?.settle(); + _syncLatch = null; + final failure = msg.failure; + // Two branches rather than one call passing both a value and its clear + // flag: that combination is ambiguous by house rule, and here it would + // also be wrong — `lastSyncFailure: null` reads as "unchanged", so a + // success would leave the previous failure's offer standing. + final git = failure == null + ? _state.git.copyWith(clearSyncing: true, clearSyncFailure: true) + : _state.git.copyWith(clearSyncing: true, lastSyncFailure: failure); + _setState(_state.copyWith(git: git)); + // Toasted even when the panel will also offer the agent handoff: the + // handoff is an affordance the user may never look at, and a failure that + // said nothing at all would read as a button that did nothing. + _emitOpFeedback( + failure == null + ? (msg.summary ?? '${msg.op.label} complete') + : failure.message, + ); } /// Surface a one-shot git op result. Bumping the seq makes each result a @@ -269,7 +398,113 @@ class FileService { void _handleGitDiffContent(GitDiffContentMessage msg) { onFragmentSuccess?.call(FragHint('git:diff-content', msg.path)); - if (msg.path != _state.git.diffPath) return; + // Also guards on diffCommitSha being unset: a working-tree diff reply + // landing after the user has already switched to a commit's diff for the + // SAME path must not overwrite it. + if (msg.path != _state.git.diffPath || _state.git.diffCommitSha != null) { + return; + } + _diffLatch?.settle(); + _diffLatch = null; + _setState( + _state.copyWith( + git: _state.git.copyWith( + diffContent: msg.diff, + diffAdditions: msg.additions, + diffDeletions: msg.deletions, + diffLoading: false, + ), + ), + ); + } + + void _handleGitLogResult(GitLogResultMessage msg) { + // Correlated on the offset, because the append below is unconditional and + // a page that is not the one in flight appends the WRONG commits: a + // timed-out `skip: 50` arriving after the user scrolled and asked for + // `skip: 50` again lands twice, duplicating commits 51-100 in the list and + // pushing every later page's offset past real history. The same reply also + // settles whichever latch is current, so the page actually in flight then + // has nothing to time out on. + if (_pendingLogSkip != null && msg.skip != _pendingLogSkip) return; + _pendingLogSkip = null; + _historyLatch?.settle(); + _historyLatch = null; + if (msg.error != null) { + _setState( + _state.copyWith( + git: _state.git.copyWith( + history: _state.git.history.copyWith( + loadingMore: false, + initialLoad: false, + error: msg.error, + ), + ), + ), + ); + return; + } + // A page fetched with `skip: 0` REPLACES the list (a fresh open of the + // History tab, or a refresh); any other skip is assumed to continue the + // list this service itself has been paginating — callers never fetch an + // arbitrary skip, so there is nothing else it could be appending to. + final commits = msg.skip == 0 + ? msg.commits + : [..._state.git.history.commits, ...msg.commits]; + _setState( + _state.copyWith( + git: _state.git.copyWith( + history: _state.git.history.copyWith( + commits: commits, + loadingMore: false, + initialLoad: false, + hasMore: msg.hasMore, + clearError: true, + ), + ), + ), + ); + } + + void _handleCommitFilesResult(GitCommitFilesResultMessage msg) { + _commitFilesLatches.remove(msg.sha)?.settle(); + final loading = Set.from(_state.git.history.filesLoadingShas) + ..remove(msg.sha); + if (msg.error != null) { + final errors = Map.from(_state.git.history.filesErrorBySha) + ..[msg.sha] = msg.error!; + _setState( + _state.copyWith( + git: _state.git.copyWith( + history: _state.git.history.copyWith( + filesLoadingShas: loading, + filesErrorBySha: errors, + ), + ), + ), + ); + return; + } + final files = Map>.from( + _state.git.history.filesBySha, + )..[msg.sha] = msg.files; + _setState( + _state.copyWith( + git: _state.git.copyWith( + history: _state.git.history.copyWith( + filesBySha: files, + filesLoadingShas: loading, + ), + ), + ), + ); + } + + void _handleGitCommitDiffContent(GitCommitDiffContentMessage msg) { + onFragmentSuccess?.call(FragHint('git:commit-diff-content', msg.path)); + if (msg.path != _state.git.diffPath || msg.sha != _state.git.diffCommitSha) { + return; + } _diffLatch?.settle(); _diffLatch = null; _setState( @@ -291,6 +526,7 @@ class FileService { case 'file:content': _failFileContent(hint.key); case 'git:diff-content': + case 'git:commit-diff-content': _failDiff(hint.key); } } @@ -451,6 +687,44 @@ class FileService { _setState(_state.copyWith(expandedPaths: expanded)); } + /// Expands [path] and every ancestor directory so it is visible in the + /// tree. Used to reveal a folder a terminal link pointed at, which — unlike + /// a file — has no `selectedFilePath` of its own to make it visible. + void revealDirectory(String path) { + final segments = path.split('/').where((s) => s.isNotEmpty); + final expanded = Set.from(_state.expandedPaths); + var acc = ''; + for (final segment in segments) { + acc = acc.isEmpty ? segment : '$acc/$segment'; + expanded.add(acc); + } + _setState(_state.copyWith(expandedPaths: expanded)); + } + + /// Resolves a path a terminal program printed (an OSC 8 `file://` hyperlink + /// target, absolute or relative) against this checkout, returning its + /// checkout-relative form — or a null [FileResolvePathResultMessage.relPath] + /// when it doesn't resolve inside this checkout. Only the bridge can answer + /// this: the app never learns the checkout's absolute root (see + /// `docs/architecture.md`), so it cannot relativize the path itself. + Future resolveTerminalPath(String rawPath) { + final requestId = const Uuid().v4(); + final pending = PendingReply( + timeout: const Duration(seconds: 8), + onTimeout: () => _pendingResolves.remove(requestId), + ); + _pendingResolves[requestId] = pending; + session.sendForCheckout( + checkoutId, + createAbMessage('file:resolve-path', { + 'projectId': projectId, + 'requestId': requestId, + 'path': rawPath, + }), + ); + return pending.future; + } + void selectFile(String path, {int? searchLine, String? searchQuery}) { // Fire here, not in requestFileContent — the latter is a shared chokepoint // also hit by session-restore, fragment recovery, git "view file", and @@ -611,6 +885,62 @@ class FileService { ); } + /// Push the current branch, or publish it when it has no upstream. Never a + /// force push — a rejected push comes back as a [GitSyncFailure] for the + /// agent to reconcile rather than being forced through. + void push() => _sync(GitSyncOp.push); + + /// Fast-forward the current branch onto its upstream. A diverged branch + /// changes nothing and reports [GitSyncFailureKind.diverged]. + void pull() => _sync(GitSyncOp.pull); + + void _sync(GitSyncOp op) { + if (_state.git.syncing != null) return; + _setState( + _state.copyWith( + git: _state.git.copyWith(syncing: op, clearSyncFailure: true), + ), + ); + // Tier-2 one-shot, the same shape as [requestDiff]: a send dropped in a + // keyless relay window, or a bridge too old to know the verb, replies + // never — and without this the two buttons stay disabled for the life of + // the session. + _syncLatch?.settle(); + final latch = _syncLatch = ReplyLatch(); + session.sendForCheckout( + checkoutId, + createAbMessage('git:sync', {'projectId': projectId, 'op': op.name}), + ); + unawaited( + session.action(() => latch.done, timeout: gitSyncTimeout).catchError((_) { + if (_disposed || _syncLatch != latch) return; + _syncLatch = null; + _setState(_state.copyWith(git: _state.git.copyWith(clearSyncing: true))); + _emitOpFeedback('${op.label} timed out'); + }), + ); + } + + /// Re-read how the branch stands against its upstream. + /// + /// [probeRemote] additionally asks the REMOTE, which costs a network round + /// trip — so it is reserved for an explicit user action, never for the + /// hydrator, which would turn every reconnect into one. + void refreshSyncState({bool probeRemote = false}) { + session.sendForCheckout( + checkoutId, + createAbMessage('git:sync-status', { + 'projectId': projectId, + if (probeRemote) 'probeRemote': true, + }), + ); + } + + Future _hydrateSyncState() async { + if (_disposed) return; + refreshSyncState(); + } + void requestDiff(String path) { _setState( _state.copyWith( @@ -618,6 +948,9 @@ class FileService { diffPath: path, diffLoading: true, clearViewing: true, + // A prior commit diff for the same path must not linger: the reply + // handler keys on diffCommitSha being unset to accept this one. + clearDiffCommitSha: true, ), ), ); @@ -646,6 +979,293 @@ class FileService { ); } + /// Commits fetched per `git:log` page — the History tab's scroll-triggered + /// [loadMoreHistory] asks for another page of this size once the list is + /// within reach of its end. + static const historyPageSize = 50; + + bool _historyRequested = false; + + /// Claims the FIRST-ever history load for this service's lifetime, + /// returning true only on that one call. `GitPanel` calls this on every + /// build once its data is ready — cheaply and safely, since it is a plain + /// bool flip, not a state notification — and defers the actual + /// [loadHistory] send to outside build() only when it wins the claim. That + /// split is what makes the trigger immune to a build() that fires more than + /// once before the resulting `loadingMore` state change is reflected back: + /// without it, each such build would kick off its own `git:log` send and + /// its own 15s reply timeout, and only the LAST would ever be tracked (or + /// answered), leaving the earlier ones as orphaned pending timers. + bool claimHistoryLoad() { + if (_historyRequested) return false; + _historyRequested = true; + return true; + } + + bool _stashesRequested = false; + + /// Claims the first-ever stash load for this service's lifetime — same + /// contract as [claimHistoryLoad], and for the same reason: `GitPanel` + /// calls this on every build, and only the winning call may fire the + /// `git:stash-list` send. + bool claimStashLoad() { + if (_stashesRequested) return false; + _stashesRequested = true; + return true; + } + + /// Fetch every stash in the repository. Called once when the Git tab first + /// mounts (via [claimStashLoad]); the agent pushes a fresh list itself after + /// every pop and drop, since the list is the only honest record of what is + /// left — see [GitPaneState.stashes]. + void loadStashes() { + // Registered on the first ask rather than in the constructor, for the same + // reason history is not hydrated at all: a FileService exists whether or + // not the Git panel is ever opened. Once the panel HAS asked, the list has + // to survive a reconnect — [claimStashLoad] is one-shot for the service's + // lifetime and nothing else ever re-reads it, so the banner would go on + // offering a stash the agent popped while the socket was down. + // Registering IS the first ask — a hydrator fires immediately when the + // session is already established and on the next establishment otherwise, + // so a separate send here would only double it. Re-registering under the + // same key supersedes, so repeat calls are free. + session.hydrateCheckout(checkoutId, _stashHydratorKey, _hydrateStashes); + } + + static const _stashHydratorKey = 'git:stash-list'; + + Future _hydrateStashes() => session.sendForCheckout( + checkoutId, + createAbMessage('git:stash-list', {'projectId': projectId}), + ); + + /// Reapplies [ref] and drops it on success — the Git panel banner's + /// "Restore". Callers on a branch OTHER than the one the stash was made on + /// should switch first: a pop is a 3-way merge against the stash's own + /// base, and popping onto an unrelated branch invites a conflict that has + /// nothing to do with what the user asked for. + void restoreStash(String ref) { + session.sendForCheckout( + checkoutId, + createAbMessage('git:stash-pop', {'projectId': projectId, 'ref': ref}), + ); + } + + /// Discards [ref] permanently — the Git panel banner's "Discard". Callers + /// must confirm first. + void dropStash(String ref) { + session.sendForCheckout( + checkoutId, + createAbMessage('git:stash-drop', {'projectId': projectId, 'ref': ref}), + ); + } + + /// History tab: fetch the first page of commits, replacing whatever was + /// loaded before. Called once when the tab is first shown. + void loadHistory() { + _historyLatch?.settle(); + final latch = _historyLatch = ReplyLatch(); + // Keeps whatever is already loaded on screen. `_handleGitLogResult` + // replaces the list wholesale for a `skip == 0` page, so clearing it here + // buys nothing and costs the caller its view: `_HistoryList` renders its + // full-pane spinner for exactly "initialLoad with no commits", which on a + // pull-to-refresh tore the RefreshIndicator out from under the gesture + // that started it and dropped the scroll position with it. Only a list + // that is genuinely empty is an initial load. + final history = _state.git.history; + _setState( + _state.copyWith( + git: _state.git.copyWith( + history: history.copyWith( + loadingMore: true, + initialLoad: history.commits.isEmpty, + hasMore: true, + clearError: true, + ), + ), + ), + ); + _requestLogPage(skip: 0, latch: latch); + } + + /// History tab: fetch the next page, appending to what is already loaded. + /// No-op while a page is already loading or none remain — the scroll + /// listener that drives this has no other way to avoid firing repeatedly + /// near the bottom of the list. + void loadMoreHistory() { + final history = _state.git.history; + if (history.loadingMore || !history.hasMore) return; + _historyLatch?.settle(); + final latch = _historyLatch = ReplyLatch(); + _setState( + _state.copyWith( + git: _state.git.copyWith(history: history.copyWith(loadingMore: true)), + ), + ); + _requestLogPage(skip: history.commits.length, latch: latch); + } + + void _requestLogPage({required int skip, required ReplyLatch latch}) { + _pendingLogSkip = skip; + session.sendForCheckout( + checkoutId, + createAbMessage('git:log', { + 'projectId': projectId, + 'skip': skip, + 'limit': historyPageSize, + }), + ); + unawaited( + session.action(() => latch.done, timeout: gitActionTimeout).catchError(( + _, + ) { + if (_disposed || _historyLatch != latch) return; + _historyLatch = null; + _pendingLogSkip = null; + _setState( + _state.copyWith( + git: _state.git.copyWith( + history: _state.git.history.copyWith( + loadingMore: false, + initialLoad: false, + error: 'Loading history timed out — no response from the agent', + ), + ), + ), + ); + }), + ); + } + + /// History tab: expand a commit's file list, fetching it on first expand — + /// [GitHistoryState.filesBySha] is a cache the toggle never re-fetches once + /// populated — or collapse it back up. More than one commit can stay + /// expanded at once; see [collapseAllHistory] for the bulk fold. + void toggleCommitExpanded(String sha) { + final history = _state.git.history; + final expanded = Set.from(history.expandedShas); + final expanding = !expanded.remove(sha); + if (expanding) expanded.add(sha); + _setState( + _state.copyWith( + git: _state.git.copyWith(history: history.copyWith(expandedShas: expanded)), + ), + ); + if (expanding && + !history.filesBySha.containsKey(sha) && + !history.filesLoadingShas.contains(sha)) { + _requestCommitFiles(sha); + } + } + + /// History tab: re-fetch a commit's file list after [_requestCommitFiles] + /// failed — the commit is already expanded (that's why an error row is on + /// screen), so retrying is a plain re-fetch rather than another toggle. + void retryCommitFiles(String sha) => _requestCommitFiles(sha); + + void _requestCommitFiles(String sha) { + final history = _state.git.history; + final loading = Set.from(history.filesLoadingShas)..add(sha); + final errors = Map.from(history.filesErrorBySha) + ..remove(sha); + _setState( + _state.copyWith( + git: _state.git.copyWith( + history: history.copyWith( + filesLoadingShas: loading, + filesErrorBySha: errors, + ), + ), + ), + ); + _commitFilesLatches.remove(sha)?.settle(); + final latch = ReplyLatch(); + _commitFilesLatches[sha] = latch; + session.sendForCheckout( + checkoutId, + createAbMessage('git:commit-files', {'projectId': projectId, 'sha': sha}), + ); + unawaited( + session.action(() => latch.done, timeout: gitActionTimeout).catchError(( + _, + ) { + if (_disposed || _commitFilesLatches[sha] != latch) return; + _commitFilesLatches.remove(sha); + final stillLoading = Set.from( + _state.git.history.filesLoadingShas, + )..remove(sha); + final withError = Map.from( + _state.git.history.filesErrorBySha, + )..[sha] = 'Loading files timed out — no response from the agent'; + _setState( + _state.copyWith( + git: _state.git.copyWith( + history: _state.git.history.copyWith( + filesLoadingShas: stillLoading, + filesErrorBySha: withError, + ), + ), + ), + ); + }), + ); + } + + /// History tab: fold every expanded commit's file list shut without + /// dropping the cached files — the same "Collapse All" the Changes tab's + /// folder toggle offers, applied to expanded commits instead of folders. + void collapseAllHistory() { + final history = _state.git.history; + if (history.expandedShas.isEmpty) return; + _setState( + _state.copyWith( + git: _state.git.copyWith(history: history.copyWith(expandedShas: const {})), + ), + ); + } + + /// History tab: open one file's diff within [sha] — the same viewer + /// [requestDiff] opens for the working tree, distinguished on screen by + /// [GitPaneState.diffCommitSha]. + void requestCommitDiff(String sha, String path) { + _setState( + _state.copyWith( + git: _state.git.copyWith( + diffPath: path, + diffCommitSha: sha, + diffLoading: true, + clearViewing: true, + ), + ), + ); + _diffLatch?.settle(); + final latch = _diffLatch = ReplyLatch(); + session.sendForCheckout( + checkoutId, + createAbMessage('git:commit-diff', { + 'projectId': projectId, + 'sha': sha, + 'path': path, + }), + ); + unawaited( + session.action(() => latch.done, timeout: gitActionTimeout).catchError(( + _, + ) { + if (_disposed || + _diffLatch != latch || + _state.git.diffPath != path || + _state.git.diffCommitSha != sha) { + return; + } + _diffLatch = null; + _setState( + _state.copyWith(git: _state.git.copyWith(diffLoading: false)), + ); + }), + ); + } + void toggleChangedOnly() { _setState(_state.copyWith(showChangedOnly: !_state.showChangedOnly)); } @@ -704,8 +1324,23 @@ class FileService { // Resolve any in-flight git:diff action so its timeout timer is cancelled. _diffLatch?.settle(); _diffLatch = null; + _syncLatch?.settle(); + _syncLatch = null; + _historyLatch?.settle(); + _historyLatch = null; + _pendingLogSkip = null; + for (final latch in _commitFilesLatches.values) { + latch.settle(); + } + _commitFilesLatches.clear(); + for (final pending in _pendingResolves.values) { + pending.fail(StateError('FileService disposed')); + } + _pendingResolves.clear(); session.unhydrateCheckout(checkoutId, 'file:selected'); session.unhydrateCheckout(checkoutId, _treeHydratorKey); + session.unhydrateCheckout(checkoutId, _syncHydratorKey); + session.unhydrateCheckout(checkoutId, _stashHydratorKey); await _heavySub?.cancel(); _heavySub = null; await _statusSub?.cancel(); diff --git a/app/lib/services/handler_service.dart b/app/lib/services/handler_service.dart index 617bfc80..8eb17a6b 100644 --- a/app/lib/services/handler_service.dart +++ b/app/lib/services/handler_service.dart @@ -55,28 +55,41 @@ class HandlerService { // moved it. Held so [_retirePending] can spend that one frame on the goal. final Set _armGoalExtractions = {}; - // Judge picks, keyed by terminalId. `sessions` in [HandlerState] only holds - // currently-armed sessions, so a disarmed terminal's judge pick would - // otherwise vanish and the next arm would silently reset to Default — the - // exact bug moving this off project level was meant to fix. Bounded and - // insertion-ordered: terminal ids are per-session UUIDs, so an unbounded map - // would accumulate an entry for every slot the project ever opened, for the - // app's lifetime. - static const _judgeCacheCap = 50; - final Map _lastKnownJudge = {}; - - // Records a clear (both null) too — a status snapshot showing an armed - // session is authoritative for that session's judge, including "cleared - // back to default". Skipping nulls here was the bug: a re-arm that cleared - // the judge left the previous non-null pick cached, so the next time a - // picker opened it silently re-seeded (and re-armed) the stale tool. + // Per-session settings a picker seeds from, keyed by terminalId. `sessions` + // in [HandlerState] only holds currently-armed sessions, so a disarmed + // terminal's picks would otherwise vanish and the next arm would silently + // reset to the defaults — the exact bug moving this off project level was + // meant to fix. Bounded and insertion-ordered: terminal ids are per-session + // UUIDs, so an unbounded map would accumulate an entry for every slot the + // project ever opened, for the app's lifetime. + static const _settingsCacheCap = 50; + final Map< + String, + ({String? tool, String? model, HandlerPersonality? personality}) + > + _lastKnownSettings = {}; + + // Records a clear (nulls) too — a status snapshot showing an armed session is + // authoritative for that session's settings, including "cleared back to + // default". Skipping nulls here was the bug: a re-arm that cleared the judge + // left the previous non-null pick cached, so the next time a picker opened it + // silently re-seeded (and re-armed) the stale tool. // // Re-inserts so a refreshed entry counts as most-recent, evicts oldest at cap. - void _rememberJudge(String terminalId, String? tool, String? model) { - _lastKnownJudge.remove(terminalId); - _lastKnownJudge[terminalId] = (tool: tool, model: model); - while (_lastKnownJudge.length > _judgeCacheCap) { - _lastKnownJudge.remove(_lastKnownJudge.keys.first); + void _rememberSettings( + String terminalId, + String? tool, + String? model, + HandlerPersonality? personality, + ) { + _lastKnownSettings.remove(terminalId); + _lastKnownSettings[terminalId] = ( + tool: tool, + model: model, + personality: personality, + ); + while (_lastKnownSettings.length > _settingsCacheCap) { + _lastKnownSettings.remove(_lastKnownSettings.keys.first); } } @@ -254,7 +267,12 @@ class HandlerService { final s = HandlerSessionState.fromWire(raw); if (s == null) continue; sessions[s.terminalId] = s; - _rememberJudge(s.terminalId, s.judgeTool, s.judgeModel); + _rememberSettings( + s.terminalId, + s.judgeTool, + s.judgeModel, + s.personality, + ); } // Replace wholesale (welcome-replay safe) rather than merge — the // snapshot is the bridge's full current set of armed sessions, and it @@ -319,7 +337,16 @@ class HandlerService { pendingUndo: pendingUndo, pendingInstructions: pendingInstructions, ); - _emit(next); + // Set and cleared from the same frame, in two calls rather than one: the + // bridge omits the key the moment Handler is available again, and a + // refusal that could only ever latch on would survive the upgrade that + // lifted it. Never both arguments at once — see the copyWith rule. + final entitlement = HandlerEntitlement.fromWire(msg.entitlement); + _emit( + entitlement == null + ? next.copyWith(clearEntitlement: true) + : next.copyWith(entitlement: entitlement), + ); } void _onHeavyJson(Map json) { @@ -427,32 +454,36 @@ class HandlerService { /// appends to it, so never round-trip a stale copy back. /// /// [judgeTool]/[judgeModel] are this session's judge choice; `''` clears back - /// to default and a name sets it. Pass null (the default) to leave the - /// session's stored judge record untouched, for any caller that doesn't - /// surface a picker — the keys are omitted from the wire message, which the - /// bridge reads as "no change" (so arming without touching the judge picker - /// never rewrites its per-session record). + /// to default and a name sets it. [personality] is its posture, which has no + /// clear — every preset is a real choice, and the default is only what a + /// session that has never been given one runs under. Pass null (the default) + /// to leave the stored record untouched, for any caller that surfaces no + /// picker: the keys are omitted from the wire message, which the bridge reads + /// as "no change", so arming without opening the settings sheet never + /// rewrites what that sheet would have shown. void arm({ required String terminalId, String? goal, List? backlog, String? judgeTool, String? judgeModel, + HandlerPersonality? personality, }) { if (_disposed) return; - if (judgeTool != null || judgeModel != null) { - // Optimistically mirror the bridge's applyJudgeChoice ('' clears, a - // name sets, an omitted field keeps its old value) so lastKnownJudge is - // right immediately: reopening a picker before the status snapshot - // round-trips would otherwise seed it with the pre-arm judge — and - // committing that stale value silently reverts this arm's choice. - final prev = lastKnownJudge(terminalId); - _rememberJudge( + if (judgeTool != null || judgeModel != null || personality != null) { + // Optimistically mirror the bridge's apply rules ('' clears, a name sets, + // an omitted field keeps its old value) so [lastKnownSettings] is right + // immediately: reopening the sheet before the status snapshot round-trips + // would otherwise seed it with the pre-arm values — and committing those + // stale ones silently reverts this arm's choice. + final prev = lastKnownSettings(terminalId); + _rememberSettings( terminalId, judgeTool != null ? (judgeTool.isEmpty ? null : judgeTool) : prev?.tool, judgeModel != null ? (judgeModel.trim().isEmpty ? null : judgeModel.trim()) : prev?.model, + personality ?? prev?.personality, ); } // Mirrors the condition the bridge queues an arm-time extraction on: a goal @@ -485,6 +516,9 @@ class HandlerService { 'backlog': ?backlog?.map((i) => i.toWire()).toList(), 'judgeTool': ?judgeTool, 'judgeModel': ?judgeModel, + 'personality': ?(personality == null + ? null + : handlerPersonalityToWire(personality)), }), ); } @@ -643,27 +677,32 @@ class HandlerService { ); } - /// The judge pick a picker would seed from (status snapshots and optimistic - /// [arm] writes feed the cache). Null = never picked. + /// The settings a picker seeds from (status snapshots and optimistic [arm] + /// writes feed the cache). Null = this terminal has never reported any. /// - /// Nothing in the app writes a judge override yet, so no surface reads this - /// back either — it is fed only by status snapshots. Kept rather than - /// deleted because this cache is the whole reason a re-arm does not silently - /// revert to Default; the clear-vs-stale rules on [_rememberJudge] are the - /// fix, and they are easy to get wrong a second time. + /// This cache is the whole reason a re-arm does not silently revert to the + /// defaults; the clear-vs-stale rules on [_rememberSettings] are the fix, and + /// they are easy to get wrong a second time. /// /// Cache first, armed-session state second: every status snapshot writes /// BOTH, and [arm] optimistically writes only the cache — so the cache is /// never staler than the armed entry and is fresher during the arm→snapshot /// round-trip. The armed fallback only matters if enough other terminals /// evicted this one's cache entry while it stayed armed. - ({String? tool, String? model})? lastKnownJudge(String terminalId) { - final cached = _lastKnownJudge[terminalId]; + ({String? tool, String? model, HandlerPersonality? personality})? + lastKnownSettings(String terminalId) { + final cached = _lastKnownSettings[terminalId]; if (cached != null) return cached; final armed = _state.sessions[terminalId]; if (armed != null && - (armed.judgeTool != null || armed.judgeModel != null)) { - return (tool: armed.judgeTool, model: armed.judgeModel); + (armed.judgeTool != null || + armed.judgeModel != null || + armed.personality != null)) { + return ( + tool: armed.judgeTool, + model: armed.judgeModel, + personality: armed.personality, + ); } return null; } diff --git a/app/lib/services/preview_service.dart b/app/lib/services/preview_service.dart index ef5749f9..1fc29d70 100644 --- a/app/lib/services/preview_service.dart +++ b/app/lib/services/preview_service.dart @@ -523,8 +523,29 @@ class PreviewService { Map headers, ) { final tunnelId = const Uuid().v4(); + final outbound = _WsOutboundQueue( + session.transport, + onAbort: (reason) { + AbLog.warn( + 'preview', + 'ws tunnel aborted', + fields: {'tunnelId': tunnelId, 'port': port, 'reason': reason}, + ); + // Close the local socket only. The `onDone` below is what removes the + // tunnel and tells the bridge, and closing here is what triggers it — + // the previewed page then sees a real close event and reconnects, + // instead of holding an open socket nothing will ever answer. + final tunnel = _activeWsTunnels[tunnelId]; + if (tunnel == null) return; + detached( + 'preview', + 'ws tunnel abort close', + () => tunnel.channel.sink.close(), + ); + }, + ); - session.transport.send( + outbound.send( createAbMessage('tunnel:ws-open', { 'tunnelId': tunnelId, 'port': port, @@ -533,31 +554,28 @@ class PreviewService { 'headers': headers, 'checkoutId': checkoutId, }), - channel: 'preview', ); final sub = channel.stream.listen( (data) { if (data is String) { - session.transport.send( + outbound.send( createAbMessage('tunnel:ws-data', { 'tunnelId': tunnelId, 'data': data, 'checkoutId': checkoutId, }), - channel: 'preview', ); return; } - session.transport.send( + outbound.send( createAbMessage('tunnel:ws-data', { 'tunnelId': tunnelId, 'data': base64Encode(data as List), 'binary': true, 'checkoutId': checkoutId, }), - channel: 'preview', ); }, onDone: () { @@ -565,12 +583,11 @@ class PreviewService { // (the bridge/upstream side closed first) — that path already told // the bridge, so closing our own sink here must not tell it again. if (_activeWsTunnels.remove(tunnelId) == null) return; - session.transport.send( + outbound.sendClose( createAbMessage('tunnel:ws-close', { 'tunnelId': tunnelId, 'checkoutId': checkoutId, }), - channel: 'preview', ); }, ); @@ -664,3 +681,91 @@ class _WsTunnel { _WsTunnel(this.channel, this.sub); } + +/// One FIFO for every app-to-bridge frame belonging to a browser WebSocket. +/// +/// Transport sealing is asynchronous. Independent fire-and-forget sends can +/// otherwise put the browser's first SignalR frame ahead of `tunnel:ws-open`, +/// or reorder later binary frames. WebSocket application protocols require the +/// byte stream to retain its original order. +/// +/// Ordering is only half the job: the frames must also arrive. A send with no +/// session keys installed completes SUCCESSFULLY and delivers nothing, so a +/// lost `tunnel:ws-open` would otherwise leave the browser's socket waiting +/// forever on a tunnel the bridge never heard of. [onAbort] fires on any frame +/// this queue cannot vouch for, and the tunnel is closed rather than left open +/// and mute. +class _WsOutboundQueue { + _WsOutboundQueue(this._transport, {required this.onAbort}); + + final AgentTransport _transport; + final void Function(String reason) onAbort; + + Future _tail = Future.value(); + int _queuedFrames = 0; + int _queuedBytes = 0; + bool _aborted = false; + + /// Same ceilings the bridge applies to its own pre-open buffer. Serializing + /// on the transport means a slow link builds the backlog HERE, and a browser + /// streaming into a wedged tunnel would otherwise grow it without limit. + static const _maxQueuedFrames = 64; + static const _maxQueuedBytes = 1024 * 1024; + static const _sendTimeout = Duration(seconds: 10); + + /// How long the close frame waits its turn. Ordering matters least here: + /// a queue that has not drained has already lost the data the close would + /// follow, and the bridge's upstream dev-server socket stays open until it + /// arrives. + static const _closeGrace = Duration(seconds: 2); + + void send(Map message) { + if (_aborted) return; + final bytes = (message['data'] as String?)?.length ?? 0; + if (_queuedFrames >= _maxQueuedFrames || + _queuedBytes + bytes > _maxQueuedBytes) { + _abort('outbound queue limit reached'); + return; + } + _queuedFrames++; + _queuedBytes += bytes; + _tail = _tail.then((_) => _sendOne(message, bytes)); + } + + /// Enqueue the tunnel's close, bounded by [_closeGrace] rather than by the + /// backlog ahead of it. Ignores [_aborted]: the bridge is owed this frame + /// precisely when the tunnel died badly. + void sendClose(Map message) { + final ahead = _tail; + detached('preview', 'ws tunnel close', () async { + await ahead.timeout(_closeGrace, onTimeout: () {}).catchError((_) {}); + await _transport.send(message, channel: 'preview').timeout(_sendTimeout); + }); + } + + /// Never throws — the chain in [send] carries no error handler of its own, + /// and one rejection there would strand every frame behind it. + Future _sendOne(Map message, int bytes) async { + try { + if (_aborted) return; + // Not `currentState == connected`: a relay stream stays connected across + // a session-down window where the send returns normally and drops. + if (!_transport.isEstablished) { + _abort('transport not established'); + return; + } + await _transport.send(message, channel: 'preview').timeout(_sendTimeout); + } catch (err) { + _abort('$err'); + } finally { + _queuedFrames--; + _queuedBytes -= bytes; + } + } + + void _abort(String reason) { + if (_aborted) return; + _aborted = true; + onAbort(reason); + } +} diff --git a/app/lib/services/terminal_service.dart b/app/lib/services/terminal_service.dart index 1302cd91..b957baf3 100644 --- a/app/lib/services/terminal_service.dart +++ b/app/lib/services/terminal_service.dart @@ -604,6 +604,11 @@ class TerminalService { layout: msg.layout ?? _state.layout, commands: msg.commands ?? _state.commands, gitBranch: msg.git?.branch ?? _state.gitBranch, + // Taken from the same frame as the branch, never carried: a status with + // no git block means the checkout stopped being a repository, and + // keeping the previous counts beside a cleared branch is worse than 0. + gitAhead: msg.git?.ahead ?? 0, + gitBehind: msg.git?.behind ?? 0, // Carried, not defaulted: a status frame says nothing about an // in-flight branch list or a checkout error, and rebuilding without // them empties an open branch picker and swallows the failure toast. diff --git a/app/lib/storage/first_run_store.dart b/app/lib/storage/first_run_store.dart index 6249a772..c2606927 100644 --- a/app/lib/storage/first_run_store.dart +++ b/app/lib/storage/first_run_store.dart @@ -44,8 +44,12 @@ class FirstRunState { /// True once the user has ever armed Handler — any session, any platform. /// Cross-project app-install discovery state (shield label collapse, - /// explainer/away-hint suppression, checklist step), NOT handler config — - /// which is why it lives here and not in the bridge's HandlerState. + /// away-hint suppression, checklist step), NOT handler config — which is why + /// it lives here and not in the bridge's HandlerState. On the arm sheet it + /// gates the explanatory paragraph ALONE: the sheet is where an arm is + /// composed, so it opens every time, and what it says about coverage or a + /// seeded goal is a fact about that arm rather than something reading it once + /// retires. final bool handlerArmedOnce; /// Global kill for the away-moment hint once the user closes it — it must diff --git a/app/lib/storage/recent_ports_store.dart b/app/lib/storage/recent_ports_store.dart deleted file mode 100644 index d08f97f0..00000000 --- a/app/lib/storage/recent_ports_store.dart +++ /dev/null @@ -1,161 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:shared_preferences/shared_preferences.dart'; - -import '../config/storage_scope.dart'; -import '../demo/demo_identity.dart'; -import 'scoped_prefs.dart'; - -/// A remembered preview target: a port plus the scheme it was last opened with. -class RecentPort { - final int port; - final String scheme; // 'http' | 'https' - const RecentPort(this.port, this.scheme); - - Map toJson() => {'port': port, 'scheme': scheme}; - - @override - bool operator ==(Object other) => - other is RecentPort && other.port == port && other.scheme == scheme; - - @override - int get hashCode => Object.hash(port, scheme); -} - -/// One project's remembered ports, emitted on [RecentPortsStore.changes]. -class RecentPortsChange { - final String projectId; - final List ports; - const RecentPortsChange(this.projectId, this.ports); -} - -/// SharedPreferences-backed list of manually-entered preview targets, keyed by -/// project. Detection (`ports:update` / `preview:snapshot`) is the normal way -/// ports appear; this store backs the manual-entry fallback so a target typed -/// once is offered as a quick-pick — with its scheme — next time. -/// -/// Stored under a single JSON key -/// (`{ "": [{"port":3000,"scheme":"http"}] }`) so each write is -/// atomic. Per project the list is most-recent-first, deduped by port (one -/// entry per port carrying its latest scheme), and capped at [_capPerProject]. -/// Mirrors [RecentAgentsStore]'s snapshot-on-write model: every mutation emits -/// a fresh immutable list on [changes]. -class RecentPortsStore { - static final _key = scopedStorageKey('antgrid.recent_ports.v1'); - static const _capPerProject = 8; - - final SharedPreferencesWithCache _prefs; - final StreamController _changes = - StreamController.broadcast(); - - RecentPortsStore._(this._prefs); - - static Future open() async => - RecentPortsStore._(await openScopedPrefs({_key})); - - Map> _readAll() { - final raw = _prefs.getString(_key); - if (raw == null) return {}; - // Degrade to empty rather than throwing through the provider build if the - // stored blob is ever malformed (partial write, manual edit, schema drift). - try { - final decoded = jsonDecode(raw) as Map; - return decoded.map( - (k, v) => MapEntry(k, (v as List).map(_parseEntry).toList()), - ); - } catch (_) { - return {}; - } - } - - // Accepts both the current object form and the legacy bare-int form - // (pre-scheme builds stored `[3000, 5173]`), treating bare ints as http. - RecentPort _parseEntry(dynamic e) { - if (e is int) return RecentPort(e, 'http'); - final m = e as Map; - return RecentPort(m['port'] as int, (m['scheme'] as String?) ?? 'http'); - } - - List list(String projectId) => - List.unmodifiable(_readAll()[projectId] ?? const []); - - /// Broadcast stream of post-write snapshots. Does NOT replay current state to - /// late subscribers — seed from [list], then listen. - Stream get changes => _changes.stream; - - /// Records [port]/[scheme] as the most-recently-used for [projectId]. An - /// existing entry for the same port (any scheme) is replaced and moved to the - /// front. No-ops on out-of-range ports. - Future add(String projectId, int port, String scheme) async { - // Nothing the demo does may reach disk; its ports are canned. - if (isDemoProjectId(projectId)) return; - if (port < 1 || port > 65535) return; - final all = _readAll(); - final ports = List.from(all[projectId] ?? const []) - ..removeWhere((e) => e.port == port) - ..insert(0, RecentPort(port, scheme)); - if (ports.length > _capPerProject) { - ports.removeRange(_capPerProject, ports.length); - } - all[projectId] = ports; - await _write(all, projectId, ports); - } - - Future remove(String projectId, int port) async { - final all = _readAll(); - final existing = all[projectId]; - if (existing == null) return; - final ports = List.from(existing) - ..removeWhere((e) => e.port == port); - if (ports.isEmpty) { - all.remove(projectId); - } else { - all[projectId] = ports; - } - await _write(all, projectId, ports); - } - - /// Drops every remembered port for [projectId]. Used by project deletion so a - /// removed project leaves no port history behind. No-ops (and emits nothing) - /// when the project has no entries. - Future removeProject(String projectId) async { - final all = _readAll(); - if (all.remove(projectId) == null) return; - await _write(all, projectId, const []); - } - - /// Drops every remembered port for every project. Used by hard sign-out — - /// the ports were observed on machines reached under the account that is - /// going away. Emits one empty snapshot per project that had entries so live - /// [RecentPortsNotifier]s drop their lists too. - Future clear() async { - final all = _readAll(); - if (all.isEmpty) return; - await _prefs.setString(_key, jsonEncode({})); - if (_changes.isClosed) return; - for (final projectId in all.keys) { - _changes.add(RecentPortsChange(projectId, const [])); - } - } - - Future close() => _changes.close(); - - Future _write( - Map> all, - String projectId, - List ports, - ) async { - final encoded = jsonEncode( - all.map((k, v) => MapEntry(k, v.map((e) => e.toJson()).toList())), - ); - // No-op write: identical blob already stored. Skip the prefs round-trip and - // the stream emission so consumers don't rebuild on unchanged mutations - // (mirrors RecentAgentsStore._write). - if (_prefs.getString(_key) == encoded) return; - await _prefs.setString(_key, encoded); - if (!_changes.isClosed) { - _changes.add(RecentPortsChange(projectId, List.unmodifiable(ports))); - } - } -} diff --git a/app/lib/util/external_url.dart b/app/lib/util/external_url.dart index 82c2f525..a05cb31c 100644 --- a/app/lib/util/external_url.dart +++ b/app/lib/util/external_url.dart @@ -1,7 +1,13 @@ +import 'dart:io' show InternetAddress; + import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../design/widgets/ab_confirm_dialog.dart'; import '../design/widgets/ab_snack_bar.dart'; +import '../models/workspace_view.dart'; +import '../services/file_service.dart'; +import '../services/preview_service.dart'; import '../widgets/terminal_hyperlink_sheet.dart'; import 'ab_log.dart'; @@ -137,6 +143,180 @@ Future openTerminalHyperlink( } } +/// Extracts the raw filesystem path from an OSC 8 `file://` hyperlink target, +/// or null when [uri] isn't shaped like one. +/// +/// The path is handed to the bridge as-is (`FileService.resolveTerminalPath` +/// → `file:resolve-path`) and resolved there against the checkout root: only +/// the bridge machine's own platform separators are authoritative, and the +/// app never learns which OS a remote session's machine runs — so this stays +/// a syntactic unwrap, not a validity check. +String? terminalFilePath(String uri) { + final parsed = Uri.tryParse(uri.trim()); + if (parsed == null || parsed.scheme != 'file') return null; + if (parsed.path.isEmpty) return null; + var path = Uri.decodeFull(parsed.path); + // `file:///C:/Users/...` parses with a leading slash ahead of the drive + // letter; a Windows path never actually starts with one. + if (RegExp(r'^/[A-Za-z]:/').hasMatch(path)) { + path = path.substring(1); + } + return path; +} + +/// Whether [host] is a dev server's address rather than an external site's — +/// `localhost`, or any literal IPv4/IPv6 address (loopback, LAN, or a raw +/// public IP typed straight at a box). A link that names a real external +/// site by bare IP is vanishingly rare; a DOMAIN name is what a public site +/// looks like, so a hostname always falls through to the external-browser +/// path in [openContentLink] regardless of what it resolves to. +bool isLocalDevHost(String host) { + return host.toLowerCase() == 'localhost' || + InternetAddress.tryParse(host) != null; +} + +/// Opens a link found in ANY app surface that renders untrusted content — +/// terminal OSC 8 hyperlinks, a markdown-previewed file, or markdown inside +/// an agent chat message. Every surface routes through this one function so +/// the three destinations agree everywhere the app shows a link: +/// +/// * `file://...` → the Files tab, resolved via [fileService] against +/// whichever checkout the caller means (never assumed here). +/// * `http(s)://` to `localhost` or a literal IP ([isLocalDevHost]) → the +/// Preview tab via [previewService], in-app on every device — desktop +/// dials it directly, a phone tunnels it over the relay — rather than an +/// external browser that may have no route to the port at all. +/// * anything else (a search result, a docs site, a GitHub PR) → the +/// system browser, through [openTerminalHyperlink]'s existing scheme and +/// deceptive-link checks. +/// +/// [fileService] and [previewService] are resolved LAZILY, and re-invoked on +/// every retry rather than captured once: this awaits user dialogs, and the +/// checkout or session behind either service can be gone by the time a +/// fallback path runs. +/// +/// Never completes with an error, matching [openTerminalHyperlink]'s own +/// contract — every caller discards the future this returns. +Future openContentLink( + BuildContext context, + String uri, { + required FileService? Function() fileService, + required PreviewService? Function() previewService, + required void Function(WorkspaceView) revealView, + bool disclosed = false, +}) async { + final parsed = Uri.tryParse(uri.trim()); + if (parsed?.scheme == 'file') { + await _openFileLink(context, uri, fileService, revealView); + return; + } + if (parsed != null && + (parsed.scheme == 'http' || parsed.scheme == 'https') && + isLocalDevHost(parsed.host)) { + await _openPreviewLink(context, parsed, previewService, revealView); + return; + } + await openTerminalHyperlink(context, uri, disclosed: disclosed); +} + +/// Opens a path a `file://` link named in the Files tab. See +/// [FileService.resolveTerminalPath] for why only the bridge can relativize +/// the path, and [FileService.revealDirectory] for the folder case. +Future _openFileLink( + BuildContext context, + String rawUri, + FileService? Function() fileService, + void Function(WorkspaceView) revealView, +) async { + try { + final path = terminalFilePath(rawUri); + if (path == null) { + if (context.mounted) showAbSnackBar(context, 'Could not open that link.'); + return; + } + final service = fileService(); + if (service == null) return; + final result = await service.resolveTerminalPath(path); + if (!context.mounted) return; + final relPath = result.relPath; + if (relPath == null) { + showAbSnackBar(context, 'That path is outside this workspace.'); + return; + } + revealView(WorkspaceView.files); + if (result.isDirectory) { + service.revealDirectory(relPath); + } else { + service.selectFile(relPath); + } + } catch (error, stack) { + AbLog.error( + 'ContentLink', + 'open file link failed', + fields: {'error': '$error', 'stack': '$stack'}, + ); + } +} + +/// Opens a `localhost`/IP-literal `http(s)` link in the Preview tab, with the +/// same port-conflict confirm-and-fallback dialog the manual "open port" flow +/// uses (`PreviewScreen._openPort`). +Future _openPreviewLink( + BuildContext context, + Uri target, + PreviewService? Function() previewService, + void Function(WorkspaceView) revealView, +) async { + final scheme = target.scheme; + final port = target.hasPort ? target.port : (scheme == 'https' ? 443 : 80); + // Reassembled rather than taken from the path alone. A hash-routed dev + // server (Vue Router's hash mode, Angular's HashLocationStrategy) keeps the + // WHOLE route in the fragment, so dropping it lands every such link on the + // app's root instead of the page it named; and a URL with no path at all + // still is not the origin once it carries a query. + final buffer = StringBuffer(target.path.isEmpty ? '/' : target.path); + if (target.query.isNotEmpty) buffer.write('?${target.query}'); + if (target.fragment.isNotEmpty) buffer.write('#${target.fragment}'); + final path = buffer.toString(); + try { + final service = previewService(); + if (service == null) return; + final result = await service.openTab(port, scheme: scheme, path: path); + if (!context.mounted) return; + if (result != SelectPortResult.portInUse) { + revealView(WorkspaceView.preview); + return; + } + final confirmed = await AbConfirmDialog.show( + context: context, + title: 'Port $port unavailable', + body: + 'Port $port could not be opened on this device (it may be in use ' + 'or reserved). Open the preview on a different local port ' + 'instead? Sites that pin assets to port $port may not fully ' + 'load.', + confirmLabel: 'Open anyway', + ); + if (!confirmed || !context.mounted) return; + // Re-resolved rather than reusing `service`: this awaited a user dialog, + // and the session behind it could have torn down in that window. + final fallback = previewService(); + if (fallback == null) return; + await fallback.selectPortWithFallback(port, scheme: scheme, path: path); + if (!context.mounted) return; + revealView(WorkspaceView.preview); + } catch (error, stack) { + if (context.mounted) { + showAbSnackBar(context, 'Could not open preview on port $port.'); + } + AbLog.error( + 'ContentLink', + 'open preview link failed', + fields: {'error': '$error', 'stack': '$stack'}, + ); + } +} + /// Whether [target] is shaped like a link trying to pass as another one. /// /// Judged from the URI alone, which is all a terminal hyperlink hands over. diff --git a/app/lib/widgets/agent_panel.dart b/app/lib/widgets/agent_panel.dart index 4fc48014..d5176541 100644 --- a/app/lib/widgets/agent_panel.dart +++ b/app/lib/widgets/agent_panel.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -11,12 +12,15 @@ import '../design/widgets/ab_button.dart'; import '../design/widgets/ab_chip.dart'; import '../design/widgets/ab_icon.dart'; import '../design/widgets/ab_icon_button.dart'; +import '../design/widgets/ab_menu.dart'; import '../design/widgets/ab_snack_bar.dart'; +import '../design/widgets/ab_tap_target.dart'; import '../design/widgets/pulsing_opacity.dart'; import '../design/widgets/ab_toolbar.dart'; import '../design/widgets/ab_tooltip.dart'; import '../models/handler_state.dart'; import '../models/session_entry.dart'; +import '../models/workspace_view.dart'; import '../providers/account_agents.dart'; import '../providers/agent_coordinates.dart'; import '../providers/agent_transport.dart'; @@ -29,6 +33,7 @@ import '../providers/providers.dart'; import '../providers/recent_agents.dart'; import '../providers/session_mode.dart'; import '../providers/sessions.dart'; +import '../providers/visible_surface.dart'; import '../screens/terminal_screen.dart'; import '../util/ab_log.dart'; import '../util/device_id.dart'; @@ -107,11 +112,14 @@ class AgentPanel extends ConsumerWidget { // the breadcrumb — same convention as _SessionMark's use of // space12 in recent_session_row_widget.dart. const SizedBox(width: AbTokens.space12), - const Expanded(child: TitleBarBreadcrumb()), + // Branch pill folded into the overflow menu below: it lives + // inside the breadcrumb on desktop, but on a phone-width row it + // competes with the title for the one flexible slot. + const Expanded( + child: TitleBarBreadcrumb(showBranchPill: false), + ), const SizedBox(width: AbTokens.space6), - const SessionModeControl(), - const SizedBox(width: AbTokens.space8), - const HandlerHeaderControl(), + const _SessionOverflowButton(), ], ) else @@ -139,6 +147,152 @@ class AgentPanel extends ConsumerWidget { } } +/// Mobile-only overflow trigger for the branch pill, the terminal/chat switch +/// and the Handler shield/pill — see the comment above its call site in +/// [AgentPanel.build]. Fitting all three inline left too little width for the +/// session title itself on a phone; folding them behind one kebab is what +/// gives the title (and its rename tap target) its room back. Desktop's +/// [AgentBar] keeps them inline — the context panel there is wide enough. +class _SessionOverflowButton extends StatelessWidget { + const _SessionOverflowButton(); + + @override + Widget build(BuildContext context) { + return Builder( + // AbCompactTapTargets: the toolbar row already owns its height, so the + // button's mobile tap-target inflation (24px visual -> 44px hit box) + // must not widen the box this anchors the popup to — without it the + // popup opened ~10px below where the icon actually sits, reading as a + // stray gap between the kebab and the menu instead of Chrome's flush + // hang-under. + builder: (anchor) => AbCompactTapTargets( + child: AbIconButton( + icon: AbIcons.more, + tooltip: 'Session options', + onTap: () => detached( + 'AgentPanel', + 'session overflow menu failed', + () => _open(anchor), + ), + ), + ), + ); + } + + Future _open(BuildContext anchor) async { + final anchorRect = abMenuAnchorRect(anchor); + if (anchorRect == null) return; + await showAbPanel( + context: anchor, + anchorRect: anchorRect, + width: 220, + // Tight, hanging right under the button — the Chrome kebab-menu look — + // rather than the wider 4px default gap other (non-adjacent) popups use. + gap: 2, + preferred: AbMenuPlacement.below, + builder: (_) => const _SessionOverflowMenu(), + ); + } +} + +/// The overflow popup's content: the branch as a menu header (Chrome's own +/// tab-context-menu convention — the thing the menu is ABOUT, named once at +/// the top) over two plain text rows, rather than the header's own +/// button/segmented-control chrome. [AbLiveMenuRow] is what a menu row that +/// has to watch a provider renders as — see its doc for why a static +/// [AbMenuItem] can't do this. +class _SessionOverflowMenu extends ConsumerWidget { + const _SessionOverflowMenu(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final branch = ref.watch(terminalStateProvider).value?.gitBranch; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (branch != null) AbMenuHeaderLabel(branch), + const SessionModeMenuItem(), + const _HandlerMenuItem(), + ], + ); + } +} + +/// [HandlerHeaderControl]'s arm/disarm action, redone as a single text row — +/// the pending-escalation pill it also carries is a status surface (still +/// reachable from the Handler tab and the transcript's own away-hint/PA bar), +/// not an action, so a Chrome-style action menu doesn't restate it. +class _HandlerMenuItem extends ConsumerWidget { + const _HandlerMenuItem(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final activeId = ref.watch(activeSessionIdProvider); + if (activeId == null) return const SizedBox.shrink(); + + final state = + ref.watch(handlerStateProvider).value ?? const HandlerState.initial(); + final armed = state.sessions[activeId] != null; + final service = serviceWhenReady(ref, handlerServiceProvider); + // Pre-arm coverage prediction — same shared derivation the header shield + // and the away hint use, so this row never disagrees with them. + final coverage = ref.watch(focusedSessionCoverageProvider); + + void toggleArm() { + if (service == null) return; + // The popup route closes first — its own doc says the content pops + // itself — or the menu stays up over the session it just changed, + // showing the stale label, and the explainer below opens over a live + // modal barrier. Both the explainer's anchor and the container are taken + // from surfaces that outlive the popped route. + final navigator = Navigator.of(context); + final host = navigator.context; + final container = ref.container; + navigator.pop(); + if (armed) { + service.disarm(activeId); + return; + } + // Fire-and-forget: past the explainer await everything runs on the + // container, never this widget's ref — same contract as + // HandlerHeaderControl.toggleArm. + unawaited( + armWithSheet( + context: host, + container: container, + terminalId: activeId, + agentObservable: coverage.observable, + agentLabel: coverage.agentLabel, + judgeCapable: coverage.judgeCapable, + ), + ); + } + + // A refusal outranks the coverage notice, the order the header shield's + // tooltip already uses: coverage describes what an arm would get, and a + // refusal decides whether one can happen at all. + String? tooltip() { + if (armed) return null; + final entitlement = state.entitlement; + if (entitlement != null) return handlerEntitlementNotice(entitlement); + // Arming an unwatchable agent still works (it just never leaves + // WATCHING) — the tooltip explains why rather than blocking the tap. + if (coverage.observable == false) { + return unwatchableNotice(coverage.agentLabel); + } + return null; + } + + return AbLiveMenuRow( + label: armed ? 'Disarm Handler' : 'Arm Handler', + icon: AbIcons.shield, + tooltip: tooltip(), + onTap: toggleArm, + ); + } +} + /// The agent panel's desktop header, mirroring `WorkspaceTabBar` across the /// resizable divider: same height, same background, so the two read as one /// continuous strip. @@ -390,9 +544,15 @@ class HandlerHeaderControl extends ConsumerWidget { } // Surface escalations on OTHER sessions even when the focused session is // armed — an unanswered question must never hide behind this session's - // WATCHING/HANDLING pill. When the focused session itself needs the user its - // own count already shows; when it's unarmed, otherPending is the full + // WATCHING/HANDLING pill. When it's unarmed, otherPending is the full // project-wide count (session-null case). + // + // Yields to the focused session when that session is itself waiting: this + // pill is one label and cannot name two sessions, and the tab it opens + // renders only the focused one, so a merged count would send the user + // somewhere that cannot account for it. What the siblings get instead is a + // count on their own drawer rows (`SessionHandlerBadge`) — the surface + // that survives whatever is in focus. final otherPending = state.pendingEscalations - (session?.pendingEscalations ?? 0); if (session?.runState != HandlerRunState.needsYou && otherPending > 0) { @@ -401,6 +561,49 @@ class HandlerHeaderControl extends ConsumerWidget { pillNavigates = true; } + // The Handler tab shows the FOCUSED session only, so a pill counting + // another session has to move focus there or it reveals an empty tab — the + // one navigation this pill exists to make. + // + // The target comes from the SAME branch that wrote the label. A pill + // carrying the focused session's own count must never move focus at all; + // `state.escalations` is banded by urgency across the whole project, so its + // head belongs to whichever session escalated most urgently — take it and a + // tap on "your session needs you" switches the user's entire workspace to + // someone else's. + // + // A focus change cannot reveal the tab by calling: moving focus arms + // WorkspaceShell's per-session UI restore, which re-applies the TARGET + // session's own saved workspace tab from a post-frame callback, and any tab + // selected before that lands is silently undone by it. So the destination + // is handed over as pending state instead — the same handover a deep link + // naming a view uses, drained by the shell after the restore. + void openHandler() { + final waiting = session?.runState == HandlerRunState.needsYou + ? null + : state.escalations + .firstWhereOrNull((e) => e.terminalId != activeId) + ?.terminalId; + if (waiting == null) { + ref.read(revealHandlerTabProvider)?.call(); + return; + } + ref.read(activeSessionIdProvider.notifier).set(waiting); + // Read back rather than assumed: `ActiveSessionId.set` REFUSES a session + // the bridge is already deleting, and such a session keeps its replayed + // escalations for the seconds before its row goes. Focus then stays put, + // and the handover below would stamp the session still in focus with a + // destination picked for a different one. + if (ref.read(activeSessionIdProvider) != waiting) { + ref.read(revealHandlerTabProvider)?.call(); + return; + } + ref.read(pendingWorkspaceViewProvider.notifier).set(( + target: ref.read(selectedTargetProvider), + value: WorkspaceView.handler, + )); + } + // A NEEDS YOU pill is a call to action, so it navigates to the Handler // tab where the question is answerable; WATCHING/HANDLING are pure // status and stay inert. @@ -411,7 +614,7 @@ class HandlerHeaderControl extends ConsumerWidget { cursor: SystemMouseCursors.click, child: GestureDetector( behavior: HitTestBehavior.opaque, - onTap: () => ref.read(revealHandlerTabProvider)?.call(), + onTap: openHandler, child: AbChip.system(label: pillLabel, color: pillColor), ), ) @@ -423,22 +626,23 @@ class HandlerHeaderControl extends ConsumerWidget { return pill ?? const SizedBox.shrink(); } - // Arming is one tap and this control composes no payload — no backlog, no - // judge override. Everything the session needs is either already stored on - // the bridge or extracted behind the handoff, so sending any of those keys - // here would overwrite state this control never showed. - // The goal is the exception and is not composed here either: - // armWithFirstRunExplainer carries the session's own opening prompt. + // This control composes no payload of its own — no backlog, no judge + // override, no posture. Everything the session needs is either already + // stored on the bridge or extracted behind the handoff, so sending any of + // those keys here would overwrite state this control never showed. The arm + // sheet is where a payload can come from, and it sends only what the user + // moved on it. The goal is not composed here either: + // armWithSheet carries the session's own opening prompt. void toggleArm() { if (service == null) return; if (session != null) { service.disarm(activeId); return; } - // Fire-and-forget: past the explainer await everything runs on the + // Fire-and-forget: past the sheet await everything runs on the // container, never this widget's ref, and nothing in the flow can throw. unawaited( - armWithFirstRunExplainer( + armWithSheet( context: context, container: ref.container, terminalId: activeId, @@ -454,6 +658,10 @@ class HandlerHeaderControl extends ConsumerWidget { observable: coverage.observable, judgeCapable: coverage.judgeCapable, agentLabel: coverage.agentLabel, + // The refusal is answerable before the press, and this is the only + // surface that answers before one: the sheet says the same thing, but + // only once the user has committed far enough to open it. + entitlement: state.entitlement, ); return Row( diff --git a/app/lib/widgets/diff_viewer.dart b/app/lib/widgets/diff_viewer.dart index e5da14dd..73cd3544 100644 --- a/app/lib/widgets/diff_viewer.dart +++ b/app/lib/widgets/diff_viewer.dart @@ -2,6 +2,7 @@ import 'dart:math' as math; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart' show SelectedContent; import '../design/ab_icons.dart'; import '../design/ab_tokens.dart'; @@ -9,8 +10,10 @@ import '../design/ab_colors.dart'; import '../design/widgets/ab_diff_stat.dart'; import '../design/widgets/ab_empty_state.dart'; import '../design/widgets/ab_icon_button.dart'; +import '../util/detached.dart'; import 'code_syntax.dart'; import 'git_status_color.dart'; +import 'send_to_agent_comment.dart'; /// Parsed representation of a single diff hunk. class _DiffHunk { @@ -151,6 +154,15 @@ class DiffViewer extends StatefulWidget { final VoidCallback onViewFile; final VoidCallback onClose; + /// Delivers a composed "Send to Agent" message (comment + source label + + /// selected code, already put together by [showSendToAgentComment]) to the + /// focused session. Kept as a plain callback rather than reading Riverpod + /// here directly — this widget stays a dumb, provider-free presentation + /// component (matching [onViewFile]/[onClose]) and the one call site + /// (`git_panel.dart`) supplies the routing. + final Future Function(BuildContext context, String message) + onSendToAgent; + const DiffViewer({ super.key, required this.path, @@ -160,6 +172,7 @@ class DiffViewer extends StatefulWidget { required this.deletions, required this.onViewFile, required this.onClose, + required this.onSendToAgent, }); @override @@ -215,6 +228,13 @@ class _DiffViewerState extends State { final ScrollController _horizontal = ScrollController(); final ScrollController _vertical = ScrollController(); + /// The last non-empty selection, tracked from [SelectionArea.onSelectionChanged] + /// — `SelectableRegionState` exposes no public getter for the live content, + /// only the anchors/button-items the toolbar itself needs, so this is the + /// only way [_buildContextMenu]'s "Send to Agent" button can read what was + /// actually selected. + SelectedContent? _lastSelection; + late List<_DiffRow> _rows; late List<_DiffHunk> _hunks; late double _codeWidth; @@ -390,51 +410,63 @@ class _DiffViewerState extends State { // // Vertical stays visible and horizontal fades with use, which is how // re_editor builds the file viewer's pair. - return RawScrollbar( - controller: _vertical, - notificationPredicate: (n) => n.depth == 1, - scrollbarOrientation: ScrollbarOrientation.right, - thickness: _scrollbarThickness, - radius: _scrollbarRadius, - crossAxisMargin: _scrollbarMargin, - thumbVisibility: true, + // + // SelectionArea makes every line's code text (never the gutter/marker + // columns — see [_buildGutter]/[_buildMarker]) selectable and + // copyable, the same as a real editor. [_buildContextMenu] is what + // turns that into "Send to Agent": Flutter anchors the toolbar it + // returns to the selection itself (the platform's own copy/paste + // popup mechanism), so the action appears wherever the user actually + // selected rather than at a fixed button elsewhere in the panel. + return SelectionArea( + contextMenuBuilder: _buildContextMenu, + onSelectionChanged: (content) => _lastSelection = content, child: RawScrollbar( - controller: _horizontal, - notificationPredicate: (n) => n.depth == 0, - scrollbarOrientation: ScrollbarOrientation.bottom, + controller: _vertical, + notificationPredicate: (n) => n.depth == 1, + scrollbarOrientation: ScrollbarOrientation.right, thickness: _scrollbarThickness, radius: _scrollbarRadius, crossAxisMargin: _scrollbarMargin, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, + thumbVisibility: true, + child: RawScrollbar( controller: _horizontal, - child: SizedBox( - width: math.max(constraints.maxWidth, contentWidth), - child: ListView.builder( - controller: _vertical, - itemCount: _rows.length, - itemExtent: _rowHeight, - // The listener rides on each ROW, not on the list: a pointer - // signal goes to the FIRST registrant in hit-test order, - // which runs innermost-first, so only a node below the - // vertical Scrollable can take an event away from it. See - // [_onPointerSignal]. - itemBuilder: (context, index) => Listener( - // Opaque, or the row only claims the pixels its text and - // gutter actually paint: the gap between them, and every - // column past the end of a short line, hit-tests through - // to the vertical list, which is exactly where a sideways - // scroll starts creeping up and down again. - behavior: HitTestBehavior.opaque, - onPointerSignal: _onPointerSignal, - child: switch (_rows[index]) { - _HunkHeaderRow(:final text) => _buildHunkHeader( - context, - text, - ), - _HunkGapRow() => _buildHunkGap(context), - _CodeRow(:final line) => _buildLine(context, line), - }, + notificationPredicate: (n) => n.depth == 0, + scrollbarOrientation: ScrollbarOrientation.bottom, + thickness: _scrollbarThickness, + radius: _scrollbarRadius, + crossAxisMargin: _scrollbarMargin, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + controller: _horizontal, + child: SizedBox( + width: math.max(constraints.maxWidth, contentWidth), + child: ListView.builder( + controller: _vertical, + itemCount: _rows.length, + itemExtent: _rowHeight, + // The listener rides on each ROW, not on the list: a pointer + // signal goes to the FIRST registrant in hit-test order, + // which runs innermost-first, so only a node below the + // vertical Scrollable can take an event away from it. See + // [_onPointerSignal]. + itemBuilder: (context, index) => Listener( + // Opaque, or the row only claims the pixels its text and + // gutter actually paint: the gap between them, and every + // column past the end of a short line, hit-tests through + // to the vertical list, which is exactly where a sideways + // scroll starts creeping up and down again. + behavior: HitTestBehavior.opaque, + onPointerSignal: _onPointerSignal, + child: switch (_rows[index]) { + _HunkHeaderRow(:final text) => _buildHunkHeader( + context, + text, + ), + _HunkGapRow() => _buildHunkGap(context), + _CodeRow(:final line) => _buildLine(context, line), + }, + ), ), ), ), @@ -615,38 +647,90 @@ class _DiffViewerState extends State { ); } + // Excluded from the SelectionArea (see [_buildBody]): a +/- marker is + // layout, not content, and copying it in front of every line would corrupt + // the code it's pasted back into. Widget _buildMarker(_DiffLineType type, Color color) { return SizedBox( width: _markerWidth, - child: Text( - switch (type) { - _DiffLineType.addition => '+', - _DiffLineType.deletion => '-', - _DiffLineType.context => '', - }, - maxLines: 1, - style: AbTokens.monoStyle( - fontSize: AbTokens.fontXs, - height: kCodeFontHeight, - color: color, + child: SelectionContainer.disabled( + child: Text( + switch (type) { + _DiffLineType.addition => '+', + _DiffLineType.deletion => '-', + _DiffLineType.context => '', + }, + maxLines: 1, + style: AbTokens.monoStyle( + fontSize: AbTokens.fontXs, + height: kCodeFontHeight, + color: color, + ), ), ), ); } + // Excluded from the SelectionArea for the same reason as the marker: a line + // number is a reference for reading the diff, not text anyone selecting the + // code beside it wants in their clipboard. Widget _buildGutter(int? lineNum, Color color) { return SizedBox( width: _gutterWidth, - child: Text( - lineNum?.toString() ?? '', - textAlign: TextAlign.right, - maxLines: 1, - style: AbTokens.monoStyle( - fontSize: AbTokens.fontXs, - height: kCodeFontHeight, - color: color, + child: SelectionContainer.disabled( + child: Text( + lineNum?.toString() ?? '', + textAlign: TextAlign.right, + maxLines: 1, + style: AbTokens.monoStyle( + fontSize: AbTokens.fontXs, + height: kCodeFontHeight, + color: color, + ), ), ), ); } + + /// Adds "Send to Agent" to the platform's own selection toolbar rather than + /// floating a separate button: [selectableRegionState.contextMenuAnchors] + /// is the same anchor Copy/Select All render from, so the action shows up + /// exactly where the user made the selection instead of a fixed spot + /// elsewhere in the panel — and disappears with the rest of the toolbar + /// once they tap elsewhere, the same as any other selection action. + Widget _buildContextMenu( + BuildContext context, + SelectableRegionState selectableRegionState, + ) { + final buttonItems = [ + ...selectableRegionState.contextMenuButtonItems, + ContextMenuButtonItem( + label: 'Send to Agent', + onPressed: () { + final text = _lastSelection?.plainText; + selectableRegionState.hideToolbar(); + if (text == null || text.trim().isEmpty) return; + detached( + 'DiffViewer', + 'send selection to agent', + () => _sendSelectionToAgent(text), + ); + }, + ), + ]; + return AdaptiveTextSelectionToolbar.buttonItems( + anchors: selectableRegionState.contextMenuAnchors, + buttonItems: buttonItems, + ); + } + + Future _sendSelectionToAgent(String selectedText) async { + final message = await showSendToAgentComment( + context: context, + selectedText: selectedText, + sourceLabel: '[from diff: ${widget.path}]', + ); + if (message == null || !mounted) return; + await widget.onSendToAgent(context, message); + } } diff --git a/app/lib/widgets/drawer_entry_row.dart b/app/lib/widgets/drawer_entry_row.dart index 5074a57c..27136acf 100644 --- a/app/lib/widgets/drawer_entry_row.dart +++ b/app/lib/widgets/drawer_entry_row.dart @@ -16,6 +16,7 @@ import '../design/widgets/ab_disclosure_chevron.dart'; import '../design/widgets/ab_icon.dart'; import '../design/widgets/ab_icon_button.dart'; import '../design/widgets/ab_list_row.dart'; +import '../design/widgets/ab_row_trailing.dart'; import '../design/widgets/ab_separator.dart'; import '../design/widgets/ab_chip.dart'; import '../design/widgets/ab_snack_bar.dart'; @@ -77,8 +78,9 @@ class DrawerEntryRow extends ConsumerStatefulWidget { /// under — the chip was approximating that, and repeating it on the band that /// already names the machine says nothing. /// -/// Stateless: the only per-row UI state is hover, owned by [HoverableDrawerRow]. -class MachineDrawerHeaderRow extends ConsumerWidget { +/// Stateful for the two reveal bits a pointer cannot supply: keyboard focus, +/// and the latch a confirm dialog holds while it is up. +class MachineDrawerHeaderRow extends ConsumerStatefulWidget { final DrawerEntry entry; /// Hairline above, separating this machine's block from whatever precedes @@ -89,40 +91,72 @@ class MachineDrawerHeaderRow extends ConsumerWidget { const MachineDrawerHeaderRow(this.entry, {super.key, this.showRule = true}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => + _MachineDrawerHeaderRowState(); +} + +class _MachineDrawerHeaderRowState + extends ConsumerState { + bool _focused = false; + bool _latched = false; + + void _setLatched(bool v) { + if (!mounted || _latched == v) return; + setState(() => _latched = v); + } + + void _setFocused(bool v) { + if (!mounted || _focused == v) return; + setState(() => _focused = v); + } + + @override + Widget build(BuildContext context) { perfRecorder.noteDrawerRebuild(); + final entry = widget.entry; final machineUuid = entry.machineUuid!; final expanded = ref.watch(expandedDrawerIdsProvider).contains(machineUuid); + final offersRemove = _RemoveButton.offersFor(ref, entry); return HoverableDrawerRow( - above: showRule ? const DrawerBandRule() : null, - builder: (context, hovered, _) => DrawerBand( - label: entry.displayName, - // Kept on the band, unlike the local one: expanding a machine is what - // opens its control-plane socket, so there is something to disclose. - expanded: expanded, - // Status dots after the hover actions, and the LIVENESS dot last of - // all: it is [LocalMachineBand]'s host dot under another name, and only - // the final slot sits a fixed distance from the row's edge on both - // bands, so only there can the two share a column. Every dot reserves - // its slot whether or not it renders — one resolving must not slide the - // trash that shares this row out from under the pointer. - trailing: Row( - mainAxisSize: MainAxisSize.min, - spacing: AbTokens.space4, - children: [ + above: widget.showRule ? const DrawerBandRule() : null, + builder: (context, hovered, _) { + final revealed = hovered || _focused || _latched; + return DrawerBand( + label: entry.displayName, + // Kept on the band, unlike the local one: expanding a machine is what + // opens its control-plane socket, so there is something to disclose. + expanded: expanded, + // A band's liveness dot lives at the panel edge permanently, so the + // action shares its cell rather than claiming one of its own: a + // second cell would push the trash a slot inboard of every other + // row's, and collapsing the action would slide the dot on + // pointer-enter. [_DrawerEntryTrailing] emits no actions here for the + // same reason — two owners of one cell is a fight. + trailing: AbRowTrailingCell.kit([ _DrawerEntryTrailing( entry: entry, - hovered: hovered, + revealed: revealed, showRemoteChip: false, + hostsActions: false, ), _MachineAggregateDot(machineUuid: machineUuid), - _MachineOnlineDot(machineUuid: machineUuid), - ], - ), - onTap: () => - ref.read(expandedDrawerIdsProvider.notifier).toggle(machineUuid), - ), + if (offersRemove) + AbRowTrailingSwap( + revealed: revealed, + resting: _MachineOnlineDot(machineUuid: machineUuid), + action: _RemoveButton(entry: entry, onLatch: _setLatched), + ) + else + AbRowTrailingCell( + child: _MachineOnlineDot(machineUuid: machineUuid), + ), + ]), + onFocusChange: _setFocused, + onTap: () => + ref.read(expandedDrawerIdsProvider.notifier).toggle(machineUuid), + ); + }, ); } } @@ -162,9 +196,11 @@ class LocalMachineBand extends ConsumerWidget { // a desktop that opened a project earlier in the session that is a // live green dot pinned to a project it has nothing to do with. Every // other real-source surface in this drawer is gated the same way. - trailing: ref.watch(demoModeProvider) - ? null - : const _LocalHostDot(), + // The empty cell stays, so the demo band's title ellipsizes where + // the real one's does instead of running a cell further right. + trailing: AbRowTrailingCell( + child: ref.watch(demoModeProvider) ? null : const _LocalHostDot(), + ), ), ], ), @@ -209,24 +245,16 @@ class _LocalHostDot extends ConsumerWidget { HostPhase.failed => (AbStatusTone.danger, AbDotStyle.filled, false), _ => (null, AbDotStyle.filled, false), }; - if (tone == null) return const _BandDotSlot(); - return _BandDotSlot( - child: AbStatusDot(tone: tone, style: style, pulse: pulse), - ); + if (tone == null) return const SizedBox.shrink(); + return AbStatusDot(tone: tone, style: style, pulse: pulse); } } -/// One status-dot cell in a band's trailing kit. +/// One INNER status-dot cell in a band's trailing kit. /// -/// The width is reserved whether or not a dot renders. Two things depend on -/// that. A band's trailing is right-anchored, so an empty cell that collapsed -/// would drag everything to its left — including the hover-revealed trash, -/// which would then slide out from under the pointer whenever a socket resolved -/// or an agent asked a question. And because the cell is a constant width, the -/// LAST one is a constant distance from the row's edge on every band, which is -/// what lets [LocalMachineBand]'s host dot and a machine band's liveness dot -/// share a column. Gaps belong to the composing [Row]'s `spacing`, so the -/// alignment is not three widgets independently agreeing on an inset. +/// The width is reserved whether or not a dot renders, so a socket resolving or +/// an agent asking a question cannot widen this cell and shove the terminal +/// [AbRowTrailingCell] outboard of the column it shares with every other row. /// /// The reserved width is a floor, not a cap: every dot here is [AbDotSize.sm] /// today, and a tight box would silently paint a larger one as a squashed @@ -261,6 +289,7 @@ class DrawerBand extends StatelessWidget { this.trailing, this.expanded, this.onTap, + this.onFocusChange, }); final String label; @@ -270,6 +299,10 @@ class DrawerBand extends StatelessWidget { final bool? expanded; final VoidCallback? onTap; + /// How a band learns it is reachable by keyboard, so a hover-revealed action + /// can be revealed by focus too. + final ValueChanged? onFocusChange; + @override Widget build(BuildContext context) { final t = context.antgrid; @@ -298,6 +331,10 @@ class DrawerBand extends StatelessWidget { trailing: trailing, density: AbRowDensity.sm, horizontalPadding: 0, + // On the band rather than on [MachineDrawerHeaderRow], so the local band + // — which reveals nothing and would otherwise sit shorter — measures the + // same as a machine band beside it. + contentFloor: AbRowContentFloor.iconButton, // Bands sit in a run of rows that all clear each other by this much; a // band with no rule above it has nothing else keeping it off them. margin: const EdgeInsets.symmetric(vertical: AbTokens.space2), @@ -308,6 +345,7 @@ class DrawerBand extends StatelessWidget { // flat run where the fill just tracks the pointer, so it ranks nothing // above its neighbours. onTap: onTap, + onFocusChange: onFocusChange, ); } } @@ -388,6 +426,18 @@ class _HoverableDrawerRowState extends State { class _DrawerEntryRowState extends ConsumerState { Timer? _prefetchTimer; + bool _focused = false; + bool _latched = false; + + void _setLatched(bool v) { + if (!mounted || _latched == v) return; + setState(() => _latched = v); + } + + void _setFocused(bool v) { + if (!mounted || _focused == v) return; + setState(() => _focused = v); + } void _startPrefetch() { _prefetchTimer?.cancel(); @@ -439,12 +489,15 @@ class _DrawerEntryRowState extends ConsumerState { title: Text(entry.displayName, style: drawerProjectTitleStyle(context)), trailing: _DrawerEntryTrailing( entry: entry, - hovered: hovered, + revealed: hovered || _focused || _latched, expanded: expanded, + onLatch: _setLatched, ), density: AbRowDensity.sm, horizontalPadding: 0, // gutter lives on the outer Padding + contentFloor: AbRowContentFloor.iconButton, margin: const EdgeInsets.symmetric(vertical: AbTokens.space2), + onFocusChange: _setFocused, onTap: () => machineUuid != null ? ref.read(expandedDrawerIdsProvider.notifier).toggle(machineUuid) : ref.read(collapsedDrawerIdsProvider.notifier).toggle(entry.id), @@ -521,13 +574,17 @@ Color _leadingTint(BuildContext context, bool isWarm) { class _DrawerEntryTrailing extends ConsumerWidget { const _DrawerEntryTrailing({ required this.entry, - required this.hovered, + required this.revealed, this.expanded, this.showRemoteChip = true, + this.hostsActions = true, + this.onLatch, }); final DrawerEntry entry; - final bool hovered; + + /// Hover, keyboard focus, or a confirm dialog this row's own trash has open. + final bool revealed; /// Non-null on a PROJECT row, which shows a rollup of its sub-tree while /// collapsed. Null on a machine band, which has [_MachineAggregateDot]. @@ -537,56 +594,78 @@ class _DrawerEntryTrailing extends ConsumerWidget { /// that it is remote adds nothing. final bool showRemoteChip; + /// False on a machine band, whose [AbRowTrailingSwap] owns the actions. Two + /// owners of one terminal cell would each claim to be outermost. + final bool hostsActions; + + /// Held true while a revealed action is still working — the trash's confirm + /// dialog, the plus's create/start — so the row it was revealed from does not + /// collapse out from under it, taking the button's own in-flight state with + /// it. + final ValueChanged? onLatch; + @override Widget build(BuildContext context, WidgetRef ref) { final statusAsync = ref.watch(projectStatusProvider(entry.id)); final status = statusAsync.value ?? const ProjectStatus.empty(); - return Row( - mainAxisSize: MainAxisSize.min, - spacing: AbTokens.space4, - children: [ - if (status.configError) - _ErrorDot( - key: ValueKey('drawer-error-dot-${entry.id}'), - message: status.configErrorMessage, - ), - if (status.activeCommandName != null) - _CommandIndicator( - key: ValueKey('drawer-cmd-indicator-${entry.id}'), - commandName: status.activeCommandName!, - ), - // A collapsed project still says whether something inside it needs - // the user — that is a call to action, and the sessions that would - // carry it are off screen. It does NOT say how many sessions it holds: - // a count is a number to read rather than a state to notice, and the - // drawer is scanned. - if (expanded == false) DrawerProjectAggregateDot(entryId: entry.id), - if (showRemoteChip && entry.kind == EntryKind.remote) - AbChip.system(label: 'REMOTE', color: context.antgrid.accent), - // Hover-only affordances; kept in the tree via Visibility so layout - // doesn't jitter on pointer-enter. `_RemoveButton` decides for itself - // whether it has anything to offer — see its doc for the two cases it - // withholds the trash. - Visibility( - visible: hovered, - maintainState: true, - maintainAnimation: true, - maintainSize: true, - child: Row( - mainAxisSize: MainAxisSize.min, - spacing: AbTokens.space4, - children: [ - // No per-machine "New session" +: a machine is a container, not a - // project, so a session must name a project. The + lives on each - // advertised project row instead (see `_AdvertisedProjectRow`). - if (entry.machineUuid == null) _NewSessionButton(entry: entry), - _RemoveButton(entry: entry), - ], - ), + final actions = hostsActions && revealed; + // Asked unconditionally, ahead of `actions`: behind the `&&` the watch it + // performs would be retired every time the row un-reveals and re-added on + // the next hover, and `MachineDrawerHeaderRow` already asks it that way — + // one predicate must not have two subscription lifetimes. + final offersRemove = _RemoveButton.offersFor(ref, entry) && actions; + // No per-machine "New session" +: a machine is a container, not a project, + // so a session must name a project. The + lives on each advertised project + // row instead (see `_AdvertisedProjectRow`). + final offersNewSession = actions && entry.machineUuid == null; + + final cells = [ + if (status.configError) + _ErrorDot( + key: ValueKey('drawer-error-dot-${entry.id}'), + message: status.configErrorMessage, ), - ], - ); + if (status.activeCommandName != null) + _CommandIndicator( + key: ValueKey('drawer-cmd-indicator-${entry.id}'), + commandName: status.activeCommandName!, + ), + // A collapsed project still says whether something inside it needs the + // user — that is a call to action, and the sessions that would carry it + // are off screen. It does NOT say how many sessions it holds: a count is + // a number to read rather than a state to notice, and the drawer is + // scanned. + if (expanded == false && + DrawerProjectAggregateDot.needsUser(ref, entry.id)) + DrawerProjectAggregateDot(entryId: entry.id), + if (showRemoteChip && entry.kind == EntryKind.remote) + AbChip.system(label: 'REMOTE', color: context.antgrid.accent), + ]; + + // Actions outermost, and whatever ends up last carries the cell: the rail + // is a position in the row, not a property of any one glyph. + if (offersRemove || offersNewSession) { + if (offersNewSession) { + final plus = _NewSessionButton(entry: entry, onLatch: onLatch); + cells.add(offersRemove ? plus : AbRowTrailingCell(child: plus)); + } + if (offersRemove) { + cells.add( + AbRowTrailingCell( + child: _RemoveButton(entry: entry, onLatch: onLatch), + ), + ); + } + } else if (hostsActions && cells.isNotEmpty) { + // Only when this kit IS the row's outermost element. On a machine band it + // is nested inside one, and claiming a rail cell there would centre an + // 8px dot in a full button footprint in the MIDDLE of the band's kit. + cells.last = AbRowTrailingCell(child: cells.last); + } + + return AbRowTrailingCell.kit(cells, ownsColumn: hostsActions) ?? + const SizedBox.shrink(); } } @@ -603,12 +682,16 @@ class DrawerProjectAggregateDot extends ConsumerWidget { final String entryId; + /// Whether [entryId] has anything to say. The caller asks BEFORE building the + /// dot, because a trailing kit that drops absent children has to know they + /// are absent — a widget that shrinks itself away still occupies a slot and + /// its gap. + static bool needsUser(WidgetRef ref, String entryId) => + agentWorkStatusNeedsUser(ref.watch(projectWorkStatusProvider(entryId))); + @override - Widget build(BuildContext context, WidgetRef ref) { - final status = ref.watch(projectWorkStatusProvider(entryId)); - if (!agentWorkStatusNeedsUser(status)) return const SizedBox.shrink(); - return AgentWorkStatusDot(status: status); - } + Widget build(BuildContext context, WidgetRef ref) => + AgentWorkStatusDot(status: ref.watch(projectWorkStatusProvider(entryId))); } /// Trash affordance for removing a project/machine from history. @@ -626,24 +709,21 @@ class DrawerProjectAggregateDot extends ConsumerWidget { /// this is a guard rail, not a correctness fix. class _RemoveButton extends ConsumerStatefulWidget { final DrawerEntry entry; - const _RemoveButton({required this.entry}); - @override - ConsumerState<_RemoveButton> createState() => _RemoveButtonState(); -} + /// Held true while the confirm dialog is up. The row that revealed this + /// button collapses on pointer-exit, and the pointer leaves it the moment the + /// modal opens — without the latch the trash unmounts under its own dialog. + final ValueChanged? onLatch; -class _RemoveButtonState extends ConsumerState<_RemoveButton> { - // Self-disable while a confirmed removal's async teardown is in flight so a - // second tap can't re-enter `_confirmRemove` (matching `_NewSessionButton`). - bool _busy = false; + const _RemoveButton({required this.entry, this.onLatch}); - @override - Widget build(BuildContext context) { - final entry = widget.entry; - // Inventory agents have no locally-stored state to remove — hide the - // trash affordance entirely (they're managed server-side). - if (entry is InventoryAgentEntry) return const SizedBox.shrink(); - final isLocal = entry is LocalProjectEntry; + /// Whether [entry] has a trash to offer at all. Asked by the row rather than + /// answered by a self-shrinking build, because the trailing kit reserves the + /// outermost cell for whatever is genuinely last. + static bool offersFor(WidgetRef ref, DrawerEntry entry) { + // Inventory agents have no locally-stored state to remove (they're managed + // server-side). + if (entry is InventoryAgentEntry) return false; // LOCAL projects only. A legacy per-project REMOTE row also has a null // `machineUuid`, but its trash is "Forget agent" — the cheap, self-healing // drop of cached coordinates a machine band keeps unconditionally — so @@ -652,17 +732,32 @@ class _RemoveButtonState extends ConsumerState<_RemoveButton> { // // A project nobody has opened has an empty cache and so reads as empty // here; the confirm dialog is what covers that case, and it names what - // will be lost. Selected down to the bool: this widget is mounted for - // every drawer row, and the list identity changes on every - // `session:updated` of the focused project. - if (isLocal && + // will be lost. Selected down to the bool: this is asked for every drawer + // row, and the list identity changes on every `session:updated` of the + // focused project. + if (entry is LocalProjectEntry && ref.watch( sessionsForEntryProvider( entry.id, ).select((s) => s.any((e) => !e.archived)), )) { - return const SizedBox.shrink(); + return false; } + return true; + } + + @override + ConsumerState<_RemoveButton> createState() => _RemoveButtonState(); +} + +class _RemoveButtonState extends ConsumerState<_RemoveButton> { + // Self-disable while a confirmed removal's async teardown is in flight so a + // second tap can't re-enter `_confirmRemove` (matching `_NewSessionButton`). + bool _busy = false; + + @override + Widget build(BuildContext context) { + final isLocal = widget.entry is LocalProjectEntry; return AbIconButton( icon: AbIcons.trash, tooltip: isLocal ? 'Remove from history' : 'Forget agent', @@ -675,42 +770,51 @@ class _RemoveButtonState extends ConsumerState<_RemoveButton> { // Captured before the dialog await: the removal below must still run if the // drawer rebuilt this row away while the confirm was open. final container = ref.container; - final ok = await AbConfirmDialog.show( - context: context, - title: isLocal - ? 'Remove ${entry.displayName}?' - : 'Forget ${entry.displayName}?', - body: isLocal - ? removeLocalProjectBody(container, entry.id) - : 'This clears the cached sessions and connection details for ' - 'this machine. It comes back on its own while it is signed ' - 'in to your account.', - confirmLabel: isLocal ? 'Remove' : 'Forget', - destructive: true, - ); - if (!ok) return; - setState(() => _busy = true); + final onLatch = widget.onLatch; + onLatch?.call(true); try { - switch (entry) { - case LocalProjectEntry e: - // `ProjectsNotifier.remove` owns the local teardown: it stops the - // project's sessions/terminals, disposes its services + transport, - // then forgets the record. - await container.read(projectsProvider.notifier).remove(e.id); - case RemoteAgentEntry e: - await container - .read(machineConnectionProvider.notifier) - .forgetMachine(e.agent.agentDeviceId); - case InventoryAgentEntry _: - // Inventory agents are not stored locally — nothing to remove. - // The entry will disappear from the list when the account inventory - // is next refreshed or the device is deleted server-side. - break; + final ok = await AbConfirmDialog.show( + context: context, + title: isLocal + ? 'Remove ${entry.displayName}?' + : 'Forget ${entry.displayName}?', + body: isLocal + ? removeLocalProjectBody(container, entry.id) + : 'This clears the cached sessions and connection details for ' + 'this machine. It comes back on its own while it is signed ' + 'in to your account.', + confirmLabel: isLocal ? 'Remove' : 'Forget', + destructive: true, + ); + if (!ok) return; + // No `mounted` EARLY RETURN here, only a guarded setState: a confirmed + // destructive action runs off the captured container, and bailing out + // because the row was rebuilt away would turn a Yes into a silent no-op. + if (mounted) setState(() => _busy = true); + try { + switch (entry) { + case LocalProjectEntry e: + // `ProjectsNotifier.remove` owns the local teardown: it stops the + // project's sessions/terminals, disposes its services + transport, + // then forgets the record. + await container.read(projectsProvider.notifier).remove(e.id); + case RemoteAgentEntry e: + await container + .read(machineConnectionProvider.notifier) + .forgetMachine(e.agent.agentDeviceId); + case InventoryAgentEntry _: + // Inventory agents are not stored locally — nothing to remove. + // The entry will disappear from the list when the account inventory + // is next refreshed or the device is deleted server-side. + break; + } + } finally { + // The row is usually gone after removal (entry dropped from the + // drawer); guard the setState so we don't touch a disposed State. + if (mounted) setState(() => _busy = false); } } finally { - // The row is usually gone after removal (entry dropped from the drawer); - // guard the setState so we don't touch a disposed State. - if (mounted) setState(() => _busy = false); + onLatch?.call(false); } } } @@ -1029,7 +1133,16 @@ Future _openColdRemoteProject( /// double-tapping would otherwise spawn duplicate sessions. class _NewSessionButton extends ConsumerStatefulWidget { final DrawerEntry entry; - const _NewSessionButton({required this.entry}); + + /// Held true for as long as a tap is in flight. The row that revealed this + /// button collapses on pointer-exit, and a cold remote open runs for tens of + /// seconds — without the latch the button unmounts mid-activation, taking + /// [_NewSessionButtonState._busy] with it, so a re-hover and a second tap + /// launch a concurrent one. It is also what keeps the failure snackbar's + /// `mounted` check true. + final ValueChanged? onLatch; + + const _NewSessionButton({required this.entry, this.onLatch}); @override ConsumerState<_NewSessionButton> createState() => _NewSessionButtonState(); @@ -1048,11 +1161,14 @@ class _NewSessionButtonState extends ConsumerState<_NewSessionButton> { } Future _onTap() async { + final onLatch = widget.onLatch; setState(() => _busy = true); + onLatch?.call(true); try { await _newSessionForEntry(); } finally { if (mounted) setState(() => _busy = false); + onLatch?.call(false); } } @@ -1132,26 +1248,24 @@ class _MachineOnlineDot extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final status = ref.watch(supervisorStatusProvider(machineUuid)).value; - if (status == null) return const _BandDotSlot(); + if (status == null) return const SizedBox.shrink(); final (tone, label) = connectionDisplayInfo(status); final online = status is Connected; - return _BandDotSlot( - // Colour is this dot's only channel, and it is the drawer's sole report - // that a machine is unreachable — so the ladder's own label carries it to - // anyone who cannot use hue. - child: Semantics( - label: label, - child: AbStatusDot( - tone: tone, - style: online ? AbDotStyle.filled : AbDotStyle.hollow, - // Pulse only while the ladder is still climbing. Stated as a - // whitelist over the sealed type, so a fifth [SupervisorStatus] has - // to opt in here rather than inherit an animation nothing stops: both - // settled states must hold still, `Released` being a deliberate - // teardown and `Blocked` staying sticky until a typed unblock input - // clears it. - pulse: status is Climbing, - ), + // Colour is this dot's only channel, and it is the drawer's sole report + // that a machine is unreachable — so the ladder's own label carries it to + // anyone who cannot use hue. + return Semantics( + label: label, + child: AbStatusDot( + tone: tone, + style: online ? AbDotStyle.filled : AbDotStyle.hollow, + // Pulse only while the ladder is still climbing. Stated as a + // whitelist over the sealed type, so a fifth [SupervisorStatus] has + // to opt in here rather than inherit an animation nothing stops: both + // settled states must hold still, `Released` being a deliberate + // teardown and `Blocked` staying sticky until a typed unblock input + // clears it. + pulse: status is Climbing, ), ); } diff --git a/app/lib/widgets/file_tree_view.dart b/app/lib/widgets/file_tree_view.dart index b66ec59f..39a7462b 100644 --- a/app/lib/widgets/file_tree_view.dart +++ b/app/lib/widgets/file_tree_view.dart @@ -365,6 +365,14 @@ List _descendantEntries( return out; } +/// One row of the tree. +/// +/// The tree's trailing rule is COLLAPSE AND FLOOR, NO CELL: the git actions +/// are dropped from layout until the row is revealed, and the row's height is +/// anchored by [AbRowContentFloor] so mounting them shifts nothing. It takes +/// no shared trailing cell, unlike the drawer's rows — its outermost element +/// is a variable-width diff-stat badge inside a resizable pane, so there is no +/// fixed panel edge for a column to align against. class _FileTreeRow extends StatefulWidget { final FileNode node; final int depth; @@ -400,12 +408,15 @@ class _FileTreeRow extends StatefulWidget { State<_FileTreeRow> createState() => _FileTreeRowState(); } -// Hover-revealed actions, same convention as session_row.dart / -// drawer_entry_row.dart: mobile has no hover, so actions start visible; -// desktop reveals them only on hover. +// Reveal, same convention as session_row.dart / drawer_entry_row.dart: mobile +// has no pointer to reveal anything with, so its affordance bit starts true. class _FileTreeRowState extends State<_FileTreeRow> { late bool _hovered = isMobilePlatform; + /// Keyboard focus reveals too, or the actions would be unreachable without a + /// pointer once they are dropped from layout at rest. + bool _focused = false; + void _onEnter(PointerEnterEvent _) { if (isMobilePlatform) return; if (!_hovered && mounted) setState(() => _hovered = true); @@ -470,6 +481,18 @@ class _FileTreeRowState extends State<_FileTreeRow> { onUnstagePath != null || onDiscardPath != null || onResolvePath != null); + // What the ROW HEIGHT has to reserve, which is a question about the tree + // and not about this file: `hasActions` above is per-row, so floor-ing on + // it would let a row's height report whether that one path happens to be + // stageable. Touch mounts no buttons at all, and neither does a tree wired + // without git callbacks (the Files tab) — neither should pay a button's + // height on every row. + final reservesButtons = + showRowButtons && + (widget.onStage != null || + widget.onUnstage != null || + widget.onDiscard != null || + widget.onResolveConflict != null); // An OPEN directory carries no decoration of its own — in changesOnly mode // every directory left after pruning already implies a descendant changed, // so a dot on top of that is redundant noise. A folded one is the @@ -477,6 +500,7 @@ class _FileTreeRowState extends State<_FileTreeRow> { final hasDecoration = (!isDirectory && widget.changeEntries.isNotEmpty) || widget.rollupEntries.isNotEmpty; + final showActions = hasActions && (_hovered || _focused); Widget row = MouseRegion( cursor: widget.onTap != null @@ -490,6 +514,12 @@ class _FileTreeRowState extends State<_FileTreeRow> { selected: widget.isSelected, selectionStyle: AbRowSelection.surface, density: AbRowDensity.sm, + contentFloor: reservesButtons + ? AbRowContentFloor.iconButton + : AbRowContentFloor.none, + onFocusChange: (v) { + if (_focused != v && mounted) setState(() => _focused = v); + }, leading: Padding( padding: EdgeInsets.only(left: widget.depth * AbTokens.space16), child: isDirectory @@ -515,55 +545,50 @@ class _FileTreeRowState extends State<_FileTreeRow> { ), overflow: TextOverflow.ellipsis, ), - trailing: (hasActions || hasDecoration) + // Actions outermost, badge inboard: at rest the change count is the + // only tenant and sits flush at the gutter on every row, so the column + // it forms is what the eye scans. Only a revealed row's badge steps + // inboard, and only while the row is revealed. + trailing: (showActions || hasDecoration) ? Row( mainAxisSize: MainAxisSize.min, children: [ - if (hasActions) - Visibility( - // Reserved size (not just visibility) so the row never - // jitters width on hover — same technique - // session_row.dart uses for its hover-only kebab menu. - visible: _hovered, - maintainState: true, - maintainAnimation: true, - maintainSize: true, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (onStagePath != null) - AbIconButton( - icon: AbIcons.gitStage, - onTap: onStagePath, - tooltip: 'Stage Changes', - ), - if (onUnstagePath != null) - AbIconButton( - icon: AbIcons.gitUnstage, - onTap: onUnstagePath, - tooltip: 'Unstage Changes', - ), - if (onDiscardPath != null) - AbIconButton( - icon: AbIcons.revert, - onTap: onDiscardPath, - tooltip: 'Discard Changes', - ), - if (onResolvePath != null) - AbIconButton( - icon: AbIcons.check, - onTap: onResolvePath, - tooltip: 'Mark Resolved', - ), - ], - ), - ), - if (hasActions && hasDecoration) - const SizedBox(width: AbTokens.space4), if (hasDecoration) widget.rollupEntries.isNotEmpty ? _FolderRollupBadge(entries: widget.rollupEntries) : _DiffStatBadge(entries: widget.changeEntries), + if (showActions && hasDecoration) + const SizedBox(width: AbTokens.space4), + if (showActions) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (onStagePath != null) + AbIconButton( + icon: AbIcons.gitStage, + onTap: onStagePath, + tooltip: 'Stage Changes', + ), + if (onUnstagePath != null) + AbIconButton( + icon: AbIcons.gitUnstage, + onTap: onUnstagePath, + tooltip: 'Unstage Changes', + ), + if (onDiscardPath != null) + AbIconButton( + icon: AbIcons.revert, + onTap: onDiscardPath, + tooltip: 'Discard Changes', + ), + if (onResolvePath != null) + AbIconButton( + icon: AbIcons.check, + onTap: onResolvePath, + tooltip: 'Mark Resolved', + ), + ], + ), ], ) : null, diff --git a/app/lib/widgets/git_panel.dart b/app/lib/widgets/git_panel.dart index 5adc83e0..91f46d0f 100644 --- a/app/lib/widgets/git_panel.dart +++ b/app/lib/widgets/git_panel.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show Clipboard, ClipboardData; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../analytics/events.dart'; @@ -10,24 +11,45 @@ import '../design/widgets/ab_button.dart'; import '../design/widgets/ab_chip.dart'; import '../design/widgets/ab_confirm_dialog.dart'; import '../design/widgets/ab_diff_stat.dart'; +import '../design/widgets/ab_empty_state.dart'; import '../design/widgets/ab_icon.dart'; import '../design/widgets/ab_icon_button.dart'; +import '../design/widgets/ab_inline_banner.dart'; +import '../design/widgets/ab_list_row.dart'; +import '../design/widgets/ab_menu.dart'; +import '../design/widgets/ab_segmented.dart'; +import '../design/widgets/ab_snack_bar.dart'; import '../design/widgets/ab_tap_target.dart'; import '../design/widgets/ab_tooltip.dart'; import '../design/widgets/ab_loading.dart'; import '../design/widgets/ab_separator.dart'; -import '../models/ab_message.dart' show GitFileStatusEntry; +import '../models/ab_message.dart' + show GitFileStatusEntry, GitCommitFileEntry, GitLogEntry, GitStashEntry; +import '../models/git_sync_state.dart'; import '../models/file_tree_models.dart'; import '../navigation/back_intent.dart'; import '../providers/analytics.dart'; import '../providers/providers.dart'; import '../providers/visible_surface.dart'; import '../services/file_service.dart'; +import '../util/detached.dart'; +import '../util/relative_time.dart'; import '../widgets/workspace_tab_bar.dart'; import '../widgets/diff_viewer.dart'; import '../widgets/file_viewer_router.dart'; import '../widgets/file_tree_view.dart'; import '../widgets/git_commit_sheet.dart'; +import '../widgets/git_status_color.dart'; +import '../widgets/git_sync_failure_handoff.dart'; +import '../widgets/send_capture_to_agent.dart'; + +/// Anchors the Changes header's title row (diff totals + conflict chip) for +/// tests — it carries no text of its own (the header sits directly under the +/// panel's own "Git" workspace tab, which already says what this is), so a +/// test can no longer find it by a "Changes" label without also risking a +/// match against a file row's own diff-stat badge. +@visibleForTesting +const gitChangesHeaderTitleKey = Key('gitChangesHeaderTitle'); /// Standalone git-changes panel extracted from FileExplorerScreen. /// @@ -64,6 +86,8 @@ class _GitPanelState extends ConsumerState { // be recomputed when this tab goes on or off screen. final onScreen = ref.watch(visibleWorkspaceViewProvider) == WorkspaceView.git; + _maybeLoadHistory(fileService); + _maybeLoadStashes(fileService); // Loading/error keep the same header (no back affordance) so the panel // chrome doesn't jump when data arrives; the data case owns its own header @@ -76,12 +100,14 @@ class _GitPanelState extends ConsumerState { loading: () => _GitPanelScaffold( counts: counts, fileService: fileService, + git: git ?? GitPaneState.empty, collapsedPaths: collapsedPaths, body: const AbLoading(message: 'loading changes...'), ), error: (error, _) => _GitPanelScaffold( counts: counts, fileService: fileService, + git: git ?? GitPaneState.empty, collapsedPaths: collapsedPaths, body: Center( child: Text( @@ -95,6 +121,43 @@ class _GitPanelState extends ConsumerState { ); } + /// History has no consumer besides this panel — unlike the file tree or + /// sync state, both shown elsewhere too — so it is fetched lazily here + /// rather than eagerly for every `FileService` construction, which would + /// cost every project session a `git:log` round trip whether or not its Git + /// tab is ever opened. + /// + /// [FileService.claimHistoryLoad] (not a local flag) is what makes this + /// safe to call on every build: build() itself must stay free of the + /// [FileService.loadHistory] send (and the reply-timeout timer it arms), so + /// the actual call is deferred to a post-frame callback — and a widget can + /// legitimately build more than once before that callback runs and the + /// resulting `loadingMore` state change comes back around. The claim is + /// what keeps that window from firing the send twice. + void _maybeLoadHistory(FileService? fileService) { + if (fileService == null) return; + if (!fileService.claimHistoryLoad()) return; + // Deliberately NOT guarded on `mounted`: the claim is one-way for the + // SERVICE's lifetime, and the service outlives this panel. Skipping the + // send because the panel unmounted inside the frame (a view switch, a + // session switch) spends the claim with nothing sent, and history then + // sits on its "loading history..." placeholder forever — that is exactly + // the state a service which never asked reports, and nothing asks again. + // `loadHistory` touches no BuildContext; a disposed service drops it. + WidgetsBinding.instance.addPostFrameCallback((_) => fileService.loadHistory()); + } + + /// Same lazy, once-per-service-lifetime fetch as [_maybeLoadHistory], for + /// the stash banner's data — see [FileService.claimStashLoad]. + void _maybeLoadStashes(FileService? fileService) { + if (fileService == null) return; + if (!fileService.claimStashLoad()) return; + // Unguarded for the same reason as [_maybeLoadHistory], and it matters + // more here: nothing else in the app ever calls `loadStashes` again, so a + // spent claim with no send hides the stash banner for good. + WidgetsBinding.instance.addPostFrameCallback((_) => fileService.loadStashes()); + } + /// Steps out ONE level: the file opened from a diff, then the diff itself. /// Deliberately unlike the compact header's back button, which clears both at /// once because it means "return to the changes list". @@ -244,6 +307,11 @@ class _GitHeaderCounts { final Set changedFolders; } +/// How much of the panel the stash banners may claim before they scroll among +/// themselves — about three, leaving the changes list the rest. See where it is +/// used for why an unbounded run of them is a layout failure, not just noise. +const double _stashBannerMaxHeight = 132; + /// The shared git-panel chrome: header + separator + expanded body, defined /// once so the loading/error/data branches can't drift in how they wrap the /// header. [onBack] is forwarded to the header (only the compact diff-viewing @@ -253,6 +321,7 @@ class _GitPanelScaffold extends StatelessWidget { required this.counts, required this.fileService, required this.body, + this.git = GitPaneState.empty, this.collapsedPaths = const {}, this.onBack, }); @@ -260,6 +329,10 @@ class _GitPanelScaffold extends StatelessWidget { final _GitHeaderCounts counts; final FileService fileService; final Widget body; + + /// Whole pane state, for the parts of the header that are not derivable from + /// [counts]: the sync indicator and the failure strip. + final GitPaneState git; final Set collapsedPaths; final VoidCallback? onBack; @@ -270,9 +343,39 @@ class _GitPanelScaffold extends StatelessWidget { _GitChangesHeader( counts: counts, fileService: fileService, + git: git, collapsedPaths: collapsedPaths, onBack: onBack, ), + // Between the header and its rule so the offer sits with the control + // that produced it. A snackbar cannot carry an action and is gone in + // four seconds; this failure needs an affordance that waits. + if (git.lastSyncFailure case final failure?) + _SyncFailureStrip(failure: failure, git: git), + // Stashes persist across sessions and reconnects (the list is read + // fresh off `git stash list` every time — see [FileService.loadStashes]) + // so this stays up as long as any stash exists, not just right after + // the switch that created one. + // + // Bounded and scrollable rather than spread straight into this Column: + // the list is every stash in the REPOSITORY (shared across worktrees, + // and including any made outside Antgrid), so a developer with an + // ordinary stash habit stacked a dozen full-width banners above the + // changes list, squeezing it to nothing on desktop and overflowing the + // viewport outright on a phone. Every entry stays reachable. + if (git.stashes.isNotEmpty) + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: _stashBannerMaxHeight), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final stash in git.stashes) + _StashBanner(stash: stash, fileService: fileService), + ], + ), + ), + ), const AbSeparator.horizontal(), Expanded(child: body), ], @@ -294,12 +397,14 @@ class _GitChangesHeader extends StatelessWidget { const _GitChangesHeader({ required this.counts, required this.fileService, + this.git = GitPaneState.empty, this.collapsedPaths = const {}, this.onBack, }); final _GitHeaderCounts counts; final FileService fileService; + final GitPaneState git; final VoidCallback? onBack; /// Folders currently folded shut. Only used to decide which way the one @@ -366,7 +471,12 @@ class _GitChangesHeader extends StatelessWidget { /// it no longer names. Measured on the pane, not the window: a phone's full /// width clears it, a touch tablet's quarter-width context pane does not, /// which is the case this exists for. - static const double _stackedHeaderWidth = 360; + // + // The sync control adds two more fixed-width cells (and a count label) to the + // right half, so the budget the title is left with shrank by about that much + // — raised in step, because the failure this constant exists to prevent is a + // title ellipsised to nothing while the counts beside it stay whole. + static const double _stackedHeaderWidth = 460; @override Widget build(BuildContext context) { @@ -396,22 +506,38 @@ class _GitChangesHeader extends StatelessWidget { children: [ Row(children: _title(context, stacked: true)), const SizedBox(height: AbTokens.space4), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: _actions(context), - ), + // Column.stretch already hands this a bounded, tight width + // (no wrapping Row/Expanded needed) — see _actionsCluster. + _actionsCluster(context), ], ) : Row( children: [ ..._title(context, stacked: false), - ..._actions(context), + Expanded(child: _actionsCluster(context)), ], ), ), ); } + /// The action buttons, scrollable rather than overflowing when the row + /// can't hold them all — a touch tablet's docked context pane and a + /// desktop window at its minimum width both land under the width these + /// need. `reverse: true` is the trick ([ListView.reverse] does the same for + /// a short chat log): content smaller than the box still anchors to the + /// END, so Commit sits flush against the panel's right edge exactly as it + /// did when this was a fixed-width row, and only overflows into a scroll + /// — starting scrolled to Commit's end, never to Refresh's — once the + /// buttons genuinely don't fit. + Widget _actionsCluster(BuildContext context) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + reverse: true, + child: Row(mainAxisSize: MainAxisSize.min, children: _actions(context)), + ); + } + /// The title half of the header. Everything in it except the title text is /// fixed-width, so [stacked] — the narrow layout — is where the row runs out /// of line and something has to give. @@ -424,53 +550,69 @@ class _GitChangesHeader extends StatelessWidget { ), const SizedBox(width: AbTokens.space8), ], - // Expanded, not a Spacer: sharing the line with the actions, the title is - // the one thing here that can give room up (the tab it sits under already - // says Git); on its own row it is what pushes nothing to the right. - Expanded( - child: Row( - children: [ - Flexible( - child: Text( - 'Changes', - overflow: TextOverflow.ellipsis, - style: AbTokens.sansStyle(color: context.antgrid.textMuted), - ), + // Expanded, not a Spacer: sharing the line with the actions, this is the + // one thing here that can give room up; on its own row it is what pushes + // nothing to the right. + Expanded(child: _titleStats(context, stacked)), + ]; + + /// The diff stat is what yields, and only where it has to: on the narrow + /// header a merge is the one thing that fills the row (back button + + /// totals + conflict chip, none of them shrinkable), and of the two counts + /// it is the chip that has to survive — it is what explains the dead + /// Commit button beside it. The totals are still on the workspace menu, and + /// a merge's conflicts contribute 0 to them anyway. + Widget _titleStats(BuildContext context, bool stacked) { + final showStat = + (counts.additions > 0 || counts.deletions > 0) && + !(stacked && counts.conflictPaths.isNotEmpty); + final showConflictChip = counts.conflictPaths.isNotEmpty; + return Row( + key: gitChangesHeaderTitleKey, + children: [ + if (showStat) + AbDiffStat( + additions: counts.additions, + deletions: counts.deletions, + fontSize: AbTokens.fontXs, + ), + if (showConflictChip) ...[ + if (showStat) const SizedBox(width: AbTokens.space8), + // Beside the header's own totals, not down in the list: a conflict + // is why Commit beside it is dead, and a user who cannot see one + // without scrolling the tree reads that button as broken. + AbChip.system( + label: counts.conflictPaths.length == 1 + ? '1 conflict' + : '${counts.conflictPaths.length} conflicts', + color: context.antgrid.gitConflict, ), - // The diff stat is what yields, and only where it has to: on the - // narrow header a merge is the one thing that fills the row (back - // button + totals + conflict chip, none of them shrinkable), and of - // the two counts it is the chip that has to survive — it is what - // explains the dead Commit button beside it. The totals are still on - // the workspace menu, and a merge's conflicts contribute 0 to them - // anyway. - if ((counts.additions > 0 || counts.deletions > 0) && - !(stacked && counts.conflictPaths.isNotEmpty)) ...[ - const SizedBox(width: AbTokens.space8), - AbDiffStat( - additions: counts.additions, - deletions: counts.deletions, - fontSize: AbTokens.fontXs, - ), - ], - if (counts.conflictPaths.isNotEmpty) ...[ - const SizedBox(width: AbTokens.space8), - // Beside the header's own totals, not down in the list: a conflict - // is why Commit beside it is dead, and a user who cannot see one - // without scrolling the tree reads that button as broken. - AbChip.system( - label: counts.conflictPaths.length == 1 - ? '1 conflict' - : '${counts.conflictPaths.length} conflicts', - color: context.antgrid.gitConflict, - ), - ], ], - ), - ), - ]; + ], + ); + } + + /// Re-pulls everything the panel shows: the file tree (which, server-side, + /// forces a fresh git-status read alongside it — see the bridge's + /// `file:tree:snapshot:request` handler), the ahead/behind sync counts, and + /// the commit log. One button for all three: from here they read as one + /// picture of the repository, not three independently-stale ones. + void _refresh() { + fileService.requestFullTree(); + fileService.refreshSyncState(); + fileService.loadHistory(); + } List _actions(BuildContext context) => [ + SizedBox( + width: AbTokens.rowHeightSm, + child: AbIconButton( + icon: AbIcons.refresh, + tooltip: 'Refresh', + onTap: _refresh, + ), + ), + const SizedBox(width: AbTokens.space6), // Its own control, not a third cell in the group below: that group is the // two actions that WRITE to the tree, and a view toggle sharing their // border would read as one of them. It is also gated separately — a tree @@ -491,6 +633,13 @@ class _GitChangesHeader extends StatelessWidget { // toward it. Each cell is gated on its OWN scope for the same reason: a // tree of nothing but conflicts has nothing safe to revert, and is exactly // where Stage All is the way out. + // Left of the write group and outside it: those two act on the working + // tree, these two act on the branch's relationship to a remote. Sharing a + // border would read as one control. + if (git.sync.hasRemote) ...[ + _SyncControl(sync: git.sync, syncing: git.syncing, fileService: fileService), + const SizedBox(width: AbTokens.space6), + ], if (counts.hasChanges) ...[ _BulkActionGroup( children: [ @@ -581,6 +730,59 @@ class _CollapseToggle extends StatelessWidget { } } +/// The small inline header the History section carries at the bottom of the +/// left column (see [_GitPanelBody._buildFileList]) — a label plus a fold +/// toggle for expanded commits. No back affordance: unlike the top-level +/// [_GitChangesHeader], this never stands alone as the whole panel's chrome, +/// so there is never a "back to history" to offer. No write actions either — +/// nothing here mutates the working tree. +/// +/// No bulk "expand all" the way [_CollapseToggle] offers one for folders: +/// expanding a commit fetches its file list, so expanding every loaded +/// commit at once would fire one request per row for a list the user hasn't +/// scrolled to yet. +class _GitHistorySectionHeader extends StatelessWidget { + const _GitHistorySectionHeader({ + required this.fileService, + required this.history, + }); + + final FileService fileService; + final GitHistoryState history; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: AbTokens.space12, + vertical: AbTokens.space4, + ), + child: AbCompactTapTargets( + child: Row( + children: [ + Expanded( + child: Text( + 'History', + overflow: TextOverflow.ellipsis, + style: AbTokens.sansStyle(color: context.antgrid.textMuted), + ), + ), + if (history.expandedShas.isNotEmpty) + SizedBox( + width: AbTokens.rowHeightSm, + child: AbIconButton( + icon: AbIcons.collapseAll, + tooltip: 'Collapse All', + onTap: fileService.collapseAllHistory, + ), + ), + ], + ), + ), + ); + } +} + /// One cell of a [_BulkActionGroup]. `onTap: null` renders it disabled, /// keeping its slot in the group. class _BulkAction { @@ -605,10 +807,16 @@ class _BulkAction { /// winning) and the border turns what is left into surface the user can aim /// at, which is what the gap was always meant to be. class _BulkActionGroup extends StatelessWidget { - const _BulkActionGroup({required this.children}); + const _BulkActionGroup({required this.children, this.leading}); final List<_BulkAction> children; + /// Content shown INSIDE the border, before the first cell — the sync + /// control's counts. Inside rather than beside it because the counts label + /// those two buttons specifically; outside the border they read as another + /// free-floating mark in the header. + final Widget? leading; + @override Widget build(BuildContext context) { return DecoratedBox( @@ -620,8 +828,9 @@ class _BulkActionGroup extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ + ?leading, for (final (i, action) in children.indexed) ...[ - if (i > 0) + if (i > 0 || leading != null) const SizedBox( height: AbTokens.iconButtonBox, child: AbSeparator.vertical(), @@ -641,14 +850,226 @@ class _BulkActionGroup extends StatelessWidget { } } -class _GitPanelBody extends StatelessWidget { +/// Pull and Push, with the ahead/behind counts that answer "is this pushed?". +/// +/// Both cells stay mounted whenever the repository has a remote, even when one +/// of them has nothing to do — the same reasoning the bulk actions beside them +/// document: a control that vanishes the moment its count reaches zero moves +/// its neighbour under a finger already travelling toward it. +/// +/// The counts are as fresh as the last fetch (see [GitSyncState]), which is +/// what Pull is for. Nothing here probes the network on its own. +class _SyncControl extends StatelessWidget { + const _SyncControl({ + required this.sync, + required this.syncing, + required this.fileService, + }); + + final GitSyncState sync; + final GitSyncOp? syncing; + final FileService fileService; + + @override + Widget build(BuildContext context) { + // Both are disabled while either runs: they mutate the same branch, and a + // pull racing a push is a state neither result can describe. + final busy = syncing != null; + + // A branch that has never been pushed has nothing to pull and no counts to + // show — one action, named for what it does, matching VS Code. + if (sync.canPublish) { + return AbButton( + label: 'Publish Branch', + leading: AbIcon( + AbIcons.gitPush, + size: AbTokens.iconButtonGlyph, + color: context.antgrid.textMuted, + ), + onTap: busy ? null : fileService.push, + ); + } + + return _BulkActionGroup( + children: [ + _BulkAction( + icon: AbIcons.gitPull, + tooltip: sync.behind > 0 + ? 'Pull ${sync.behind} commit${sync.behind == 1 ? '' : 's'}' + : 'Pull', + onTap: (busy || !sync.canPull) ? null : fileService.pull, + ), + _BulkAction( + icon: AbIcons.gitPush, + tooltip: sync.ahead > 0 + ? 'Push ${sync.ahead} commit${sync.ahead == 1 ? '' : 's'}' + : 'Push', + onTap: (busy || !sync.canPush) ? null : fileService.push, + ), + ], + // Null, not an empty box, when there is nothing to say: the group draws + // its separator on the strength of `leading != null`, so a zero-width + // child would leave a rule with nothing in front of it. + leading: busy + ? const Padding( + padding: EdgeInsets.symmetric(horizontal: AbTokens.space6), + child: AbLoadingDot(size: AbTokens.fontXs), + ) + : (sync.ahead > 0 || sync.behind > 0 + ? _SyncCounts(sync: sync) + : null), + ); + } +} + +/// The up/down counts, inside the sync control's border so they read as its +/// label rather than as free-floating marks. +class _SyncCounts extends StatelessWidget { + const _SyncCounts({required this.sync}); + + final GitSyncState sync; + + @override + Widget build(BuildContext context) { + final colors = context.antgrid; + final style = AbTokens.monoStyle( + fontSize: AbTokens.fontXs, + color: colors.textMuted, + ); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: AbTokens.space6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (sync.behind > 0) ...[ + AbIcon(AbIcons.arrowDown, size: AbTokens.fontXs, color: colors.textMuted), + Text('${sync.behind}', style: style), + ], + if (sync.ahead > 0) ...[ + if (sync.behind > 0) const SizedBox(width: AbTokens.space4), + AbIcon(AbIcons.arrowUp, size: AbTokens.fontXs, color: colors.textMuted), + Text('${sync.ahead}', style: style), + ], + ], + ), + ); + } +} + +/// The strip a failed push or pull leaves behind, and the one tap that hands +/// it to the agent. +/// +/// It persists rather than auto-dismissing: the toast that already fired says +/// what happened, and this says what can be done about it — which is worth +/// nothing if it disappears while the user is still reading the toast. +class _SyncFailureStrip extends ConsumerWidget { + const _SyncFailureStrip({required this.failure, required this.git}); + + final GitSyncFailure failure; + final GitPaneState git; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return AbInlineBanner( + text: '${failure.op.label} failed — ${failure.message}', + color: context.antgrid.gitConflict, + trailing: failure.warrantsAgent + ? AbButton( + label: 'Ask agent to fix', + // A tap handler discards the future it starts, so a rejection + // inside the dialog or the send would reach + // `PlatformDispatcher.onError` as a fatal with no in-app frames. + onTap: () => detached( + 'GitPanel', + 'hand sync failure to agent', + () => _handOff(context, ref), + ), + ) + : null, + ); + } + + Future _handOff(BuildContext context, WidgetRef ref) async { + // Read the entries here rather than holding them on the strip: the dialog + // stays open indefinitely, and what the agent should be told about the + // working tree is what it holds when the message is composed. + final entries = + ref.read(fileTreeStateProvider).value?.gitFileEntries ?? const []; + await offerSyncFailureToAgent( + context: context, + container: ref.container, + failure: failure, + sync: git.sync, + changed: entries, + ); + } +} + +/// One stashed set of changes, offered as Restore or Discard. +/// +/// Persists until acted on — same reasoning as [_SyncFailureStrip]: a stash +/// is exactly the kind of thing a snackbar (gone in four seconds) loses. Most +/// often this is the ONE stash the New Session composer just created when a +/// dirty branch switch was confirmed, but it renders every stash in the +/// repository (`git stash` has one list, shared by every worktree) — so a +/// stash made outside Antgrid, or a second one from a later switch, shows up +/// here too rather than being invisible until the user thinks to run `git +/// stash list` themselves. +class _StashBanner extends StatelessWidget { + const _StashBanner({required this.stash, required this.fileService}); + + final GitStashEntry stash; + final FileService fileService; + + Future _discard(BuildContext context) async { + final confirmed = await AbConfirmDialog.show( + context: context, + title: 'Discard stash', + body: + 'Permanently delete the changes stashed from ' + '"${stash.branch.isEmpty ? 'an earlier branch' : stash.branch}"? ' + 'This cannot be undone.', + confirmLabel: 'Discard', + destructive: true, + ); + if (confirmed) fileService.dropStash(stash.ref); + } + + @override + Widget build(BuildContext context) { + final from = stash.branch.isEmpty ? 'a branch switch' : stash.branch; + return AbInlineBanner( + text: 'Uncommitted changes stashed from "$from" are waiting.', + color: context.antgrid.warning, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + AbButton( + label: 'Restore', + compact: true, + onTap: () => fileService.restoreStash(stash.ref), + ), + const SizedBox(width: AbTokens.space6), + AbButton( + label: 'Discard', + compact: true, + onTap: () => + detached('GitPanel', 'discard stash', () => _discard(context)), + ), + ], + ), + ); + } +} + +class _GitPanelBody extends ConsumerWidget { const _GitPanelBody({required this.state, required this.fileService}); final FileTreeState state; final FileService fileService; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { return LayoutBuilder( builder: (context, constraints) { final showSideBySide = constraints.maxWidth >= kCompactBreakpoint; @@ -659,9 +1080,11 @@ class _GitPanelBody extends StatelessWidget { // "back" from. final showBack = !showSideBySide && isViewing; + final counts = _GitHeaderCounts.of(state.gitFileEntries); return _GitPanelScaffold( - counts: _GitHeaderCounts.of(state.gitFileEntries), + counts: counts, fileService: fileService, + git: state.git, collapsedPaths: state.git.collapsedPaths, onBack: showBack ? () { @@ -671,8 +1094,10 @@ class _GitPanelBody extends StatelessWidget { : null, body: _buildContent( context, + ref, showSideBySide: showSideBySide, isViewing: isViewing, + counts: counts, ), ); }, @@ -680,29 +1105,110 @@ class _GitPanelBody extends StatelessWidget { } Widget _buildContent( - BuildContext context, { + BuildContext context, + WidgetRef ref, { required bool showSideBySide, required bool isViewing, + required _GitHeaderCounts counts, }) { if (showSideBySide) { return Row( children: [ SizedBox( width: 280, - child: _buildFileList(context), + child: _buildLeftColumn(context), ), // 280px non-ladder: side-by-side file list width const AbSeparator.vertical(weight: AbSeparatorWeight.strong), - Expanded(child: _buildContentArea(context)), + Expanded(child: _buildContentArea(context, ref)), ], ); } // Compact: show viewer when a diff or "view file" is active. if (isViewing) { - return _buildContentArea(context); + return _buildContentArea(context, ref); + } + + return _buildCompactChangesHistory(context, counts); + } + + /// The panel's left column: the Changes tree on top, the commit History + /// underneath it, in one fixed 3:2 split rather than a tab switching + /// between them — both stay on screen and each scrolls independently, + /// so seeing what changed and seeing how it got there never cost a tap + /// to switch between. + /// + /// With no working-tree changes the Changes tree has nothing to show but + /// an empty state, so it is dropped entirely rather than reserving 3/5 of + /// the column for it — History takes the full column instead. + Widget _buildLeftColumn(BuildContext context) { + final historyColumn = Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _GitHistorySectionHeader( + fileService: fileService, + history: state.git.history, + ), + const AbSeparator.horizontal(), + Expanded( + child: _HistoryList(git: state.git, fileService: fileService), + ), + ], + ); + + if (state.gitFileEntries.isEmpty) { + return historyColumn; + } + + return Column( + children: [ + Expanded(flex: 3, child: _buildFileList(context)), + const AbSeparator.horizontal(), + Expanded(flex: 2, child: historyColumn), + ], + ); + } + + /// The compact (phone-width) counterpart to [_buildLeftColumn]'s fixed 3:2 + /// stack: a segmented Changes ⇄ History switch instead, each tab getting + /// the full column. + /// + /// The side-by-side layout's stack works because a docked context pane has + /// real vertical room; squeezed into a phone's own already-short height it + /// left History — arguably the more common reason to open this tab on + /// mobile, since editing/staging happens more on desktop — in a nested + /// scroll region under the Changes tree's own, fighting it for gesture + /// ownership and rarely showing more than a commit or two at once. + Widget _buildCompactChangesHistory( + BuildContext context, + _GitHeaderCounts counts, + ) { + final historyColumn = Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _GitHistorySectionHeader( + fileService: fileService, + history: state.git.history, + ), + const AbSeparator.horizontal(), + Expanded( + child: _HistoryList(git: state.git, fileService: fileService), + ), + ], + ); + + if (state.gitFileEntries.isEmpty) { + // Nothing to switch to — History alone gets the full column, same as + // the side-by-side layout's own empty-changes case. + return historyColumn; } - return _buildFileList(context); + return _ChangesHistorySwitcher( + changedCount: counts.revertablePaths.length, + commitCount: state.git.history.commits.length, + changes: _buildFileList(context), + history: historyColumn, + ); } // The same widget the Files tab renders, in its [changesOnly] mode: decorated, @@ -813,21 +1319,37 @@ class _GitPanelBody extends StatelessWidget { if (confirmed) fileService.discard([path], includeStaged: true); } - Widget _buildContentArea(BuildContext context) { + Widget _buildContentArea(BuildContext context, WidgetRef ref) { final git = state.git; if (git.diffPath != null) { if (git.diffLoading) { return const AbLoading(); } if (git.diffContent != null) { + // A commit-scoped diff's status letter comes from that commit's own + // file list, never `state.gitFileStatuses` — the working tree's + // status for the same path (or none at all) describes a different + // change. + final commitSha = git.diffCommitSha; + final gitStatus = commitSha == null + ? state.gitFileStatuses[git.diffPath!] + : git.history.filesBySha[commitSha] + ?.where((f) => f.path == git.diffPath) + .firstOrNull + ?.status; return DiffViewer( path: git.diffPath!, - gitStatus: state.gitFileStatuses[git.diffPath!], + gitStatus: gitStatus, diff: git.diffContent!, additions: git.diffAdditions ?? 0, deletions: git.diffDeletions ?? 0, onViewFile: () => fileService.gitViewFile(git.diffPath!), onClose: () => fileService.clearDiff(), + onSendToAgent: (context, message) => sendCaptureToAgent( + context: context, + container: ref.container, + text: message, + ), ); } return Center( @@ -858,3 +1380,542 @@ class _GitPanelBody extends StatelessWidget { ); } } + +/// Which tab [_ChangesHistorySwitcher] shows. +enum _GitMobileTab { changes, history } + +/// Phone-width swap between the Changes tree and commit History — see +/// [_GitPanelBody._buildCompactChangesHistory] for why this replaces the +/// side-by-side layout's fixed 3:2 stack on a narrow screen. +/// +/// An [IndexedStack], not a rebuild-on-switch: both tabs stay mounted so +/// flipping back doesn't lose either list's scroll position or which commits +/// are expanded, the same reasoning `WorkspaceShell` keeps its panels +/// mounted rather than tearing them down on every toggle. +class _ChangesHistorySwitcher extends StatefulWidget { + const _ChangesHistorySwitcher({ + required this.changedCount, + required this.commitCount, + required this.changes, + required this.history, + }); + + final int changedCount; + final int commitCount; + final Widget changes; + final Widget history; + + @override + State<_ChangesHistorySwitcher> createState() => + _ChangesHistorySwitcherState(); +} + +class _ChangesHistorySwitcherState extends State<_ChangesHistorySwitcher> { + // Changes is "what do I need to act on" — stays the default landing tab + // even though History now gets the full column instead of a sliver of one. + _GitMobileTab _tab = _GitMobileTab.changes; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AbTokens.space12, + vertical: AbTokens.space8, + ), + // Scrollable, not a bare AbSegmented: the enclosing Column stretches + // this to the full row width, and AbSegmented hugs its own content + // (mainAxisSize.min) rather than sharing that width between cells — + // on the narrowest phones, a double-digit changed/commit count can + // need more than the row has, and a SingleChildScrollView absorbs + // that the same way the Changes header's own action row does, + // rather than a hard RenderFlex overflow. + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: AbSegmented<_GitMobileTab>( + segments: [ + AbSegment( + value: _GitMobileTab.changes, + label: 'Changes · ${widget.changedCount}', + ), + AbSegment( + value: _GitMobileTab.history, + label: 'History · ${widget.commitCount}', + ), + ], + selected: _tab, + onSelect: (value) => setState(() => _tab = value), + ), + ), + ), + const AbSeparator.horizontal(), + Expanded( + child: IndexedStack( + index: _tab.index, + children: [widget.changes, widget.history], + ), + ), + ], + ); + } +} + +/// The History section's scrollable commit list, in its own [Expanded] slot +/// under [_GitHistorySectionHeader]. Each commit can expand in place to a +/// file list (more than one at once — see [GitHistoryState]); the list itself +/// paginates via [FileService.loadMoreHistory] as the user scrolls near its +/// end, the same "ask for more before you hit the wall" margin a fling can +/// cover in one frame. +class _HistoryList extends StatefulWidget { + const _HistoryList({required this.git, required this.fileService}); + + final GitPaneState git; + final FileService fileService; + + @override + State<_HistoryList> createState() => _HistoryListState(); +} + +class _HistoryListState extends State<_HistoryList> { + final _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + } + + @override + void dispose() { + _scrollController.removeListener(_onScroll); + _scrollController.dispose(); + super.dispose(); + } + + void _onScroll() { + if (!_scrollController.hasClients) return; + final position = _scrollController.position; + if (position.pixels >= position.maxScrollExtent - 400) { + widget.fileService.loadMoreHistory(); + } + } + + @override + Widget build(BuildContext context) { + final history = widget.git.history; + if (history.initialLoad && history.commits.isEmpty) { + return const AbLoading(message: 'loading history...'); + } + if (history.error != null && history.commits.isEmpty) { + return AbEmptyState.error( + title: 'Could not load history', + subtitle: history.error, + action: AbButton( + label: 'Retry', + compact: true, + onTap: widget.fileService.loadHistory, + ), + ); + } + if (history.commits.isEmpty) { + return const AbEmptyState( + title: 'No commits yet', + icon: AbIcons.gitCommit, + ); + } + + return RefreshIndicator( + onRefresh: () async { + widget.fileService.loadHistory(); + await Future.delayed(const Duration(milliseconds: 500)); + }, + child: ListView.builder( + controller: _scrollController, + itemCount: history.commits.length + 1, + itemBuilder: (context, index) { + if (index == history.commits.length) { + return _HistoryFooter( + history: history, + fileService: widget.fileService, + ); + } + final commit = history.commits[index]; + final expanded = history.expandedShas.contains(commit.sha); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _CommitHeaderRow( + commit: commit, + expanded: expanded, + onTap: () => + widget.fileService.toggleCommitExpanded(commit.sha), + ), + if (expanded) + _CommitFilesSection( + sha: commit.sha, + files: history.filesBySha[commit.sha], + loading: history.filesLoadingShas.contains(commit.sha), + error: history.filesErrorBySha[commit.sha], + openPath: widget.git.diffCommitSha == commit.sha + ? widget.git.diffPath + : null, + fileService: widget.fileService, + ), + const AbSeparator.horizontal(), + ], + ); + }, + ), + ); + } +} + +/// The trailing row of the history list: a spinner while the next page loads, +/// a retry affordance if it failed, "No more commits" once [hasMore] is +/// false, or nothing while there's more to scroll to but nothing is loading +/// yet. +class _HistoryFooter extends StatelessWidget { + const _HistoryFooter({required this.history, required this.fileService}); + + final GitHistoryState history; + final FileService fileService; + + @override + Widget build(BuildContext context) { + if (history.loadingMore) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: AbTokens.space16), + child: Center(child: AbLoadingDot()), + ); + } + if (history.error != null) { + return Padding( + padding: const EdgeInsets.all(AbTokens.space12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + history.error!, + textAlign: TextAlign.center, + style: AbTokens.sansStyle( + fontSize: AbTokens.fontXs, + color: context.antgrid.error, + ), + ), + const SizedBox(height: AbTokens.space8), + AbButton( + label: 'Retry', + compact: true, + onTap: fileService.loadMoreHistory, + ), + ], + ), + ); + } + if (!history.hasMore) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: AbTokens.space16), + child: Center( + child: Text( + 'No more commits', + style: AbTokens.sansStyle( + fontSize: AbTokens.fontXs, + color: context.antgrid.textMuted, + ), + ), + ), + ); + } + return const SizedBox.shrink(); + } +} + +/// One commit's row: a graph-rail dot, its subject, and the author/date/sha +/// meta line. Tapping anywhere on the row toggles the file list beneath it; +/// long-pressing opens a copy-SHA menu — the touch replacement for a desktop +/// row's spare icon buttons, which a phone-width row has no room for. +class _CommitHeaderRow extends StatelessWidget { + const _CommitHeaderRow({ + required this.commit, + required this.expanded, + required this.onTap, + }); + + final GitLogEntry commit; + final bool expanded; + final VoidCallback onTap; + + Future _showActions(BuildContext context, Offset globalPosition) async { + final action = await showAbMenu( + context: context, + anchorRect: Rect.fromCenter(center: globalPosition, width: 1, height: 1), + header: commit.shortSha, + entries: const [ + AbMenuItem(label: 'Copy full SHA', icon: AbIcons.copy, value: 'sha'), + AbMenuItem( + label: 'Copy short SHA', + icon: AbIcons.copy, + value: 'shortSha', + ), + ], + ); + if (!context.mounted || action == null) return; + await Clipboard.setData( + ClipboardData(text: action == 'sha' ? commit.sha : commit.shortSha), + ); + if (context.mounted) showAbSnackBar(context, 'Copied to clipboard'); + } + + @override + Widget build(BuildContext context) { + final p = context.antgrid; + final when = DateTime.tryParse(commit.authorDate); + return GestureDetector( + // A void callback discards whatever future it starts — see + // util/detached.dart — so a rejected `showAbMenu`/clipboard write would + // otherwise reach `PlatformDispatcher.onError` as an unattributed fatal. + onLongPressStart: (details) => detached( + 'GitPanel', + 'commit history long-press actions', + () => _showActions(context, details.globalPosition), + ), + // The rail's line segments fill the row via Expanded, which needs a + // determinate height to resolve against — IntrinsicHeight measures the + // row's natural (title+subtitle) height first and hands that down, + // the same trick AbSegmented uses to stretch its own cell dividers. + child: IntrinsicHeight( + child: AbListRow( + onTap: onTap, + hoverable: true, + density: AbRowDensity.md, + crossAxisAlignment: CrossAxisAlignment.stretch, + titleMaxLines: 2, + leading: _CommitRail(expanded: expanded), + // A commit subject clips at 2 lines; the tooltip is the only way to + // read the rest of a longer one, matching VS Code's history hover. + title: AbTooltip( + message: commit.subject, + child: Text(commit.subject), + ), + subtitle: Row( + children: [ + Flexible( + child: Text(commit.authorName, overflow: TextOverflow.ellipsis), + ), + const SizedBox(width: AbTokens.space6), + if (when != null) + AbTooltip( + message: absoluteTime(when), + child: Text(relativeTime(when)), + ), + const Spacer(), + Text( + commit.shortSha, + style: AbTokens.monoStyle( + fontSize: AbTokens.fontXxs, + color: p.textMuted, + ), + ), + ], + ), + ), + ), + ); + } +} + +/// The History list's graph rail: one continuous line down the column with a +/// dot at each commit, filled with the accent while that commit is expanded. +/// +/// Each row draws only its own short segment (top half-line, dot, bottom +/// half-line), stretched to that row's height by the [IntrinsicHeight] in +/// [_CommitHeaderRow] — with consecutive commit rows sitting flush against +/// the hairline [AbSeparator] between them, the segments read as one +/// unbroken rail with no cross-row layout coordination needed. The rail does +/// NOT continue through an expanded commit's file list, the same way a git +/// graph doesn't draw through expanded detail. +class _CommitRail extends StatelessWidget { + const _CommitRail({required this.expanded}); + + final bool expanded; + + @override + Widget build(BuildContext context) { + final p = context.antgrid; + final line = Expanded(child: Container(width: 1.5, color: p.borderDefault)); + return SizedBox( + width: 16, + child: Column( + children: [ + line, + Container( + width: 7, + height: 7, + margin: const EdgeInsets.symmetric(vertical: 3), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: expanded ? p.accent : p.textMuted, + ), + ), + line, + ], + ), + ); + } +} + +/// One expanded commit's file list — loading, an error with retry, an empty +/// result (a commit with no diff, e.g. an empty merge), or the files +/// themselves. Indented under the commit row it belongs to. +class _CommitFilesSection extends StatelessWidget { + const _CommitFilesSection({ + required this.sha, + required this.files, + required this.loading, + required this.error, + required this.openPath, + required this.fileService, + }); + + final String sha; + final List? files; + final bool loading; + final String? error; + final String? openPath; + final FileService fileService; + + @override + Widget build(BuildContext context) { + if (loading) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: AbTokens.space12), + child: Center(child: AbLoadingDot()), + ); + } + if (error != null) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: AbTokens.space16, + vertical: AbTokens.space8, + ), + child: Row( + children: [ + Expanded( + child: Text( + error!, + style: AbTokens.sansStyle( + fontSize: AbTokens.fontXs, + color: context.antgrid.error, + ), + ), + ), + AbButton( + label: 'Retry', + compact: true, + onTap: () => fileService.retryCommitFiles(sha), + ), + ], + ), + ); + } + final entries = files ?? const []; + if (entries.isEmpty) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: AbTokens.space16, + vertical: AbTokens.space8, + ), + child: Text( + 'No file changes', + style: AbTokens.sansStyle( + fontSize: AbTokens.fontXs, + color: context.antgrid.textMuted, + ), + ), + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final file in entries) + _CommitFileRow( + sha: sha, + file: file, + selected: file.path == openPath, + onTap: () => fileService.requestCommitDiff(sha, file.path), + ), + ], + ); + } +} + +/// One file within an expanded commit — status letter, path, and a diff stat. +/// Tapping it opens that file's diff for this specific commit. +class _CommitFileRow extends StatelessWidget { + const _CommitFileRow({ + required this.sha, + required this.file, + required this.selected, + required this.onTap, + }); + + final String sha; + final GitCommitFileEntry file; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final p = context.antgrid; + return AbListRow( + onTap: onTap, + hoverable: true, + selected: selected, + selectionStyle: AbRowSelection.surface, + density: AbRowDensity.sm, + // Indented under the commit's own leading rail so the file list + // reads as nested content, matching the depth-indent the Changes tab's + // folder tree uses for the same reason. + horizontalPadding: AbTokens.space12 + AbTokens.space16, + leading: SizedBox( + width: 14, + child: Text( + file.status, + textAlign: TextAlign.center, + style: AbTokens.monoStyle( + fontSize: AbTokens.fontXs, + fontWeight: FontWeight.w600, + color: gitStatusColor(context, file.status), + ), + ), + ), + // A long path clips to the row's width with no way to read the rest of + // it — the hover tooltip is what VS Code's own changed-files list shows + // in exactly this spot. + title: AbTooltip( + message: file.path, + child: Text( + file.path, + style: AbTokens.monoStyle( + color: selected ? p.accent : p.textPrimary, + ), + ), + ), + subtitle: file.oldPath != null + ? AbTooltip( + message: file.oldPath!, + child: Text(file.oldPath!), + ) + : null, + trailing: (file.additions > 0 || file.deletions > 0) + ? AbDiffStat( + additions: file.additions, + deletions: file.deletions, + fontSize: AbTokens.fontXxs, + ) + : null, + ); + } +} diff --git a/app/lib/widgets/git_sync_failure_handoff.dart b/app/lib/widgets/git_sync_failure_handoff.dart new file mode 100644 index 00000000..a7d6ddec --- /dev/null +++ b/app/lib/widgets/git_sync_failure_handoff.dart @@ -0,0 +1,163 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../models/ab_message.dart' show GitFileStatusEntry; +import '../models/git_sync_state.dart'; +import 'send_capture_to_agent.dart'; +import 'send_to_agent_comment.dart'; + +/// Hands a failed push or pull to the coding agent. +/// +/// Everything the agent needs is already structured on [failure] — the +/// invocation, git's verbatim stderr, the branch and its remote — so the report +/// is COMPOSED from those fields and never re-parsed out of git's prose. That +/// is the same split the bridge makes: it classifies, the app forwards. +/// +/// Deliberately one tap and never automatic. A failure that wrote itself into +/// an agent's stdin would interleave with whatever turn was already running. +Future offerSyncFailureToAgent({ + required BuildContext context, + required ProviderContainer container, + required GitSyncFailure failure, + GitSyncState sync = GitSyncState.empty, + List changed = const [], +}) async { + final report = composeSyncFailureReport( + failure: failure, + sync: sync, + changed: changed, + ); + + // The existing review dialog: it shows the composed report and lets the user + // add a line before anything is sent. This IS the confirmation step — there + // is deliberately no second one. + final message = await showSendToAgentComment( + context: context, + selectedText: report, + sourceLabel: '[from git ${failure.op.name}]', + ); + if (message == null || !context.mounted) return; + + // `sendCaptureToAgent`, not `TerminalService.sendToAgentTerminal`: only it + // routes for BOTH session modes — a terminal agent takes stdin, a chat agent + // takes a composer handoff, and they are genuinely different destinations. + await sendCaptureToAgent( + context: context, + container: container, + text: message, + ); +} + +/// The report handed to the agent. +/// +/// The closing instruction is the load-bearing line and belongs in the text +/// rather than in the user's head: it is what keeps a helpful agent from +/// reaching for `reset --hard` or `push --force` to make the error go away. It +/// is a request, not a guarantee — the Handler's own destructive floor is what +/// actually bounds a force push if one is proposed. +/// +/// Separated from the dialog so it can be tested without a widget tree. +String composeSyncFailureReport({ + required GitSyncFailure failure, + GitSyncState sync = GitSyncState.empty, + List changed = const [], +}) { + final buffer = StringBuffer(); + final verb = failure.op == GitSyncOp.push ? 'push' : 'pull'; + final command = failure.command; + + buffer.writeln( + command != null ? '`$command` failed.' : 'git $verb failed.', + ); + + final stderr = failure.stderr?.trim(); + if (stderr != null && stderr.isNotEmpty) { + buffer.writeln(); + // Indented rather than fenced: this goes into a terminal agent's stdin as + // often as into a chat composer, and a fence there is just noise. + for (final line in stderr.split('\n')) { + buffer.writeln(' ${line.trimRight()}'); + } + } else { + buffer.writeln(); + buffer.writeln(' ${failure.message}'); + } + + buffer.writeln(); + final branch = failure.branch ?? sync.branch; + final remoteRef = failure.remoteRefLabel ?? sync.remoteRefLabel; + if (branch != null && remoteRef != null && sync.hasUpstream) { + buffer.writeln( + 'Branch `$branch` is ${sync.ahead} ahead and ${sync.behind} behind ' + '`$remoteRef`.', + ); + } else if (branch != null && remoteRef != null) { + buffer.writeln('Branch `$branch` has no upstream; `$remoteRef` is where it ' + 'would be published.'); + } else if (branch != null) { + buffer.writeln('Branch `$branch`.'); + } + + final worktree = _describeWorktree(changed); + if (worktree != null) buffer.writeln('Working tree: $worktree.'); + + buffer.writeln(); + buffer.writeln(_instructionFor(failure)); + return buffer.toString().trimRight(); +} + +/// What the agent is being asked to do, per failure kind. Each names the +/// outcome the user wants rather than a command, so the agent picks the route +/// — and each rules out the destructive shortcut that would technically make +/// the error stop. +String _instructionFor(GitSyncFailure failure) => switch (failure.kind) { + GitSyncFailureKind.notFastForward || + GitSyncFailureKind.rejected => 'Please reconcile this and push, without ' + 'discarding my local commits and without force-pushing.', + GitSyncFailureKind.diverged => 'Please reconcile the two histories and bring ' + 'the branch up to date, without discarding my local commits.', + GitSyncFailureKind.conflict => 'Please resolve the merge conflicts, then ' + 'finish the ${failure.op.name}.', + GitSyncFailureKind.dirtyTree => 'Please get my uncommitted changes safely out ' + 'of the way (commit or stash them — do not discard them), then ' + '${failure.op.name}.', + GitSyncFailureKind.auth => 'Please work out what credentials this remote ' + 'needs and tell me what to do — do not store any secret in the repo.', + GitSyncFailureKind.noUpstream || + GitSyncFailureKind.ambiguousRemote => 'Please work out which remote this ' + 'branch should track, set it, and push.', + _ => 'Please work out what went wrong and finish the ${failure.op.name}, ' + 'without discarding my local commits.', +}; + +/// "4 modified, 1 untracked" — the counts that explain a dirty-tree refusal, +/// deduped by path because a path staged AND edited again legitimately appears +/// twice in the entry list. +String? _describeWorktree(List changed) { + if (changed.isEmpty) return null; + final byPath = {}; + for (final e in changed) { + // Worktree status wins over staged, matching the emission order the bridge + // documents — what blocks a checkout is the unstaged edit. + byPath[e.path] = e.status; + } + var modified = 0; + var untracked = 0; + var conflicted = 0; + for (final status in byPath.values) { + switch (status) { + case 'U': + untracked++; + case '!': + conflicted++; + default: + modified++; + } + } + final parts = [ + if (modified > 0) '$modified modified', + if (untracked > 0) '$untracked untracked', + if (conflicted > 0) '$conflicted conflicted', + ]; + return parts.isEmpty ? null : parts.join(', '); +} diff --git a/app/lib/widgets/handler/handler_arm_explainer.dart b/app/lib/widgets/handler/handler_arm_explainer.dart index 006a1e1c..dbc5e3d8 100644 --- a/app/lib/widgets/handler/handler_arm_explainer.dart +++ b/app/lib/widgets/handler/handler_arm_explainer.dart @@ -3,15 +3,36 @@ import 'dart:async'; import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../design/widgets/ab_confirm_dialog.dart'; +import '../../design/ab_colors.dart'; +import '../../design/ab_icons.dart'; +import '../../design/ab_tokens.dart'; +import '../../design/widgets/ab_adaptive_sheet.dart'; +import '../../design/widgets/ab_button.dart'; +import '../../design/widgets/ab_dialog.dart'; +import '../../design/widgets/ab_toast.dart'; import '../../models/handler_state.dart'; +import '../../navigation/root_navigator.dart'; +import '../../providers/agent_catalog.dart'; import '../../providers/first_run.dart'; import '../../providers/providers.dart'; import '../../providers/session_opening_prompt.dart'; +import '../../screens/upgrade_screen.dart'; +import '../../services/handler_service.dart'; +import 'handler_instruction_composer.dart'; import 'handler_item_status.dart'; +import 'handler_session_settings.dart'; -/// Body copy for the first-arm explainer. Top-level so the copy matrix is -/// unit-testable without pumping a dialog. +/// Body copy for the arm sheet, or null when there is nothing left to say — +/// which is the ordinary repeat arm over a covered agent. Top-level so the copy +/// matrix is unit-testable without pumping a dialog. +/// +/// [explain] carries the standing "what is Handler" paragraph, and is false +/// once the user has armed anything (see [FirstRunState.handlerArmedOnce]). +/// Only that paragraph is dropped: this sheet opens on every arm, so its copy +/// would otherwise re-teach a user who has walked away and come back many +/// times. Everything below it is a fact about THIS arm — what will be queued, +/// what the agent will report — and none of it is retired by having read the +/// paragraph once. /// /// The coverage line mirrors the catalog contract (see agentCatalogProvider): /// `false` is a bridge saying "cannot watch" — reuse [unwatchableNotice], the @@ -20,14 +41,11 @@ import 'handler_item_status.dart'; /// /// [hasOpeningPrompt] announces the seeded goal: a backlog appears on its own /// the moment such a session arms, and the sentence it came from was typed on a -/// different screen minutes earlier. Said once, before the coverage caveat — a -/// warning still reads last. -/// -/// Withheld entirely from the `false` arm. Extraction still runs there, so the -/// sentence is mechanically true, but it promises a backlog two lines above the -/// notice saying arming would stay silent — and this is the one screen whose -/// whole job is to set the expectation before the user walks away. A session -/// that will say nothing has nothing to say about what it starts from. +/// different screen minutes earlier. Said before the coverage caveat — a +/// warning still reads last — and withheld entirely from the `false` arm, since +/// a session that will say nothing has nothing to say about what it starts +/// from. This is the one screen whose whole job is to set the expectation +/// before the user walks away. /// /// [judgeCapable] is the second, independent half of the same coverage answer /// — the session IS watched, but its judge cannot run headless, so every pause @@ -38,73 +56,327 @@ import 'handler_item_status.dart'; /// `false` arm already carries the stronger fact and stacking a second caveat /// under it just dilutes the one that matters; the `null` arm has claimed /// nothing about coverage and must not start here. -String handlerArmExplainerBody({ +String? handlerArmExplainerBody({ required bool? agentObservable, String? agentLabel, bool hasOpeningPrompt = false, bool? judgeCapable, + bool explain = true, }) { const base = "Handler watches this session while you're away. When the agent pauses " 'on a question or a permission, Handler answers what it safely can and ' 'queues the rest for you.'; - final head = hasOpeningPrompt - ? '$base\n\nIt starts from what you asked for when you opened this ' - 'session, and queues that as your backlog.' - : base; - return switch (agentObservable) { - true => judgeCapable == false ? '$head\n\n$escalateOnlyNotice' : head, - false => '$base\n\n${unwatchableNotice(agentLabel)}', + // Names Handler outright rather than opening on "It", because the paragraph + // that would have been its antecedent is gone on every arm past the first. + const goal = + 'Handler starts from what you asked for when you opened this session, ' + 'and queues that as your backlog.'; + final warning = switch (agentObservable) { + true => judgeCapable == false ? escalateOnlyNotice : null, + false => unwatchableNotice(agentLabel), null => - "$head\n\nThis agent hasn't reported what Handler can see here, so it " - 'may stay silent.', + "This agent hasn't reported what Handler can see here, so it may stay " + 'silent.', }; + final paragraphs = [ + if (explain) base, + // Withheld entirely from the unwatchable arm. Extraction still runs there, + // so the sentence is mechanically true, but it promises a backlog directly + // above the notice saying arming would stay silent. + if (hasOpeningPrompt && agentObservable != false) goal, + ?warning, + ]; + return paragraphs.isEmpty ? null : paragraphs.join('\n\n'); } -/// Shows the one-time "what is Handler" explainer. Returns true when the user -/// confirmed arming. -Future showHandlerArmExplainer( +/// What the arm sheet collected: a settings DELTA that rides the arm's +/// `handler:configure`, and a sentence that must NOT ride it at all. +/// +/// A record rather than a widened edit because the two have different +/// destinations and different timing — the settings go out with the arm, the +/// instruction only after the bridge confirms it — and one type that could hold +/// either is one a call site can send down the wrong path. Null still means the +/// user backed out. +typedef HandlerArmDecision = ({ + HandlerSessionSettingsEdit settings, + String instruction, +}); + +/// Shows the arm sheet — what Handler will do here, with the session's judge, +/// posture and an instruction composer on it. Returns what the user decided, or +/// null if they backed out. +/// +/// A sheet rather than a dialog, for one reason: this screen tells a user their +/// judge cannot run headless, and the picker that fixes it belongs beside the +/// warning. A title/body/two-buttons dialog has nowhere to put a control. +/// +/// Every arm opens it, not just the first: the sentence typed here is the only +/// backlog a session can be given AT arm time, and the coverage it warns about +/// is per-agent — so a user whose last arm was a watchable agent must still +/// meet an unwatchable one's notice on the next. +Future showHandlerArmSheet( BuildContext context, { + required String terminalId, + required HandlerSessionSettingsValue initial, required bool? agentObservable, String? agentLabel, bool hasOpeningPrompt = false, bool? judgeCapable, -}) => AbConfirmDialog.show( - context: context, - title: 'Arm Handler', - body: handlerArmExplainerBody( + bool explain = true, +}) => showAbAdaptiveSheet( + context, + child: _ArmSheet( + terminalId: terminalId, + initial: initial, + hasOpeningPrompt: hasOpeningPrompt, agentObservable: agentObservable, agentLabel: agentLabel, - hasOpeningPrompt: hasOpeningPrompt, judgeCapable: judgeCapable, + explain: explain, ), - confirmLabel: 'Arm Handler', - cancelLabel: 'Not now', ); -/// The single first-arm flow, shared by the header shield and the away-moment -/// hint so the two can never drift: explainer while [FirstRunState.handlerArmedOnce] -/// is false → arm on confirm → latch the flag on EVERY successful arm. +class _ArmSheet extends ConsumerStatefulWidget { + const _ArmSheet({ + required this.terminalId, + required this.initial, + required this.hasOpeningPrompt, + required this.agentObservable, + required this.agentLabel, + required this.judgeCapable, + required this.explain, + }); + + final String terminalId; + final HandlerSessionSettingsValue initial; + final bool? agentObservable; + final String? agentLabel; + + /// See [handlerArmExplainerBody]. Drops the standing explanation only; the + /// coverage warnings and the seeded goal are per-arm facts and stay. + final bool explain; + + /// Whether the judge this sheet OPENED on can run headless. The seed only — + /// the body is recomputed against whatever judge is picked while it is up + /// (see [_ArmSheetState.build]). + final bool? judgeCapable; + + /// Steers the composer's hint alone. A seeded goal is already extracted on + /// arm, and an instruction typed here is extracted a second time — nothing + /// dedups across the two passes — so the sheet's job is to stop the user + /// restating what it has just told them is already queued. + final bool hasOpeningPrompt; + + @override + ConsumerState<_ArmSheet> createState() => _ArmSheetState(); +} + +class _ArmSheetState extends ConsumerState<_ArmSheet> { + /// What the sheet opens on, and the `from` side of its judge delta. + /// + /// The posture is seeded to a real preset even where nothing has reported one + /// — unlike the settings sheet, which reports what the far end holds and must + /// show "not reported" rather than invent it. This is the control the sheet + /// exists for, and its unreported copy names a bridge too old to have the + /// setting, which is a claim this surface has no grounds to make. + /// + /// That seed is a DISPLAY value and never a report, so it is not what the + /// posture is sent against — see [_postureTouched]. The service cache is + /// empty for a disarmed session after a restart while the bridge still holds + /// the posture that session was last given, and treating the seed as the + /// stored value would reset that pick to the default on every re-arm. The + /// judge needs none of this, which is why it can diff normally: it is never + /// seeded in the first place. + late final HandlerSessionSettingsValue _opened = ( + judgeTool: widget.initial.judgeTool, + judgeModel: widget.initial.judgeModel, + personality: widget.initial.personality ?? HandlerPersonality.watchdog, + ); + late HandlerSessionSettingsValue _value = _opened; + + /// Whether the user has TOUCHED the posture control, which is not the same + /// question as whether the value ended up different. Moving off the seed and + /// back is still a choice, and the seed it lands on may not be what the + /// bridge holds — so any touch sends, and only an untouched control stays + /// silent. A tap on the cell already selected never reaches here: AbSegmented + /// swallows it, which is why this cannot simply be "the user tapped it". + bool _postureTouched = false; + final _instruction = TextEditingController(); + + @override + void dispose() { + _instruction.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final p = context.antgrid; + // Recomputed on every build, never frozen at open: the composer's judge + // chip is ON this sheet, so the escalate-only warning in this copy is one + // the user can fix while reading it. A body computed once would keep + // warning about a judge the sheet had already replaced, directly above the + // control that replaced it. + final effectiveJudge = handlerEffectiveJudge( + ref, + widget.terminalId, + _value.judgeTool, + ); + final body = handlerArmExplainerBody( + agentObservable: widget.agentObservable, + agentLabel: widget.agentLabel, + hasOpeningPrompt: widget.hasOpeningPrompt, + judgeCapable: effectiveJudge == null + ? widget.judgeCapable + : ref.watch(agentCatalogProvider)[effectiveJudge]?.judgeCapable, + explain: widget.explain, + ); + return SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: abDialogTitlePadding, + child: abDialogTitle( + 'Arm Handler', + onClose: () => Navigator.of(context).maybePop(), + ), + ), + // Absent on a repeat arm over a covered agent, where every sentence + // this sheet could say has either been read already or would be a + // claim about coverage nothing reported. The title and the composer + // carry it from there. + if (body != null) + Padding( + padding: const EdgeInsets.fromLTRB( + AbTokens.space16, + 0, + AbTokens.space16, + AbTokens.space16, + ), + child: Text( + body, + style: AbTokens.sansStyle( + fontSize: AbTokens.fontSm, + color: p.textSecondary, + ), + ), + ) + else + // The title row carries no bottom padding of its own — a paragraph's + // leading is what has always separated it from what follows. + const SizedBox(height: AbTokens.space12), + // Directly under the sentence about what gets queued, and above the + // posture control: this box IS the act, and how much Handler handles + // is a setting subordinate to it. No autofocus — the sheet is a thing + // to read first, and a keyboard over it on a phone hides the copy + // that explains what arming does. + Padding( + padding: const EdgeInsets.fromLTRB( + AbTokens.space16, + 0, + AbTokens.space16, + AbTokens.space16, + ), + child: HandlerInstructionComposer( + terminalId: widget.terminalId, + controller: _instruction, + hintText: widget.hasOpeningPrompt + ? 'Anything to add beyond that?' + // The empty backlog's own invitation, verbatim: one act + // worded one way wherever the user meets it. + : "Add what you want done while you're away.", + judge: ( + judgeTool: _value.judgeTool, + judgeModel: _value.judgeModel, + ), + onJudgeChanged: (pick) => setState( + () => _value = ( + judgeTool: pick.judgeTool, + judgeModel: pick.judgeModel, + personality: _value.personality, + ), + ), + judgeScopeNote: handlerJudgeScopeOnArm, + // No send key: this sheet's one commit is [Arm Handler] below. + send: null, + ), + ), + // The judge rows stay off this sheet — the composer's chip is that + // picker here, and mounting both would be two controls for one value. + HandlerPostureControl( + terminalId: widget.terminalId, + value: _value, + onChanged: (next) => setState(() { + _value = next; + _postureTouched = true; + }), + ), + Padding( + padding: const EdgeInsets.all(AbTokens.space16), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + AbButton( + label: 'Not now', + onTap: () => Navigator.of(context).maybePop(), + ), + const SizedBox(width: AbTokens.space8), + AbButton( + label: 'Arm Handler', + variant: AbButtonVariant.primary, + onTap: () { + final edit = handlerSessionSettingsEdit(_opened, _value); + Navigator.of(context).maybePop(( + settings: ( + judgeTool: edit.judgeTool, + judgeModel: edit.judgeModel, + personality: _postureTouched + ? _value.personality + : null, + ), + instruction: _instruction.text, + )); + }, + ), + ], + ), + ), + ], + ), + ); + } +} + +/// The single arm flow, shared by the header shield and the away-moment hint so +/// the two can never drift: sheet → arm on confirm → latch +/// [FirstRunState.handlerArmedOnce] on every successful arm. /// -/// Cancelling arms nothing and leaves the flag false, so the next tap explains -/// again — "never shown again" starts at the first successful arm. Takes a -/// [ProviderContainer], not a WidgetRef: the caller's widget may be gone by the -/// time the dialog resolves. [context] is only used before the await. +/// Cancelling arms nothing. Takes a [ProviderContainer], not a WidgetRef: the +/// caller's widget may be gone by the time the sheet resolves. [context] is +/// re-checked with `context.mounted` past every await, because the refusal path +/// below shows a second surface after one. /// /// The goal comes from [sessionOpeningPromptsProvider] rather than from the -/// caller: both arm surfaces are one tap over a session the user did not have -/// to describe, and the sentence they started it with is the only statement of +/// caller: both arm surfaces sit over a session the user did not have to +/// describe, and the sentence they started it with is the only statement of /// intent that exists. Null when nothing was remembered — a session adopted at /// launch, one started from an empty composer, or one already armed once — and -/// an omitted goal leaves the bridge's stored one untouched, so the payload-free -/// arm is still exactly what those sessions get. -/// -/// The service is resolved AFTER the explainer, never captured before it: the -/// dialog stays open for as long as the user reads it, and a transport -/// reconnect in that window disposes the build-time instance, whose `arm` then -/// returns having sent nothing. The user tapped "Arm Handler", the dialog -/// closed, and they walk away believing the session is watched. -Future armWithFirstRunExplainer({ +/// an omitted goal leaves the bridge's stored one untouched, so a re-arm is +/// still exactly the payload-free arm those sessions want. +/// +/// The service is resolved AFTER the sheet, never captured before it: the sheet +/// stays open for as long as the user reads it, and a transport reconnect in +/// that window disposes the build-time instance, whose `arm` then returns having +/// sent nothing. The user tapped "Arm Handler", the sheet closed, and they walk +/// away believing the session is watched. +/// +/// What the sheet sends is a DELTA: a control the user never touched sends +/// nothing, so an arm cannot clear a judge or posture the bridge holds and this +/// app has not yet been told about. +Future armWithSheet({ required BuildContext context, required ProviderContainer container, required String terminalId, @@ -112,22 +384,145 @@ Future armWithFirstRunExplainer({ String? agentLabel, bool? judgeCapable, }) async { + // Asked BEFORE the arm sheet, never after: a sheet that cannot commit is a + // form the user fills in only to be told it was never going to send. + final refusal = focusedServiceOrNull( + container, + (s) => s.handlerService, + )?.currentState.entitlement; + if (refusal != null) { + if (!await _showHandlerRefusal(context, refusal)) return; + if (!context.mounted) return; + await openUpgrade(context, container); + if (!context.mounted) return; + // Falls THROUGH into the ordinary arm rather than re-reading the refusal + // it just showed: nothing re-emits a status frame when a device token is + // re-minted, so the app's copy is at its stalest exactly here — the moment + // after an upgrade. The bridge reads its verdict live at the arm, so + // letting the arm run is what asks the only party that knows; a refusal + // that still holds comes back on the frame that arm itself raises, and the + // latch below is what speaks it. + } final goal = container.read(sessionOpeningPromptsProvider)[terminalId]; - if (!container.read(firstRunProvider).handlerArmedOnce) { - final ok = await showHandlerArmExplainer( + final decision = await showHandlerArmSheet( + context, + terminalId: terminalId, + initial: handlerSessionSettingsFor( + focusedServiceOrNull(container, (s) => s.handlerService), + terminalId, + ), + agentObservable: agentObservable, + agentLabel: agentLabel, + hasOpeningPrompt: goal != null, + judgeCapable: judgeCapable, + explain: !container.read(firstRunProvider).handlerArmedOnce, + ); + if (decision == null) return; + focusedServiceOrNull(container, (s) => s.handlerService)?.arm( + terminalId: terminalId, + goal: goal, + judgeTool: decision.settings.judgeTool, + judgeModel: decision.settings.judgeModel, + personality: decision.settings.personality, + ); + latchHandlerArmedOnConfirmation( + container, + terminalId, + instruction: decision.instruction, + ); +} + +/// Says why Handler will not arm on this machine, and offers the one fix the +/// reason actually has. True when the user asked to see plans. +/// +/// Only [HandlerEntitlementReason.notEntitled] offers that, because it is the +/// only refusal a purchase lifts. Every other reason gets a single Close: a +/// button that cannot help is worse than no button, since taking it teaches the +/// user the wrong thing about what went wrong. +/// +/// A sheet rather than a toast: this is the answer to a deliberate press, it +/// carries an action, and a message that fades is how the press went unanswered +/// in the first place. +Future _showHandlerRefusal( + BuildContext context, + HandlerEntitlement entitlement, +) async => + await showAbAdaptiveSheet( context, - agentObservable: agentObservable, - agentLabel: agentLabel, - hasOpeningPrompt: goal != null, - judgeCapable: judgeCapable, + child: _HandlerRefusalSheet(entitlement: entitlement), + ) ?? + false; + +class _HandlerRefusalSheet extends StatelessWidget { + const _HandlerRefusalSheet({required this.entitlement}); + + final HandlerEntitlement entitlement; + + @override + Widget build(BuildContext context) { + final p = context.antgrid; + final upgradable = + entitlement.reason == HandlerEntitlementReason.notEntitled; + return SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: abDialogTitlePadding, + child: abDialogTitle( + // Names the plan on the one sheet that can sell it, and the state + // on the one that cannot: a title promising Pro over a machine + // whose credentials simply stopped answering points the user at a + // purchase that changes nothing. + upgradable ? 'Handler needs Pro' : 'Handler is unavailable', + onClose: () => Navigator.of(context).maybePop(false), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB( + AbTokens.space16, + 0, + AbTokens.space16, + AbTokens.space16, + ), + child: Text( + handlerEntitlementNotice(entitlement), + style: AbTokens.sansStyle( + fontSize: AbTokens.fontSm, + color: p.textSecondary, + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB( + AbTokens.space16, + 0, + AbTokens.space16, + AbTokens.space16, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + AbButton( + label: upgradable ? 'Not now' : 'Close', + onTap: () => Navigator.of(context).maybePop(false), + ), + if (upgradable) ...[ + const SizedBox(width: AbTokens.space8), + AbButton( + label: 'See plans', + variant: AbButtonVariant.primary, + onTap: () => Navigator.of(context).maybePop(true), + ), + ], + ], + ), + ), + ], + ), ); - if (!ok) return; } - focusedServiceOrNull( - container, - (s) => s.handlerService, - )?.arm(terminalId: terminalId, goal: goal); - latchHandlerArmedOnConfirmation(container, terminalId); } /// How long to wait for the bridge's `handler:status` to list a just-armed @@ -135,13 +530,19 @@ Future armWithFirstRunExplainer({ /// must not read as a failed arm. const kHandlerArmConfirmWindow = Duration(seconds: 30); -/// What a confirmed arm retires. Two things, both keyed on the bridge REPORTING -/// the session armed (its `handler:status` lists [terminalId]) rather than on -/// the send: the arm is fire-and-forget, and a dropped one must leave the next -/// attempt exactly what this one had. +/// How to cancel the latch in flight for a terminal, while one is. Keyed by +/// terminal rather than owned by a widget because the latch outlives every +/// widget involved: the sheet that started it is already popped, and the +/// session's own surfaces are what a user walks away from. +final _armLatches = {}; + +/// What a confirmed arm retires, and the one thing it releases. All of it is +/// keyed on the bridge REPORTING the session armed (its `handler:status` lists +/// [terminalId]) rather than on the send: the arm is fire-and-forget, and a +/// dropped one must leave the next attempt exactly what this one had. /// -/// [FirstRunState.handlerArmedOnce] — so a dropped send keeps the explainer, the -/// labeled shield and the away hint alive. +/// [FirstRunState.handlerArmedOnce] — so a dropped send keeps the labeled +/// shield, the away hint and the sheet's explanatory paragraph alive. /// /// The session's remembered opening prompt — so only a FIRST arm seeds a goal. /// A plain disarm leaves the bridge nothing to rehydrate, so a re-arm carrying @@ -152,32 +553,212 @@ const kHandlerArmConfirmWindow = Duration(seconds: 30); /// how every install that has armed once keeps re-seeding. /// /// The subscription self-cancels on confirmation or after -/// [kHandlerArmConfirmWindow]; an unconfirmed arm simply retires nothing. +/// [kHandlerArmConfirmWindow]; an unconfirmed arm retires nothing — and says so +/// where an instruction was riding on it. +/// +/// [instruction] is the arm sheet's typed text, and confirmation is the ONLY +/// moment it can be sent. `handler:instruct` reaching a bridge with no armed +/// session is dropped outright — logged to a stdout no phone reads — so the +/// same sentence issued beside the arm is lost, not queued. Nor may it ride the +/// arm as a goal: `instruct` is the one feed point for instruction-scoped +/// authorization, so the same words as a goal grant nothing and arrive as work +/// without the permission they imply. void latchHandlerArmedOnConfirmation( ProviderContainer container, - String terminalId, -) { + String terminalId, { + String? instruction, +}) { + // A re-tap while the bridge has not answered opens the sheet again — the + // shield still reads unarmed — so two latches can be live over one session + // and both would fire on the same status, sending two instructions for one + // intended arm. The newest supersedes. + // + // It does NOT inherit the older one's sentence, because the reopened sheet + // builds a fresh composer: the second pass is blank unless the user retypes. + // So a superseded latch that was carrying words reports them lost rather than + // dropping them silently — the replacement usually carries none, and this is + // the only surface that ever held them. + _armLatches.remove(terminalId)?.call(); ProviderSubscription>? sub; Timer? timeout; - bool confirmed(HandlerState? state) => - state?.sessions.containsKey(terminalId) ?? false; - void latch() { + void stop() { timeout?.cancel(); + timeout = null; sub?.close(); sub = null; + } + + bool confirmed(HandlerState? state) => + state?.sessions.containsKey(terminalId) ?? false; + /// The other way an arm ends: the bridge answered, and its answer was no. + /// Ends the latch the same way a confirmation does — nothing is retired, and + /// the send is reported rather than left to time out in silence. + void refuse(HandlerEntitlement entitlement) { + _armLatches.remove(terminalId); + stop(); + _reportArmRefused(container, entitlement, instruction); + } + + void latch() { + _armLatches.remove(terminalId); + stop(); container.read(sessionOpeningPromptsProvider.notifier).forget(terminalId); container.read(firstRunProvider.notifier).markHandlerArmed(); + _sendArmInstruction(container, terminalId, instruction); } + _armLatches[terminalId] = () { + stop(); + _reportArmInstructionLost( + container, + instruction, + reason: + 'You re-armed before the first attempt was confirmed, so the ' + 'instruction you typed with it was not sent.', + ); + }; sub = container.listen(handlerStateProvider, (_, next) { - if (confirmed(next.value)) latch(); + if (confirmed(next.value)) { + latch(); + return; + } + // A frame carrying a refusal is the bridge saying so as of that frame, and + // the refused arm raises one itself — so this answers within a round trip + // instead of after the confirmation window. Only frames that ARRIVE count, + // never the state already held: the refusal the user walked through to get + // here is still sitting there, and reading it would report an arm that is + // still in flight as dead. + final entitlement = next.value?.entitlement; + if (entitlement != null) refuse(entitlement); }); timeout = Timer(kHandlerArmConfirmWindow, () { - sub?.close(); - sub = null; + _armLatches.remove(terminalId); + stop(); + _reportArmInstructionLost(container, instruction); }); // The status may already list the session (re-arm race after a disarm the // bridge never processed) — check once so the latch doesn't wait on a // change that never comes. if (confirmed(container.read(handlerStateProvider).value)) latch(); } + +/// Sends the arm sheet's sentence, once the bridge has confirmed the arm. +/// +/// The service is RE-RESOLVED here and never captured across the sheet or the +/// latch window: this runs up to [kHandlerArmConfirmWindow] after the sheet +/// closed, and a transport reconnect in that window disposes the build-time +/// instance, whose `instruct` then sends nothing at all. +/// +/// The three-valued result is honoured rather than discarded. `duplicate` is +/// reachable (a re-arm carrying the same sentence as one still outstanding) and +/// `empty` means no service resolved — on a screen the user is about to walk +/// away from, an unsent instruction must not look like a sent one. +void _sendArmInstruction( + ProviderContainer container, + String terminalId, + String? text, +) { + if (text == null || text.trim().isEmpty) return; + final result = + focusedServiceOrNull( + container, + (s) => s.handlerService, + )?.instruct(terminalId, text) ?? + HandlerInstructResult.empty; + switch (result) { + case HandlerInstructResult.sent: + return; + // The arm IS confirmed on this path — it is the only thing that gets here — + // so the send is what failed, and blaming the arm would point the user at a + // session that is armed and watching. + case HandlerInstructResult.empty: + _reportArmInstructionLost( + container, + text, + reason: + 'The connection dropped before your instruction went out, so it ' + 'was not sent. The session is armed — send it again from the ' + 'backlog.', + ); + // Already outstanding, so the words ARE queued. Saying "nothing was queued" + // here invites a re-send that stacks the same work twice. + case HandlerInstructResult.duplicate: + _reportArmInstructionLost( + container, + text, + title: 'Already queued', + reason: 'That instruction is already outstanding on this session.', + ); + } +} + +/// Says so when the sentence never made it. +/// +/// An arm the bridge never confirms is indistinguishable from a dropped send — +/// a refused entitlement emits a status that does not list the session — so +/// without this the user's words vanish with no surface holding them, on the +/// one screen whose whole job is to set expectations before they walk away. +/// +/// [reason] names what actually went wrong. The default is the timeout's — the +/// only caller that genuinely never saw a confirmation — because a toast that +/// blames the arm on a path where the arm succeeded sends the user looking in +/// the wrong place. +void _reportArmInstructionLost( + ProviderContainer container, + String? text, { + String title = 'Nothing was queued', + String reason = + 'Handler never confirmed the arm, so your instruction was not sent.', +}) { + if (text == null || text.trim().isEmpty) return; + _reportArmFailure(container, title: title, description: reason); +} + +/// Says why an arm the bridge REFUSED went nowhere. +/// +/// The one report on this flow that fires with no instruction riding on it: a +/// plain arm that is refused loses no words of the user's, so the refusal +/// itself is the whole of what there is to say — and saying nothing is what +/// makes a paid feature indistinguishable from a dropped tap. +void _reportArmRefused( + ProviderContainer container, + HandlerEntitlement entitlement, + String? instruction, +) { + final notice = handlerEntitlementNotice(entitlement); + final lost = instruction != null && instruction.trim().isNotEmpty; + _reportArmFailure( + container, + title: 'Handler not armed', + description: lost + ? '$notice The instruction you typed with it was not sent.' + : notice, + ); +} + +/// The one way this flow speaks once its widgets are gone. +/// +/// The navigator's OVERLAY, not its context: `Overlay.maybeOf` reads an +/// inherited marker planted inside each overlay entry, so it answers only from +/// within a mounted route. The navigator's own element sits above every entry +/// and resolves to null, which would make this whole path a silent no-op — and +/// there is no widget of ours alive here to ask instead. +void _reportArmFailure( + ProviderContainer container, { + required String title, + required String description, +}) { + final overlay = container + .read(rootNavigatorKeyProvider) + .currentState + ?.overlay; + if (overlay == null) return; + showAbToastOn( + overlay, + toast: AbToast( + icon: AbIcons.warning, + title: title, + description: description, + ), + ); +} diff --git a/app/lib/widgets/handler/handler_away_hint.dart b/app/lib/widgets/handler/handler_away_hint.dart index 6792b6d8..1a1cd712 100644 --- a/app/lib/widgets/handler/handler_away_hint.dart +++ b/app/lib/widgets/handler/handler_away_hint.dart @@ -46,11 +46,11 @@ class HandlerAwayHint extends ConsumerWidget { onTap: service == null || activeId == null ? null : () { - // Fire-and-forget: past the explainer await everything runs + // Fire-and-forget: past the sheet await everything runs // on the container, never this widget's ref, and none of it // can throw (the arm send is a plain fire-and-forget too). unawaited( - armWithFirstRunExplainer( + armWithSheet( context: context, container: ref.container, terminalId: activeId, diff --git a/app/lib/widgets/handler/handler_backlog_drawer.dart b/app/lib/widgets/handler/handler_backlog_drawer.dart index f359524a..2b329c95 100644 --- a/app/lib/widgets/handler/handler_backlog_drawer.dart +++ b/app/lib/widgets/handler/handler_backlog_drawer.dart @@ -7,7 +7,6 @@ import '../../design/ab_icons.dart'; import '../../design/ab_tokens.dart'; import '../../design/widgets/ab_adaptive_sheet.dart'; import '../../design/widgets/ab_button.dart'; -import '../../design/widgets/ab_chip.dart'; import '../../design/widgets/ab_dialog.dart'; import '../../design/widgets/ab_empty_state.dart'; import '../../design/widgets/ab_icon.dart'; @@ -21,18 +20,9 @@ import '../../providers/providers.dart'; import '../../providers/sessions.dart'; import '../../services/handler_service.dart'; import '../../util/detached.dart'; +import 'handler_instruction_composer.dart'; import 'handler_item_status.dart'; - -/// The 1-tap presets. Each label is verbatim the instruction the chip sends: a -/// chip is exactly the sentence the user would have typed, which is what keeps -/// it on the same authorization path as typed text. Keeping label and payload -/// one string is what stops the two drifting apart. -const handlerPresetInstructions = [ - 'Run Tests', - 'Commit', - 'Create PR', - 'Clean Build', -]; +import 'handler_session_settings.dart'; /// What the sheet is called, and what it is called for. The surface keeps its /// own name first: a card, a menu entry and the pill all send the user here by @@ -193,9 +183,9 @@ String? _sessionName(WidgetRef ref, String terminalId) { /// /// So it opens with the act rather than the absence, and answers the question an /// empty list raises in every one of those cases — whether an unfed Handler is -/// doing anything at all. It offers no button: the presets and the field are -/// already on screen under this list, and a second route to one action is how -/// one action ends up with two names. +/// doing anything at all. It offers no button: the composer is already on +/// screen under this list, and a second route to one action is how one action +/// ends up with two names. /// /// [hasGoal] is what stops the invitation reading as "nothing was received". /// The goal stands above this list and the bridge extracts items from it, so a @@ -270,7 +260,7 @@ class _GoalLine extends StatelessWidget { } } -/// Presets and the free-text field, here rather than pinned above the composer. +/// The instruction box, here rather than pinned above the session composer. /// /// Queueing work for Handler to do later is a different act from talking to the /// agent now, and the two fields stacked said otherwise: same shape, same send @@ -331,17 +321,14 @@ class _InstructionComposerState extends ConsumerState<_InstructionComposer> { return null; } - /// Preset chips and typed text land here alike: one path, one message type, - /// so a rule that later applies to instructions cannot miss the chips. - /// /// Resolved through the container for the same reason [_sendEdit] is: this /// fires from a tap inside a sheet, which the send itself may pop. /// - /// The service owns both the empty check and the debounce, so a chip and the - /// field are refused on the same terms; this only decides what the user is - /// told about it. A blank field is silent — there was nothing to send and - /// the user knows it — while a duplicate is a send that looked identical to - /// one that worked and did not happen, on the primary action of the surface. + /// The service owns both the empty check and the debounce; this only decides + /// what the user is told about it. A blank field is silent — there was + /// nothing to send and the user knows it — while a duplicate is a send that + /// looked identical to one that worked and did not happen, on the primary + /// action of the surface. HandlerInstructResult _instruct(String text) { final result = focusedServiceOrNull( @@ -383,6 +370,54 @@ class _InstructionComposerState extends ConsumerState<_InstructionComposer> { _input.clear(); } + HandlerSessionSettingsValue? _judge; + + /// Seeded once, from the service rather than from a provider — the same rule + /// [_SettingsSheet] follows: reseeding on every rebuild lets the status + /// snapshot that confirms an edit land mid-gesture and reset the control. + HandlerSessionSettingsValue get _judgeValue => + _judge ?? + handlerSessionSettingsFor( + focusedServiceOrNull(ref.container, (s) => s.handlerService), + widget.terminalId, + ); + + /// The judge is what READS the sentence typed above it, so picking one here + /// commits immediately rather than waiting on some absent Save. + /// + /// `armed: true` on an already-armed session is the bridge's EDIT path — but + /// on a session that is GONE it is a fresh arm, which retires that slot's undo + /// offers. This composer mounts under `session != null`, but the judge PANEL + /// it opens is a route that outlives it: a wrap-up disarming the session under + /// an open drawer unmounts this State while the panel is still up and can + /// still call back. Hence both guards, not just the mount condition. + void _commitJudge(HandlerJudgePick pick) { + if (!mounted) return; + final service = focusedServiceOrNull( + ref.container, + (s) => s.handlerService, + ); + final stillArmed = + ref.read(handlerStateProvider).value?.sessions[widget.terminalId] != + null; + if (service == null || !stillArmed) return; + final next = ( + judgeTool: pick.judgeTool, + judgeModel: pick.judgeModel, + personality: _judgeValue.personality, + ); + final edit = handlerSessionSettingsEdit(_judgeValue, next); + // Pinned only once the send is real: `_judgeValue` prefers `_judge`, so a + // value pinned ahead of a dropped send is one no status frame can correct, + // and every later delta is computed against a `from` the bridge never held. + service.arm( + terminalId: widget.terminalId, + judgeTool: edit.judgeTool, + judgeModel: edit.judgeModel, + ); + setState(() => _judge = next); + } + @override Widget build(BuildContext context) { ref.listen(handlerStateProvider, (_, next) => _adoptGrant(next.value)); @@ -405,61 +440,34 @@ class _InstructionComposerState extends ConsumerState<_InstructionComposer> { child: Column( mainAxisSize: MainAxisSize.min, children: [ - // Wrapped, not scrolled. Four md chips overrun a narrow phone by - // roughly one label, and a strip that scrolls says so only to - // someone who already drags it — so the last preset was reachable - // only by accident, on the fastest route this sheet has to a useful - // backlog. A second run costs one row on the widths that need it and - // nothing on the widths that don't. - Padding( - padding: const EdgeInsets.symmetric( - horizontal: AbTokens.space16, - vertical: AbTokens.space10, - ), - child: Wrap( - spacing: AbTokens.space14, - runSpacing: AbTokens.space10, - children: [ - for (final preset in handlerPresetInstructions) - AbChip.label( - label: preset, - color: p.textSecondary, - size: AbChipSize.md, - onTap: () => _instruct(preset), - ), - ], - ), - ), Padding( padding: EdgeInsets.fromLTRB( AbTokens.space16, - 0, + AbTokens.space8, AbTokens.space16, stillHeld == null && granted == null ? AbTokens.space8 : AbTokens.space4, ), - child: Row( - children: [ - Expanded( - child: AbTextField( - controller: _input, - // "Send", not "Add": a sentence here can take a line off this - // list or reword one as readily as it can add one, and a - // control promising to add is at its most wrong exactly when - // the user is cancelling something. - hintText: 'Send an instruction…', - textInputAction: TextInputAction.send, - onSubmitted: (_) => _submitTyped(), - ), - ), - const SizedBox(width: AbTokens.space6), - AbIconButton( - icon: AbIcons.send, - tooltip: 'Send to Handler', - onTap: _submitTyped, - ), - ], + child: HandlerInstructionComposer( + terminalId: widget.terminalId, + controller: _input, + // "Send", not "Add": a sentence here can take a line off this + // list or reword one as readily as it can add one, and a control + // promising to add is at its most wrong exactly when the user is + // cancelling something. + hintText: 'Send an instruction…', + judge: ( + judgeTool: _judgeValue.judgeTool, + judgeModel: _judgeValue.judgeModel, + ), + onJudgeChanged: _commitJudge, + judgeScopeNote: handlerJudgeScopeNextPass, + send: HandlerComposerSend( + tooltip: 'Send to Handler', + semanticLabel: 'Send to Handler', + onSend: _submitTyped, + ), ), ), // Answered where the send was made, and in the same verb the field, @@ -468,8 +476,8 @@ class _InstructionComposerState extends ConsumerState<_InstructionComposer> { // is unchanged, and the tail row saying so may be scrolled away — // which is a broken button, not a debounce. if (stillHeld != null) - // Full width so the line starts on the field's own left edge; the - // column around it centres anything that sizes to its child. + // Full width so the line starts on the composer's own left edge; + // the column around it centres anything that sizes to its child. SizedBox( width: double.infinity, child: Padding( @@ -517,8 +525,8 @@ class _GrantEcho extends StatelessWidget { @override Widget build(BuildContext context) { final p = context.antgrid; - // Full width so the lines start on the field's own left edge; the column - // around it centres anything that sizes to its child. + // Full width so the lines start on the composer's own left edge; the + // column around it centres anything that sizes to its child. return SizedBox( width: double.infinity, child: Padding( diff --git a/app/lib/widgets/handler/handler_instruction_composer.dart b/app/lib/widgets/handler/handler_instruction_composer.dart new file mode 100644 index 00000000..c6db6d46 --- /dev/null +++ b/app/lib/widgets/handler/handler_instruction_composer.dart @@ -0,0 +1,319 @@ +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../design/ab_colors.dart'; +import '../../design/ab_tokens.dart'; +import '../../design/widgets/ab_composer_send_button.dart'; +import '../../design/widgets/ab_prompt_field.dart'; +import '../../design/widgets/ab_tooltip.dart'; +import '../transcript/composer/smart_enter.dart'; +import 'handler_judge_chip.dart'; +import 'handler_session_settings.dart'; + +/// The send key AND what pressing it means, supplied whole by the host. +/// +/// A host that COLLECTS rather than sends passes none: the arm sheet's commit +/// is its own `[Arm Handler]` button, and a second key beside it would be +/// disabled on empty text while that button stayed enabled — two controls for +/// one action, disagreeing about whether the action is available. With no +/// [HandlerComposerSend] the composer renders no key at all, so nothing on +/// that surface promises a commit it does not make. +class HandlerComposerSend { + const HandlerComposerSend({ + required this.tooltip, + required this.semanticLabel, + required this.onSend, + }); + + final String tooltip; + + /// [ComposerSendButton] paints a bare glyph, so the label is the only thing + /// a screen reader has to say what this key does. + final String semanticLabel; + + final VoidCallback onSend; +} + +/// The one box the user types Handler instructions into, shared by the arm +/// sheet and the backlog drawer. +/// +/// CONTROLLED: the host owns the [TextEditingController] and the send verb; +/// the composer owns focus, the Enter policy, the bordered box, the growth cap +/// and the judge chip. There is no `mode` flag, because the two hosts differ in +/// what a commit MEANS — one queues a sentence on the wire, the other holds it +/// until the arm lands — and that is supplied whole via [send] rather than +/// selected by a boolean. +/// +/// The composer NEVER clears or disposes [controller]: clearing is a statement +/// that the send happened, which only the host knows (the drawer clears on a +/// sent instruction alone), and a composer that disposed it would kill the arm +/// sheet's text before its own pop could read it. +/// +/// Host commentary — a duplicate-send line, a grant echo — stacks BELOW this +/// widget, outside its border: the box is the instrument, not the transcript. +class HandlerInstructionComposer extends ConsumerStatefulWidget { + const HandlerInstructionComposer({ + super.key, + required this.terminalId, + required this.controller, + required this.hintText, + required this.judge, + required this.onJudgeChanged, + required this.judgeScopeNote, + this.send, + }); + + final String terminalId; + + /// Host-owned and host-disposed. + final TextEditingController controller; + + final String hintText; + + /// The judge that will READ what is typed here — see [HandlerJudgeChip]. + final HandlerJudgePick judge; + final ValueChanged onJudgeChanged; + + /// When a judge pick takes effect, in the host's own terms: + /// [handlerJudgeScopeOnArm] or [handlerJudgeScopeNextPass]. + final String judgeScopeNote; + + /// Null on a host that collects rather than sends — see + /// [HandlerComposerSend]. + final HandlerComposerSend? send; + + @override + ConsumerState createState() => + _HandlerInstructionComposerState(); +} + +class _HandlerInstructionComposerState + extends ConsumerState { + late final FocusNode _focus; + bool _hovered = false; + bool _focused = false; + bool _canSend = false; + + @override + void initState() { + super.initState(); + _focus = FocusNode(); + // Intercepts Enter/Shift+Enter before the field's own key handling — the + // same pattern the New Session composer and the transcript's RichComposer + // both use. + _focus.onKeyEvent = _onKey; + _focus.addListener(_onFocusChanged); + widget.controller.addListener(_onText); + _canSend = _hasText; + } + + @override + void didUpdateWidget(HandlerInstructionComposer oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.controller, widget.controller)) { + oldWidget.controller.removeListener(_onText); + widget.controller.addListener(_onText); + _canSend = _hasText; + } + } + + @override + void dispose() { + widget.controller.removeListener(_onText); + _focus.onKeyEvent = null; + _focus.removeListener(_onFocusChanged); + _focus.dispose(); + super.dispose(); + } + + bool get _hasText => widget.controller.text.trim().isNotEmpty; + + void _onFocusChanged() { + if (_focus.hasFocus == _focused) return; + setState(() => _focused = _focus.hasFocus); + } + + /// Every keystroke passes through here, so rebuild only on the one bit the + /// control row actually renders — whether the key is live. + void _onText() { + final next = _hasText; + if (next == _canSend) return; + setState(() => _canSend = next); + } + + /// Enter policy, delegated to [decideEnter] so this box and the transcript + /// composer cannot answer a return key differently. + /// + /// `onKeyEvent` fires for hardware keys only, so `hasHardwareKeyboard` is a + /// fact here rather than an assumption; a plain [AbPromptField] has no + /// document model, so no line it holds is ever anything but plain. That + /// leaves the soft keyboard's Enter as a newline and mobile committing + /// through the send key — which is why this field carries no + /// `TextInputAction.send`, whose behaviour is the exact opposite. + KeyEventResult _onKey(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + final key = event.logicalKey; + if (key != LogicalKeyboardKey.enter && + key != LogicalKeyboardKey.numpadEnter) { + return KeyEventResult.ignored; + } + final keyboard = HardwareKeyboard.instance; + final action = decideEnter( + caretLineIsPlain: true, + isShift: keyboard.isShiftPressed, + isCtrlOrCmd: keyboard.isControlPressed || keyboard.isMetaPressed, + hasHardwareKeyboard: true, + ); + final send = widget.send; + if (action == EnterAction.send && send != null && _canSend) { + send.onSend(); + return KeyEventResult.handled; + } + // A send this surface cannot make falls back to the newline rather than to + // nothing: on a host with no send key there is no commit to reach, and + // swallowing the key would leave the field looking frozen. + _insertNewline(); + return KeyEventResult.handled; + } + + /// Written by hand rather than left to fall through: a hardware Enter's + /// default multiline behaviour depends on platform text-editing shortcuts + /// this composer should not have to depend on. + void _insertNewline() { + final value = widget.controller.value; + // `isValid` only asserts the offsets are non-negative — it says nothing + // about them fitting the text — and this controller is owned by the HOST, + // which may have replaced the text without moving the selection. An + // out-of-range one would make replaceRange throw from inside a key handler. + final inRange = + value.selection.isValid && + value.selection.end <= value.text.length && + value.selection.start <= value.selection.end; + final selection = inRange + ? value.selection + : TextSelection.collapsed(offset: value.text.length); + widget.controller.value = TextEditingValue( + text: value.text.replaceRange(selection.start, selection.end, '\n'), + selection: TextSelection.collapsed(offset: selection.start + 1), + ); + } + + @override + Widget build(BuildContext context) { + final p = context.antgrid; + final send = widget.send; + // One "armed instrument" surface, the same focus contract as the New + // Session composer and RichComposer: default -> strong on hover -> accent + // while the field has focus. + final borderColor = _focused + ? p.accent + : _hovered + ? p.borderStrong + : p.borderDefault; + + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: AnimatedContainer( + duration: AbTokens.motionDefault, + curve: Curves.easeOut, + decoration: BoxDecoration( + border: Border.all(color: borderColor), + borderRadius: AbTokens.borderRadius8, + color: p.bgSurface, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + AbTokens.space12, + AbTokens.space12, + AbTokens.space12, + AbTokens.space6, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Shell-prompt marker, the same "type here" affordance the + // New Session composer carries, so the two boxes read as one + // instrument. + Padding( + padding: const EdgeInsets.only(top: AbTokens.space2), + child: Text( + '❯', + style: AbTokens.monoStyle( + fontSize: AbTokens.fontMd, + fontWeight: FontWeight.w600, + color: p.accent, + ), + ), + ), + const SizedBox(width: AbTokens.space8), + Expanded( + // Same cap as RichComposer (~8 lines): a long instruction + // scrolls inside the box rather than pushing the backlog + // list — or the arm sheet's own commit — off screen. + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 176), + child: AbPromptField( + key: const Key('handler-instruction-field'), + controller: widget.controller, + focusNode: _focus, + hintText: widget.hintText, + ), + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB( + AbTokens.space10, + 0, + AbTokens.space10, + AbTokens.space10, + ), + child: Row( + children: [ + // The chip takes the row's slack so its compound label sheds + // inside a bounded width (ComposerChip needs one); the send + // key stays last and intrinsic, so no gap opens after it. + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: HandlerJudgeChip( + terminalId: widget.terminalId, + judge: widget.judge, + onChanged: widget.onJudgeChanged, + scopeNote: widget.judgeScopeNote, + ), + ), + ), + if (send != null) ...[ + const SizedBox(width: AbTokens.space8), + Semantics( + button: true, + enabled: _canSend, + label: send.semanticLabel, + child: AbTooltip( + message: send.tooltip, + child: ComposerSendButton( + key: const Key('handler-instruction-send'), + onTap: _canSend ? send.onSend : null, + ), + ), + ), + ], + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/widgets/handler/handler_item_status.dart b/app/lib/widgets/handler/handler_item_status.dart index 2b933b51..91dd4706 100644 --- a/app/lib/widgets/handler/handler_item_status.dart +++ b/app/lib/widgets/handler/handler_item_status.dart @@ -130,25 +130,65 @@ String unwatchableNotice(String? agentLabel) => const escalateOnlyNotice = "This judge can't run headless, so every pause comes to you."; +/// A plan name as the user meets it on their own billing surfaces, from the +/// lowercase label the token carries. +String _planLabel(String tier) => + tier.isEmpty ? tier : '${tier[0].toUpperCase()}${tier.substring(1)}'; + +/// Why Handler will not arm on this machine — the sentence a refusal is spoken +/// with, wherever it is spoken. +/// +/// One string per reason, shared by the shield's tooltip and the sheet the +/// shield opens, for the reason this whole file exists: a user who hovers and +/// then taps must not be told two different things about one refusal. +/// +/// Each sentence names the fix its own reason actually has, which is why the +/// two are not merged. `not_entitled` is a plan the user can change; the tier +/// is named when the bridge could read one, because "you are on Free" answers +/// a question "you need Pro" leaves open. `unreadable` is a machine whose +/// credentials stopped answering — an upgrade buys nothing there, and offering +/// one would sell a plan the user may already be paying for. +/// +/// The null arm claims nothing beyond unavailability: a reason this app has no +/// sentence for still has to say that arming will not work, since silence is +/// the failure being fixed. +String handlerEntitlementNotice(HandlerEntitlement e) => switch (e.reason) { + HandlerEntitlementReason.notEntitled => + e.tier == null + ? 'Handler is part of Pro, and the plan this machine is signed in on ' + "doesn't include it." + : 'Handler is part of Pro. This machine is signed in on the ' + '${_planLabel(e.tier!)} plan.', + HandlerEntitlementReason.unreadable => + "Antgrid can't confirm this machine's plan, so Handler is held back. Sign " + 'out and back in on the computer running this project.', + null => "Handler isn't available on this machine right now.", +}; + /// What the shield says before it is pressed. /// /// Top-level so the precedence is unit-testable without pumping the panel, the -/// same reason [handlerArmExplainerBody] is. Arming is one tap, so this tooltip -/// is the only pre-arm surface that answers EVERY time: the explainer carries -/// the same facts but sits behind FirstRunState.handlerArmedOnce, a once-ever -/// latch, while coverage is per-agent — so a user whose first arm was a capable -/// agent would meet an escalate-only one with no warning at all. +/// same reason [handlerArmExplainerBody] is. This tooltip is the only surface +/// that answers before the shield is pressed at all: the arm sheet carries the +/// same facts, but only once the user has committed far enough to open it. /// /// [observable] false outranks [judgeCapable] false: a session that reports /// nothing cannot be watched, which makes what its judge could have done moot. /// Either being null claims nothing, exactly as the catalog requires. +/// +/// [entitlement] outranks both, and is outranked only by [armed]. Coverage +/// describes what an arm WOULD get, and a refused machine has no arm to get +/// it — but a session armed before the refusal is still the user's to disarm, +/// so that answer stays first. String handlerShieldTooltip({ required bool armed, required bool? observable, required bool? judgeCapable, String? agentLabel, + HandlerEntitlement? entitlement, }) { if (armed) return 'Disarm Handler'; + if (entitlement != null) return handlerEntitlementNotice(entitlement); if (observable == false) return unwatchableNotice(agentLabel); if (judgeCapable == false) return escalateOnlyNotice; return 'Arm Handler'; diff --git a/app/lib/widgets/handler/handler_judge_chip.dart b/app/lib/widgets/handler/handler_judge_chip.dart new file mode 100644 index 00000000..97314056 --- /dev/null +++ b/app/lib/widgets/handler/handler_judge_chip.dart @@ -0,0 +1,417 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../design/ab_colors.dart'; +import '../../design/ab_icons.dart'; +import '../../design/ab_tokens.dart'; +import '../../design/widgets/ab_icon.dart'; +import '../../design/widgets/ab_menu.dart'; +import '../../design/widgets/ab_separator.dart'; +import '../../design/widgets/ab_text_field.dart'; +import '../../models/agent_descriptor.dart'; +import '../../models/agent_event.dart'; +import '../../providers/agent_catalog.dart'; +import '../../providers/capability_catalog.dart'; +import '../../util/detached.dart'; +import '../new_session/environment_menu.dart'; +import 'handler_session_settings.dart'; + +/// The judge, beside the box that types the sentence it will read. +/// +/// The judge belongs in a composer's control row and not only in a settings +/// block because it is what actually READS the typed instruction: the bridge +/// splits a sentence into backlog items only when the judge can run headless, +/// and hands the whole sentence over as one item otherwise. Naming it here is +/// naming who is about to read this. +/// +/// Compound label — judge, then model — because the model qualifies the judge +/// and never stands alone; [ComposerChip.secondaryLabel] is what makes the +/// model half shed first under width pressure while the judge name survives. +class HandlerJudgeChip extends ConsumerWidget { + const HandlerJudgeChip({ + super.key, + required this.terminalId, + required this.judge, + required this.onChanged, + required this.scopeNote, + this.enabled = true, + }); + + final String terminalId; + final HandlerJudgePick judge; + final ValueChanged onChanged; + + /// When the pick takes effect, in the host's own terms — see + /// [handlerJudgeScopeOnArm] and [handlerJudgeScopeNextPass]. The chip cannot + /// derive it: the same control means "from the moment it arms" on one surface + /// and "on the next pass" on the other. + final String scopeNote; + + final bool enabled; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final catalog = ref.watch(agentCatalogProvider); + // The chip names what will actually run rather than the word "Default": the + // pick and the inherited default are told apart by the check mark in the + // panel, which is the one place that question is being asked. + final effective = handlerEffectiveJudge(ref, terminalId, judge.judgeTool); + // Read unconditionally, even with no model override and no panel open: + // the first read of a key kicks off an async load and answers empty, so a + // catalog first touched at the drill would open the model pane on the + // "nobody has listed these" branch and swap it for a list a frame later. + // Starting here means the answer is already settled by the time it shows. + final models = _modelsFor(ref, effective); + return ComposerChip( + icon: AbIcons.shield, + label: effective == null + ? 'Judge' + : (catalog[effective]?.label ?? effective), + secondaryLabel: _modelLabel(models, judge.judgeModel), + enabled: enabled, + onTap: (ctx) { + final anchor = abMenuAnchorRect(ctx); + if (anchor == null) return; + detached('HandlerJudgeChip', 'open judge panel', () async { + await showAbPanel( + context: ctx, + anchorRect: anchor, + // The composer sits low on its surface, so the panel opens upward + // — and stays pinned to the chip as the model pane grows. + preferred: AbMenuPlacement.above, + builder: (_) => _JudgePanel( + terminalId: terminalId, + initial: judge, + scopeNote: scopeNote, + onChanged: onChanged, + ), + ); + }); + }, + ); + } + + /// The model's display name, or null when nothing is overridden — a chip with + /// no secondary half is the default, which needs no word of its own. + /// + /// Falls back to the raw id: a model typed by hand into the free-text branch + /// is never in the catalog, and showing it back is the only confirmation the + /// user gets that it stuck. + String? _modelLabel(List models, String? id) { + if (id == null) return null; + final matches = models.where((m) => m.id == id); + return matches.isEmpty ? id : matches.first.name; + } +} + +/// The judge's models, or empty when there is no judge to ask about. +/// +/// Shared by the chip and its panel so both start hydration from their own +/// build — the panel needs its own call because pane one can change the judge, +/// and the drill that follows must not be the first touch of the new key. +List _modelsFor(WidgetRef ref, String? tool) => + tool == null + ? const [] + : cachedModelsFor(ref, tool); + +/// One route, two panes. The model ALWAYS drills, even for a tool with three +/// models: one agent exposes twenty-odd, and a panel that changes shape per +/// agent is one the user relearns per agent. +class _JudgePanel extends ConsumerStatefulWidget { + const _JudgePanel({ + required this.terminalId, + required this.initial, + required this.scopeNote, + required this.onChanged, + }); + + final String terminalId; + final HandlerJudgePick initial; + final String scopeNote; + final ValueChanged onChanged; + + @override + ConsumerState<_JudgePanel> createState() => _JudgePanelState(); +} + +class _JudgePanelState extends ConsumerState<_JudgePanel> { + late HandlerJudgePick _pick; + late final TextEditingController _search; + late final TextEditingController _freeText; + + /// One node for both pane-two fields — only ever one of them is mounted, and + /// sharing it keeps [_enterModelPane]'s focus request from having to know + /// which branch it landed on. + late final FocusNode _searchFocus; + + bool _drilled = false; + String _query = ''; + + @override + void initState() { + super.initState(); + _pick = widget.initial; + _search = TextEditingController()..addListener(_onQueryChanged); + _freeText = TextEditingController(text: widget.initial.judgeModel ?? ''); + _searchFocus = FocusNode(); + } + + @override + void dispose() { + _search.removeListener(_onQueryChanged); + _search.dispose(); + _freeText.dispose(); + _searchFocus.dispose(); + super.dispose(); + } + + void _onQueryChanged() => setState(() => _query = _search.text); + + /// Stays open on a judge pick: the panel's other half is the model, and a + /// judge change is exactly when the model most needs revisiting. + void _pickJudge(String? tool) { + // Re-tapping the judge already in force is not a change. Firing anyway + // would hand `judgeModel: null` to the delta, which reads as "clear it" — + // so a tap that only confirmed the current pick would destroy the model + // override beside it. The panel stays open either way. + if (tool == _pick.judgeTool) return; + // The free-text field IS the model for a tool with no catalog, so it is + // cleared with the value it holds — otherwise the new judge is offered the + // previous CLI's id back, which is a flag it rejects on every pass. + _freeText.clear(); + _search.clear(); + setState(() => _pick = (judgeTool: tool, judgeModel: null)); + widget.onChanged(_pick); + } + + /// Pops: the model is the leaf of this panel, so committing it finishes the + /// errand the chip was tapped for. + void _pickModel(String? id) { + setState(() => _pick = (judgeTool: _pick.judgeTool, judgeModel: id)); + widget.onChanged(_pick); + Navigator.of(context).pop(); + } + + void _enterModelPane() { + setState(() => _drilled = true); + // autofocus fires once per FocusNode, at its first attach — a node this + // State owns survives a back-and-re-drill, so the second visit would come + // up unfocused with no error. The field is not attached yet when setState + // runs, hence the post-frame request rather than a direct one. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _searchFocus.requestFocus(); + }); + } + + String _judgeLabel(Map catalog, String? tool) => + tool == null ? 'Default' : (catalog[tool]?.label ?? tool); + + @override + Widget build(BuildContext context) { + final catalog = ref.watch(agentCatalogProvider); + final effective = handlerEffectiveJudge( + ref, + widget.terminalId, + _pick.judgeTool, + ); + // Read from pane one as well as pane two: picking a judge here changes the + // key, and the drill that follows must not be the first touch of it. + final models = _modelsFor(ref, effective); + // AbPopupSurface.width is a MAX, not a fixed width, so a narrow first pane + // followed by a wide second one visibly jumps. One SizedBox above the pane + // switch pins both. + return SizedBox( + width: 280, + child: _drilled + ? _modelPane(catalog, effective, models) + : _judgePane(catalog, effective, models), + ); + } + + Widget _judgePane( + Map catalog, + String? effective, + List models, + ) { + final p = context.antgrid; + final defaultTool = handlerEffectiveJudge(ref, widget.terminalId, null); + final judgeTools = ref.watch(judgeCapableToolsProvider); + final modelName = _pick.judgeModel == null + ? 'Default' + : _modelName(models, _pick.judgeModel!); + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const PanelSectionHeader('Judged by'), + PanelRow( + icon: AbIcons.shield, + label: defaultTool == null + ? 'Default' + : 'Default (${catalog[defaultTool]?.label ?? defaultTool})', + selected: _pick.judgeTool == null, + onTap: () => _pickJudge(null), + ), + for (final tool in judgeTools) + PanelRow( + icon: AbIcons.shield, + label: catalog[tool]?.label ?? tool, + selected: _pick.judgeTool == tool, + onTap: () => _pickJudge(tool), + ), + // A drill row, not an inline list: see the class doc. + PanelRow( + icon: AbIcons.code, + label: 'Model', + selected: false, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Bounded: a model id can be longer than the panel is wide, and + // PanelRow's trailing slot takes its intrinsic width. + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 120), + child: Text( + modelName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AbTokens.monoStyle( + fontSize: AbTokens.fontSm, + color: p.textMuted, + ), + ), + ), + const SizedBox(width: AbTokens.space6), + AbIcon(AbIcons.chevronRight, size: 12, color: p.textMuted), + ], + ), + onTap: _enterModelPane, + ), + PanelHint(widget.scopeNote), + ], + ); + } + + Widget _modelPane( + Map catalog, + String? effective, + List models, + ) { + // Escape is bound at the route and pops the whole panel. Rebinding it on + // this subtree makes the innermost enabled handler win, so Escape here goes + // BACK — the typed query survives a mis-drill. + return Actions( + actions: { + DismissIntent: CallbackAction( + onInvoke: (_) { + setState(() => _drilled = false); + return null; + }, + ), + }, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + PanelRow( + icon: AbIcons.back, + // Names the judge it returns to, not the word "back": the pane + // above is a judge list, and the model only means anything under one. + label: _judgeLabel(catalog, effective), + selected: false, + // setState, never Navigator.pop: there is one route here, and a pop + // would dismiss the whole panel along with the user's query. + onTap: () => setState(() => _drilled = false), + ), + const PanelSectionHeader('Model'), + // Pinned ABOVE the filter and outside the searched list: putting the + // model back must never require clearing a query first. + PanelRow( + icon: AbIcons.code, + label: 'Default', + selected: _pick.judgeModel == null, + onTap: () => _pickModel(null), + ), + if (models.isEmpty) + ..._freeTextBranch(_judgeLabel(catalog, effective)) + else + ..._modelListBranch(models), + ], + ), + ); + } + + /// No catalog is not a broken path: the models list is written by a CHAT + /// session of that tool, so a machine that has only ever run this agent in a + /// terminal has nothing to offer and typing the id is the only way to name a + /// model at all. + List _freeTextBranch(String judgeLabel) => [ + Padding( + padding: const EdgeInsets.all(AbTokens.space8), + child: AbTextField( + controller: _freeText, + focusNode: _searchFocus, + autofocus: true, + hintText: 'Model id', + // Committed on submit, not per keystroke: each change is a configure + // frame, and a half-typed model id is one the judge would try to run. + onSubmitted: (text) => + _pickModel(text.trim().isEmpty ? null : text.trim()), + ), + ), + PanelHint( + "This machine hasn't heard $judgeLabel list its models — type an id.", + ), + ]; + + List _modelListBranch(List models) { + final query = _query.trim().toLowerCase(); + final filtered = models.where((m) { + if (query.isEmpty) return true; + return m.name.toLowerCase().contains(query) || + m.id.toLowerCase().contains(query); + }).toList(); + + return [ + Padding( + padding: const EdgeInsets.all(AbTokens.space8), + child: AbTextField( + controller: _search, + focusNode: _searchFocus, + autofocus: true, + hintText: 'Search models…', + prefixIcon: AbIcons.search, + ), + ), + const AbSeparator.horizontal(weight: AbSeparatorWeight.strong), + ConstrainedBox( + // Nothing else caps this panel's height but the viewport. + constraints: const BoxConstraints(maxHeight: 240), + child: filtered.isEmpty + ? const Padding( + padding: EdgeInsets.all(AbTokens.space12), + child: PanelHint('No matching models'), + ) + : ListView.builder( + shrinkWrap: true, + itemCount: filtered.length, + itemBuilder: (ctx, index) { + final m = filtered[index]; + return PanelRow( + icon: AbIcons.code, + label: m.name, + selected: m.id == _pick.judgeModel, + onTap: () => _pickModel(m.id), + ); + }, + ), + ), + ]; + } + + String _modelName(List models, String id) { + final matches = models.where((m) => m.id == id); + return matches.isEmpty ? id : matches.first.name; + } +} diff --git a/app/lib/widgets/handler/handler_pa_bar.dart b/app/lib/widgets/handler/handler_pa_bar.dart index b5ca543f..60bdbbb2 100644 --- a/app/lib/widgets/handler/handler_pa_bar.dart +++ b/app/lib/widgets/handler/handler_pa_bar.dart @@ -6,15 +6,19 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../design/ab_colors.dart'; import '../../design/ab_icons.dart'; +import '../../design/ab_tokens.dart'; +import '../../design/widgets/ab_chip.dart'; import '../../design/widgets/ab_icon.dart'; import '../../design/widgets/ab_list_row.dart'; import '../../models/handler_state.dart'; import '../../providers/providers.dart'; import '../../providers/sessions.dart'; import '../../providers/value_controller.dart'; +import '../../util/detached.dart'; import '../transcript/format.dart'; import 'handler_backlog_drawer.dart'; import 'handler_item_status.dart'; +import 'handler_session_settings.dart'; /// Opens the backlog drawer for one armed terminal. typedef HandlerBacklogOpener = void Function(String terminalId); @@ -260,7 +264,50 @@ class _HandlerPaBarState extends ConsumerState { // Unstyled: AbListRow already renders a subtitle as muted chrome, and // restating it here would silently drop the row's line height. subtitle: hint == null ? null : Text(hint), - trailing: AbIcon(AbIcons.chevronUp, size: 12, color: p.textMuted), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // The posture, always — including the default. This bar is on + // screen for the whole time a session is armed, and it is the only + // place the setting is visible at all; showing it only once it has + // been changed makes "no chip" a state the user has to know how to + // read. It costs width the title is already short of (see the + // subtitle note above), which is the trade. + Builder( + builder: (chipContext) => GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => detached( + 'HandlerPaBar', + 'open session settings', + () => + showHandlerSessionSettingsSheet(chipContext, terminalId), + ), + child: AbChip.system( + // An em-dash where the bridge has reported no posture at all, + // never the default's name: this chip is read as a live fact + // about the session, and naming a preset the far end has + // never heard of is a claim about a control over nothing (see + // handlerPersonalityFromWire). The chip still opens the sheet, + // which is where that gets explained. + label: session.personality == null + ? '—' + : handlerPersonalityLabel( + session.personality!, + ).toUpperCase(), + // Tinted where nothing is judging: the posture is stored and + // inert, and a bar naming it in ordinary chrome while every + // pause escalates says the opposite of what is happening. + color: + session.observability == HandlerObservability.escalateOnly + ? p.warning + : p.textMuted, + ), + ), + ), + const SizedBox(width: AbTokens.space6), + AbIcon(AbIcons.chevronUp, size: 12, color: p.textMuted), + ], + ), onTap: () => openBacklog(terminalId), ), ); diff --git a/app/lib/widgets/handler/handler_screen.dart b/app/lib/widgets/handler/handler_screen.dart index 88d93e1d..62e29873 100644 --- a/app/lib/widgets/handler/handler_screen.dart +++ b/app/lib/widgets/handler/handler_screen.dart @@ -27,6 +27,7 @@ import 'handler_decision_card.dart'; import 'handler_item_status.dart'; import 'handler_layout.dart'; import 'handler_reply_sheet.dart'; +import 'handler_session_settings.dart'; /// Day-aware, not a bare clock: this feed is written while the user is away and /// read afterwards, so it routinely spans midnight. @@ -38,30 +39,35 @@ class HandlerScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final state = ref.watch(handlerStateProvider).value; - return _body(context, ref, state); + return _body(context, ref, ref.watch(focusedSessionHandlerStateProvider)); } - Widget _body(BuildContext context, WidgetRef ref, HandlerState? state) { + Widget _body(BuildContext context, WidgetRef ref, HandlerState state) { final p = context.antgrid; // Undo offers and wrap-up reports keep this screen alive after the last // disarm: a wrapped-up session is exactly when the force push it made at // 3am — and the account of what it did — get read. - if (state == null || - (!state.anyArmed && - state.snapshots.isEmpty && - state.wrapUps.isEmpty)) { + // + // Escalations are in the test for a different reason: they arrive on their + // own stream, so a pushed one can land before the status frame that adds + // its session, and `anyArmed` alone would answer an unanswered question + // with the arm CTA. + if (!state.anyArmed && + state.escalations.isEmpty && + state.snapshots.isEmpty && + state.wrapUps.isEmpty) { return const Padding( padding: EdgeInsets.all(AbTokens.space24), child: Center( child: AbEmptyState( icon: AbIcons.shield, - title: 'Handler is off', + title: 'Handler is off for this session', subtitle: 'Arm it with the shield at the end of the top bar. It then ' - 'watches that session, answers what it can, and escalates the ' - 'rest to you here.', + 'watches this session, answers what it can, and escalates the ' + 'rest to you here. This tab answers for one session — ' + 'another that needs you is counted on its own agent header.', ), ), ); @@ -70,27 +76,11 @@ class HandlerScreen extends ConsumerWidget { final service = serviceWhenReady(ref, handlerServiceProvider); final container = ref.container; - final entries = { - for (final s in ref.watch(activeSessionsProvider)) s.id: s, - }; - String nameOf(String terminalId) => entries[terminalId]?.name ?? terminalId; // No catalog prediction here on purpose: every row on this screen is an // ARMED session, and the bridge's own per-session observability describes // its live mode and judge pick. The pre-arm guess belongs where nothing is // armed yet (the header's shield tooltip). - // Show which session a row belongs to only when the screen is actually - // mixing rows from more than one terminal — a single-session handler - // repeating the same label on every row is noise. - final distinctTerminals = { - ...state.sessions.keys, - ...state.escalations.map((e) => e.terminalId), - ...state.activity.map((a) => a.terminalId), - ...state.snapshots.map((s) => s.terminalId), - ...state.wrapUps.map((w) => w.terminalId), - }; - final showSessionLabels = distinctTerminals.length > 1; - Future answer(HandlerEscalation e) async { if (service == null) return; if (e.kind == 'resolve_in_session') { @@ -152,18 +142,14 @@ class HandlerScreen extends ConsumerWidget { // decision card, plain row) and this is the only piece all three share, so // it is the only place the marker cannot be added to two of them and // forgotten on the third. - Widget meta(String terminalId, int at, {bool urgent = false}) => _RowMeta( - sessionName: showSessionLabels ? nameOf(terminalId) : null, - at: at, - p: p, - urgent: urgent, - ); + Widget meta(int at, {bool urgent = false}) => + _RowMeta(at: at, p: p, urgent: urgent); // The urgency test itself, once, for that same reason: spelled out at each // of the three call sites it is three chances to omit, and a fourth row // shape starts life without it. Widget escalationMeta(HandlerEscalation e) => - meta(e.terminalId, e.at, urgent: e.urgency == 'high'); + meta(e.at, urgent: e.urgency == 'high'); return CustomScrollView( slivers: [ @@ -254,26 +240,25 @@ class HandlerScreen extends ConsumerWidget { ), ], if (state.sessions.isNotEmpty) ...[ - // Never conditional on the session count. Section headers here are - // PINNED, so the band left standing over a headerless section is the - // previous one — drop this and a lone session card scrolls up under - // "NEEDS YOU", reading as an unanswered escalation. - _section('Sessions', state.sessions.length, p.textMuted, p), + // Section headers here are PINNED, so the band left standing over a + // headerless section is the previous one — drop this and the session + // card scrolls up under "NEEDS YOU", reading as an unanswered + // escalation. + _section('Session', null, p.textMuted, p), SliverList.list( children: [ for (final s in state.sessions.values) _SessionCard( session: s, - sessionName: showSessionLabels ? nameOf(s.terminalId) : null, - // The sessionNames watch above already subscribes this build - // to session-list changes, so the resolver read stays - // reactive. The bare state.defaultTool is NOT a second - // resolution rule — it only fires while the service is still + // Through the shared resolver, which carries the session-list + // subscription its own read needs — resolving off the service + // directly here would pin whatever the list said on first + // build. The bare state.defaultTool is NOT a second + // resolution rule: it only fires while the service is still // resolving (the resolver already includes it when the // service is up). judgeLabel: - s.judgeTool ?? - service?.resolvedDefaultTool(s.terminalId) ?? + handlerEffectiveJudge(ref, s.terminalId, s.judgeTool) ?? state.defaultTool ?? 'default', // Resolved at tap time, not captured here: this fires from a @@ -285,6 +270,12 @@ class HandlerScreen extends ConsumerWidget { container, (s) => s.handlerService, )?.disarm(s.terminalId), + onOpenSettings: () => detached( + 'HandlerScreen', + 'open session settings', + () => + showHandlerSessionSettingsSheet(context, s.terminalId), + ), onOpenBacklog: () => unawaited( showHandlerBacklogDrawer(context, s.terminalId), ), @@ -302,7 +293,7 @@ class HandlerScreen extends ConsumerWidget { final w = state.wrapUps[state.wrapUps.length - 1 - i]; return _WrapUpCard( wrapUp: w, - meta: meta(w.terminalId, w.at), + meta: meta(w.at), // Derived, never read off the record: an undo taken after the // wrap-up spends its entry and a re-arm retires the offers // outright, so a count frozen at compose time is a lie on the @@ -327,7 +318,7 @@ class HandlerScreen extends ConsumerWidget { final s = state.snapshots[state.snapshots.length - 1 - i]; return _SnapshotRow( snapshot: s, - meta: meta(s.terminalId, s.at), + meta: meta(s.at), pending: state.pendingUndo.contains(s.snapshotId), onUndo: () => detached('HandlerScreen', 'undo snapshot', () => undo(s)), @@ -358,11 +349,7 @@ class HandlerScreen extends ConsumerWidget { itemCount: state.activity.length, itemBuilder: (_, i) { final a = state.activity[i]; - return _ActivityRow( - record: a, - meta: meta(a.terminalId, a.at), - p: p, - ); + return _ActivityRow(record: a, meta: meta(a.at), p: p); }, ), ], @@ -382,15 +369,9 @@ class HandlerScreen extends ConsumerWidget { ); } -/// Right-aligned session/time metadata shown on escalation and activity rows. +/// Right-aligned time metadata shown on escalation and activity rows. class _RowMeta extends StatelessWidget { - const _RowMeta({ - required this.sessionName, - required this.at, - required this.p, - this.urgent = false, - }); - final String? sessionName; + const _RowMeta({required this.at, required this.p, this.urgent = false}); final int at; final AbColors p; @@ -408,11 +389,10 @@ class _RowMeta extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [ - // Above the session name, so the eye reaches it on the way down to the - // timestamp rather than after it. System-assigned data, so the mono - // uppercase chip, matching ESCALATE ONLY on the session card. + // Above the timestamp, so the eye reaches it on the way down rather + // than after it. System-assigned data, so the mono uppercase chip, + // matching ESCALATE ONLY on the session card. if (urgent) AbChip.system(label: 'URGENT', color: p.warning), - if (sessionName != null) Text(sessionName!, style: style), Text(_fmtTime(at), style: style), ], ); @@ -460,24 +440,24 @@ String? handlerParkNote(HandlerSessionState session, {DateTime? now}) { /// this panel. /// /// Only the status line's own two ends are fixed — the run-state word and the -/// Armed chip. Everything else that could grow (the judge name, the session -/// name) either shrinks or sits on a line below, so a narrow context panel or -/// a scaled text size cannot overflow the row. +/// Armed chip. Everything else that could grow (the judge name) either shrinks +/// or sits on a line below, so a narrow context panel or a scaled text size +/// cannot overflow the row. class _SessionCard extends StatelessWidget { const _SessionCard({ required this.session, - required this.sessionName, required this.judgeLabel, required this.onDisarm, + required this.onOpenSettings, required this.onOpenBacklog, }); final HandlerSessionState session; - final String? sessionName; /// Resolved judge CLI, rendered read-only on the status line (override, else /// the session's default). This never mutates it. final String judgeLabel; final VoidCallback onDisarm; + final VoidCallback onOpenSettings; final VoidCallback onOpenBacklog; // Only the first few items render inline — a stacking session can hold dozens @@ -496,6 +476,11 @@ class _SessionCard extends StatelessWidget { context: context, anchorRect: anchor, entries: [ + AbMenuItem( + label: 'Handler settings', + icon: AbIcons.settings, + onTap: onOpenSettings, + ), AbMenuItem( label: 'Disarm Handler', icon: AbIcons.shield, @@ -636,17 +621,6 @@ class _SessionCard extends StatelessWidget { ], ), ], - // Its own full-width line, not a slot on the status row: names - // only render when several sessions are on screen, which is - // exactly when they share a prefix and a truncated one - // identifies nothing. - if (sessionName != null) - Text( - sessionName!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: mutedMono, - ), const SizedBox(height: AbTokens.space2), Text( // A 1-tap arm legitimately has no goal until extraction @@ -1067,36 +1041,38 @@ String _itemDecisionLabel(String decision) { /// The glyph in the reserved rail. It earns the width the rail costs on every /// row: the feed is scanned for one kind of entry at a time far more often than /// it is read top to bottom. -(String?, Color?) _activityGlyph(HandlerActivityRecord r, AbColors p) => - switch (r.decision) { - 'armed' => (AbIcons.shield, p.accent), - 'goal_edited' => (AbIcons.list, p.textMuted), - // Watched, nothing sent. The one glyph in the rail that stands for an - // absence of action, so a column of them is what the eye skips over. - 'continue' => (AbIcons.eye, p.textMuted), - 'handle' => (AbIcons.send, p.accent), - 'escalate' => (AbIcons.bell, p.accent), - 'item_done' => (AbIcons.check, p.success), - 'item_blocked' => (AbIcons.warning, p.warning), - 'item_failed' => (AbIcons.error, p.error), - 'item_skipped' => (AbIcons.close, p.textMuted), - 'instruction_dropped' => (AbIcons.warning, p.textMuted), - // A key, not a shield: `armed` already owns the accent shield, and a feed - // scanned one kind of row at a time cannot be asked to tell two identical - // glyphs apart by what a session had already done. Permission, not alarm. - 'instruction_authorized' => (AbIcons.password, p.accent), - // The drawer's own Edit mark. A change the user made by hand and one - // their sentence made for them are the same change to the same list, and - // giving the second its own glyph would teach the pencil a second meaning. - 'instruction_amended' => (AbIcons.edit, p.accent), - // The remit being tested. Shield in the warning tone, beside `armed`'s. - 'floor_warning' => (AbIcons.shield, p.warning), - 'evidence_rejected' => (AbIcons.warning, p.warning), - 'wrapped_up' => (AbIcons.check, p.textMuted), - 'parked' => (AbIcons.stop, p.warning), - 'resumed' => (AbIcons.start, p.textMuted), - _ => (null, null), - }; +(String?, Color?) _activityGlyph( + HandlerActivityRecord r, + AbColors p, +) => switch (r.decision) { + 'armed' => (AbIcons.shield, p.accent), + 'goal_edited' => (AbIcons.list, p.textMuted), + // Watched, nothing sent. The one glyph in the rail that stands for an + // absence of action, so a column of them is what the eye skips over. + 'continue' => (AbIcons.eye, p.textMuted), + 'handle' => (AbIcons.send, p.accent), + 'escalate' => (AbIcons.bell, p.accent), + 'item_done' => (AbIcons.check, p.success), + 'item_blocked' => (AbIcons.warning, p.warning), + 'item_failed' => (AbIcons.error, p.error), + 'item_skipped' => (AbIcons.close, p.textMuted), + 'instruction_dropped' => (AbIcons.warning, p.textMuted), + // A key, not a shield: `armed` already owns the accent shield, and a feed + // scanned one kind of row at a time cannot be asked to tell two identical + // glyphs apart by what a session had already done. Permission, not alarm. + 'instruction_authorized' => (AbIcons.password, p.accent), + // The drawer's own Edit mark. A change the user made by hand and one + // their sentence made for them are the same change to the same list, and + // giving the second its own glyph would teach the pencil a second meaning. + 'instruction_amended' => (AbIcons.edit, p.accent), + // The remit being tested. Shield in the warning tone, beside `armed`'s. + 'floor_warning' => (AbIcons.shield, p.warning), + 'evidence_rejected' => (AbIcons.warning, p.warning), + 'wrapped_up' => (AbIcons.check, p.textMuted), + 'parked' => (AbIcons.stop, p.warning), + 'resumed' => (AbIcons.start, p.textMuted), + _ => (null, null), +}; Widget? _activitySubtitle(HandlerActivityRecord r, AbColors p) { final detail = r.detail; diff --git a/app/lib/widgets/handler/handler_session_settings.dart b/app/lib/widgets/handler/handler_session_settings.dart new file mode 100644 index 00000000..3fab5b81 --- /dev/null +++ b/app/lib/widgets/handler/handler_session_settings.dart @@ -0,0 +1,717 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../design/ab_colors.dart'; +import '../../design/ab_icons.dart'; +import '../../design/ab_tokens.dart'; +import '../../design/widgets/ab_adaptive_sheet.dart'; +import '../../design/widgets/ab_control_box.dart'; +import '../../design/widgets/ab_dialog.dart'; +import '../../design/widgets/ab_icon.dart'; +import '../../design/widgets/ab_menu.dart'; +import '../../design/widgets/ab_section_header.dart'; +import '../../design/widgets/ab_segmented.dart'; +import '../../design/widgets/ab_separator.dart'; +import '../../design/widgets/ab_text_field.dart'; +import '../../models/agent_event.dart'; +import '../../models/handler_state.dart'; +import '../../providers/agent_catalog.dart'; +import '../../providers/capability_catalog.dart'; +import '../../providers/providers.dart'; +import '../../providers/sessions.dart'; +import '../../services/handler_service.dart'; +import '../../util/detached.dart'; + +/// The sheet's own gutter. Everything on it lines up on this one inset. +const _gutter = EdgeInsets.symmetric(horizontal: AbTokens.space16); + +/// What the posture caption says while nothing is being judged. The presets are +/// stored and inert, and a control that reads as running while every pause +/// escalates is the one claim this sheet must never make. +const handlerPostureParkedBlurb = + 'Stored, but not running — nothing is being judged, so every pause comes ' + 'to you whatever this says.'; + +/// What the posture caption says when the far end has never named one. Claiming +/// a preset here would be a control over nothing: the bridge that would have to +/// honour it predates the setting, so no cell may read as chosen and no pick +/// can be reported as taken. +const handlerPostureUnreportedBlurb = + 'This agent has not reported a posture — the machine it runs on is on a ' + 'build that predates the setting, so picking one changes nothing yet.'; + +/// The escalate-only fact on the one surface that can act on it: the judge +/// picker under this line is the fix, so it names the fix rather than stopping +/// at the diagnosis the way the shield tooltip and the arm copy have to. +String handlerJudgeParkedNotice(String? judgeLabel) => + "${judgeLabel ?? 'This judge'} can't run headless, so nothing is judged. " + 'Pick one that can and this posture takes effect.'; + +/// Just the judge half of [HandlerSessionSettingsValue] — what a picker that +/// owns the judge and nothing else hands back. Null tool means the session's +/// own CLI; null model means that CLI's default. +typedef HandlerJudgePick = ({String? judgeTool, String? judgeModel}); + +/// What a judge pick means on the ARM sheet: nothing is running yet, so the +/// choice is simply the one the session opens under. +const handlerJudgeScopeOnArm = 'Judges this session from the moment it arms.'; + +/// What a judge pick means once the session is armed. Judge calls are +/// serialised, so a session mid-pass finishes under the judge it started with — +/// said plainly rather than implied, the same reason +/// [HandlerSessionSettings.appliesNextPass] exists. +const handlerJudgeScopeNextPass = 'Takes effect on the next pass.'; + +/// The tool that will actually judge [terminalId] given an optional per-session +/// [override] — the one resolution every surface naming the judge goes through, +/// so a chip, a notice and the bridge can never name different tools. +String? handlerEffectiveJudge( + WidgetRef ref, + String terminalId, + String? override, +) { + // Watched for its subscription, not its value: `resolvedDefaultTool` reads + // the SessionsService's CURRENT state directly, which notifies nothing. The + // watch belongs here rather than at each call site — a modal route that does + // not rebuild on unrelated churn (the arm sheet, the settings sheet) would + // otherwise resolve the judge once, before the session list has filled in, + // and keep naming the wrong CLI for its whole life. + ref.watch(activeSessionsProvider); + final service = serviceWhenReady(ref, handlerServiceProvider); + return override ?? service?.resolvedDefaultTool(terminalId); +} + +/// The two per-session choices Handler exposes: which CLI judges its pauses, +/// and how far it leans toward answering them itself. +/// +/// One value type shared by both hosts — the arm sheet, which collects it +/// and sends it with the arm, and the settings sheet, which commits each change +/// as it is made. A null judge means the session's own tool; a null model means +/// that CLI's default. A null personality is NOT a preset: it means the far end +/// has never reported one (see [handlerPersonalityFromWire]), and the control +/// showing it must say so rather than name a preset the bridge cannot honour. +typedef HandlerSessionSettingsValue = ({ + String? judgeTool, + String? judgeModel, + HandlerPersonality? personality, +}); + +/// One [HandlerService.arm] call's worth of change: null on a field means +/// "leave the stored value alone", `''` on a judge field means "clear back to +/// default". The shape `arm` already takes, so no caller re-derives it. +typedef HandlerSessionSettingsEdit = ({ + String? judgeTool, + String? judgeModel, + HandlerPersonality? personality, +}); + +/// The edit that turns [from] into [to] — only the fields that MOVED. +/// +/// Sending the whole value instead would rewrite a judge pick the sheet merely +/// displayed: a cold settings cache seeds every field null, and a full send +/// would then clear a per-session record on the bridge that this app has not +/// yet been told about. +HandlerSessionSettingsEdit handlerSessionSettingsEdit( + HandlerSessionSettingsValue from, + HandlerSessionSettingsValue to, +) { + final toolMoved = to.judgeTool != from.judgeTool; + return ( + judgeTool: toolMoved ? (to.judgeTool ?? '') : null, + // A tool change ALWAYS carries the model, even when both sides read null: + // this app's view of the model is null whenever its cache is cold, and + // omitting the field then leaves the previous CLI's id on the bridge under + // the new judge — a flag it rejects on every pass, which is the one thing + // clearing the model across a tool change exists to prevent. + judgeModel: toolMoved || to.judgeModel != from.judgeModel + ? (to.judgeModel ?? '') + : null, + personality: to.personality == from.personality ? null : to.personality, + ); +} + +/// What a sheet opens on for [terminalId], read through the service cache so a +/// disarmed session still offers back what it was last given (see +/// [HandlerService.lastKnownSettings]). +/// +/// The posture is carried through UNCOERCED: a bridge that has never reported +/// one is not a bridge running [HandlerPersonality.watchdog], and seeding the +/// default here would put a preset on screen as a live fact and then send +/// nothing when the user "changed" it to the value already displayed. +HandlerSessionSettingsValue handlerSessionSettingsFor( + HandlerService? service, + String terminalId, +) { + final stored = service?.lastKnownSettings(terminalId); + return ( + judgeTool: stored?.tool, + judgeModel: stored?.model, + personality: stored?.personality, + ); +} + +/// The controls, with no chrome and no commit of their own — both hosts own +/// what a change means, and they mean different things (collected into an arm +/// vs. sent as an edit). +/// +/// Both halves, in the order the settings sheet wants them. A host that already +/// carries a judge picker of its own (the arm sheet's composer chip) mounts +/// [HandlerPostureControl] alone rather than offering the same value twice. +class HandlerSessionSettings extends StatelessWidget { + const HandlerSessionSettings({ + super.key, + required this.terminalId, + required this.value, + required this.onChanged, + this.appliesNextPass = false, + }); + + final String terminalId; + final HandlerSessionSettingsValue value; + final ValueChanged onChanged; + + /// Whether a change lands on the pass after this one rather than immediately. + /// True post-arm: judge calls are serialised, so a session mid-pass finishes + /// under the posture it started with. Said plainly rather than implied — a + /// control that looks instant and is not is one the user stops trusting. + final bool appliesNextPass; + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // The posture leads and the judge follows: how much Handler decides + // alone is what a user opens this for, while the judge is machinery + // most sessions never touch. + HandlerPostureControl( + terminalId: terminalId, + value: value, + onChanged: onChanged, + appliesNextPass: appliesNextPass, + ), + HandlerJudgeControl( + terminalId: terminalId, + value: value, + onChanged: onChanged, + ), + ], + ); +} + +/// How much Handler decides alone, plus the two lines that qualify it: the +/// posture blurb, and the parked notice when the judge can't run headless. +/// +/// The notice stays HERE rather than with the picker it names because it is an +/// answer about the posture — why this control is stored and inert. Its copy +/// never says "below", so it reads true whether the picker that fixes it sits +/// under this block (the settings sheet) or above it (the arm sheet's chip). +class HandlerPostureControl extends ConsumerWidget { + const HandlerPostureControl({ + super.key, + required this.terminalId, + required this.value, + required this.onChanged, + this.appliesNextPass = false, + }); + + final String terminalId; + final HandlerSessionSettingsValue value; + final ValueChanged onChanged; + + /// See [HandlerSessionSettings.appliesNextPass]. + final bool appliesNextPass; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final p = context.antgrid; + final catalog = ref.watch(agentCatalogProvider); + // The tool that will actually run, which is what every claim below is about. + final effectiveJudge = handlerEffectiveJudge( + ref, + terminalId, + value.judgeTool, + ); + final judgeCapable = effectiveJudge == null + ? null + : catalog[effectiveJudge]?.judgeCapable; + final judgeLabel = effectiveJudge == null + ? null + : (catalog[effectiveJudge]?.label ?? effectiveJudge); + // A judge that cannot go headless runs no decide pass at all — the bridge + // gates the whole path on this same answer — so the posture here is stored + // and does nothing until the judge picker changes. + final parked = judgeCapable == false; + // Nothing reported one, so no cell may paint as chosen — the type argument + // is nullable for exactly that: `selected` matches no segment, which is the + // only honest rendering of "the far end has never named a posture". + final posture = value.personality; + final unreported = posture == null; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const _Head(label: 'How much it handles'), + Padding( + padding: _gutter, + child: AbSegmented( + segments: [ + for (final preset in HandlerPersonality.values) + AbSegment( + value: preset, + label: handlerPersonalityLabel(preset), + ), + ], + selected: posture, + // Still selectable while parked: the choice is stored and starts + // working the moment the judge is fixed. It just must not look + // like it is running. + inactive: parked || unreported, + onSelect: (preset) => onChanged(( + judgeTool: value.judgeTool, + judgeModel: value.judgeModel, + personality: preset, + )), + ), + ), + _Caption( + text: unreported + ? handlerPostureUnreportedBlurb + : parked + ? handlerPostureParkedBlurb + : appliesNextPass + ? '${handlerPersonalityBlurb(posture)} Takes effect on the next pass.' + : handlerPersonalityBlurb(posture), + // The parked line is the load-bearing one on this sheet, not an aside + // under a control that is working. + color: parked || unreported ? p.textSecondary : p.textMuted, + ), + // This is the one class of surface where the warning is actionable; + // everywhere else it appears it only diagnoses. + if (parked) _Notice(text: handlerJudgeParkedNotice(judgeLabel)), + ], + ); + } +} + +/// Which CLI judges the session's pauses, and under which model. +/// +/// Split out from the posture so a host that already names the judge somewhere +/// else can leave this block off — two controls writing one value is a state +/// the user has to reconcile. +class HandlerJudgeControl extends ConsumerWidget { + const HandlerJudgeControl({ + super.key, + required this.terminalId, + required this.value, + required this.onChanged, + }); + + final String terminalId; + final HandlerSessionSettingsValue value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final catalog = ref.watch(agentCatalogProvider); + final judgeTools = ref.watch(judgeCapableToolsProvider); + final defaultTool = handlerEffectiveJudge(ref, terminalId, null); + final effectiveJudge = value.judgeTool ?? defaultTool; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const Padding( + padding: EdgeInsets.fromLTRB( + AbTokens.space16, + AbTokens.space14, + AbTokens.space16, + AbTokens.space14, + ), + child: AbSeparator.horizontal(), + ), + const _Head(label: 'Judged by'), + _PickerRow( + value: value.judgeTool == null + // The catalog's label, never the registry key — the menu below + // names every tool that way, and a row naming the same tool by + // its raw id reads as a different one. + ? (defaultTool == null + ? 'Default' + : 'Default (${catalog[defaultTool]?.label ?? defaultTool})') + : (catalog[value.judgeTool]?.label ?? value.judgeTool!), + entries: [ + AbMenuItem(label: 'Default', value: ''), + for (final tool in judgeTools) + AbMenuItem(label: catalog[tool]?.label ?? tool, value: tool), + ], + onSelected: (picked) { + final tool = picked.isEmpty ? null : picked; + // Re-picking the tool already in force is not an edit. Firing + // anyway would carry the cleared model into the delta and wipe an + // override the user only opened the menu to read back. + if (tool == value.judgeTool) return; + onChanged(( + judgeTool: tool, + // Cleared, never carried: a model id is a name only its own CLI + // answers to, so keeping it across a tool change hands the new + // judge a flag it rejects on every pass. + judgeModel: null, + personality: value.personality, + )); + }, + ), + // A sub-label rather than a peer heading: a model names nothing without + // the judge above it, so the two rows are one decision. + const _Head(label: 'Model', sub: true), + _ModelControl( + judgeTool: effectiveJudge, + model: value.judgeModel, + onChanged: (model) => onChanged(( + judgeTool: value.judgeTool, + judgeModel: model, + personality: value.personality, + )), + ), + ], + ); + } +} + +/// The judge's model, as a picker when this machine has heard that CLI list its +/// models and as free text otherwise. +/// +/// The list comes from the capability catalog a CHAT session of that tool wrote +/// (`capability_catalog.dart`), which is why the field is not a fallback for a +/// broken path: a machine that has only ever run this agent in a terminal has +/// no catalog to offer, and typing the id is then the only way to name one. +class _ModelControl extends ConsumerStatefulWidget { + const _ModelControl({ + required this.judgeTool, + required this.model, + required this.onChanged, + }); + + final String? judgeTool; + final String? model; + final ValueChanged onChanged; + + @override + ConsumerState<_ModelControl> createState() => _ModelControlState(); +} + +class _ModelControlState extends ConsumerState<_ModelControl> { + late final TextEditingController _controller = TextEditingController( + text: widget.model ?? '', + ); + + @override + void didUpdateWidget(_ModelControl old) { + super.didUpdateWidget(old); + // A judge change resets the field unconditionally, even when the committed + // model was null on both sides: an id typed but never submitted survives + // that comparison, and the field would then offer the PREVIOUS CLI's id to + // the new judge — the one thing clearing the model on a tool change exists + // to prevent. Otherwise only when the value moved underneath us; never on + // every rebuild, which would fight the user's cursor as they type. + if (widget.judgeTool != old.judgeTool) { + _controller.text = widget.model ?? ''; + } else if (widget.model != old.model && + (widget.model ?? '') != _controller.text) { + _controller.text = widget.model ?? ''; + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final tool = widget.judgeTool; + final models = tool == null + ? const [] + : cachedModelsFor(ref, tool); + if (models.isEmpty) { + return Padding( + padding: _gutter, + child: AbTextField( + controller: _controller, + hintText: 'Default', + // Committed on submit, not per keystroke: each change is a configure + // frame, and a half-typed model id is one the judge would try to run. + onSubmitted: (text) => + widget.onChanged(text.trim().isEmpty ? null : text.trim()), + ), + ); + } + final matches = models.where((m) => m.id == widget.model); + final selected = matches.isEmpty ? null : matches.first; + return _PickerRow( + // A model this catalog does not describe still names itself. The list is + // whatever a CHAT session of that tool happened to report, so an id the + // user typed on another surface — or one the bridge holds from a build + // ago — is routinely absent from it, and rendering it as "Default" would + // report a configured model as unset. + value: selected?.name ?? widget.model ?? 'Default', + entries: [ + AbMenuItem(label: 'Default', value: ''), + for (final m in models) AbMenuItem(label: m.name, value: m.id), + ], + onSelected: (picked) => widget.onChanged(picked.isEmpty ? null : picked), + ); + } +} + +/// A one-line value that opens a menu under itself. Not [AbSegmented]: the judge +/// list is however many agents the catalog describes, and a segmented control +/// that grows with the registry stops fitting a phone. +class _PickerRow extends StatelessWidget { + const _PickerRow({ + required this.value, + required this.entries, + required this.onSelected, + }); + + final String value; + final List entries; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + final p = context.antgrid; + return Padding( + padding: _gutter, + child: Builder( + builder: (anchorContext) => GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => detached('_PickerRow', 'open settings picker', () async { + final anchor = abMenuAnchorRect(anchorContext); + if (anchor == null) return; + final picked = await showAbMenu( + context: anchorContext, + anchorRect: anchor, + entries: entries, + ); + // The menu outlives this row — a project switch or a host restart + // can tear the sheet down while it is up — and `onSelected` runs + // `setState` on the sheet's State. + if (picked != null && anchorContext.mounted) onSelected(picked); + }), + // AbControlBox rather than a box of its own: the model row swaps + // between this trigger and an AbTextField depending on whether the + // machine has ever heard that CLI list its models, and only the + // shared recipe keeps the two the same height on every machine. + child: AbControlBox( + child: Row( + children: [ + Expanded( + child: Text( + value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AbTokens.monoStyle( + fontSize: AbTokens.fontXs, + color: p.textPrimary, + ), + ), + ), + AbIcon(AbIcons.chevronDown, size: 12, color: p.textMuted), + ], + ), + ), + ), + ), + ); + } +} + +/// A block label at the sheet's gutter. [sub] marks a row that belongs to the +/// block above it rather than opening a new one, and is quieter for it. +class _Head extends StatelessWidget { + const _Head({required this.label, this.sub = false}); + + final String label; + final bool sub; + + @override + Widget build(BuildContext context) => AbSectionHeader( + label: label, + color: sub ? context.antgrid.textDisabled : null, + padding: EdgeInsets.fromLTRB( + AbTokens.space16, + sub ? AbTokens.space10 : 0, + AbTokens.space16, + AbTokens.space6, + ), + ); +} + +/// The explanatory line under a control. +class _Caption extends StatelessWidget { + const _Caption({required this.text, required this.color}); + + final String text; + final Color color; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.fromLTRB( + AbTokens.space16, + AbTokens.space6, + AbTokens.space16, + 0, + ), + child: Text( + text, + style: AbTokens.sansStyle(fontSize: AbTokens.fontXs, color: color), + ), + ); +} + +class _Notice extends StatelessWidget { + const _Notice({required this.text}); + + final String text; + + @override + Widget build(BuildContext context) { + final p = context.antgrid; + return Padding( + padding: const EdgeInsets.fromLTRB( + AbTokens.space16, + AbTokens.space6, + AbTokens.space16, + 0, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: AbTokens.space2), + child: AbIcon(AbIcons.warning, size: 11, color: p.warning), + ), + const SizedBox(width: AbTokens.space6), + Expanded( + child: Text( + text, + style: AbTokens.sansStyle( + fontSize: AbTokens.fontXs, + color: p.warning, + ), + ), + ), + ], + ), + ); + } +} + +/// Opens the settings sheet for an ARMED [terminalId] — the PA bar's posture +/// chip and the Handler tab's armed menu are its two doors, and both exist only +/// while the session is armed. +/// +/// Every change commits on the spot as a `handler:configure` carrying `armed: +/// true`, which is the bridge's edit path: there is no Save, and no state here +/// that a dismissal could strand. +/// +/// `armed: true` is an EDIT only while a session exists to edit. This sheet is +/// a modal and outlives the bar that opened it, so it closes itself rather than +/// commit into a session that disarmed underneath it — see [_SettingsSheetState._commit]. +Future showHandlerSessionSettingsSheet( + BuildContext context, + String terminalId, +) => showAbAdaptiveSheet( + context, + child: _SettingsSheet(terminalId: terminalId), +); + +class _SettingsSheet extends ConsumerStatefulWidget { + const _SettingsSheet({required this.terminalId}); + + final String terminalId; + + @override + ConsumerState<_SettingsSheet> createState() => _SettingsSheetState(); +} + +class _SettingsSheetState extends ConsumerState<_SettingsSheet> { + HandlerSessionSettingsValue? _value; + + /// Seeded once, from the service rather than from a provider: reseeding on + /// every rebuild would let the status snapshot that CONFIRMS an edit land + /// mid-gesture and reset the control the user is still using. + HandlerSessionSettingsValue get _current => + _value ?? + handlerSessionSettingsFor( + focusedServiceOrNull(ref.container, (s) => s.handlerService), + widget.terminalId, + ); + + void _commit(HandlerSessionSettingsValue next) { + if (!mounted) return; + final service = focusedServiceOrNull( + ref.container, + (s) => s.handlerService, + ); + // `armed: true` on an already-armed session is the bridge's EDIT path — but + // sent once the session is GONE it is a fresh arm, which retires that + // slot's undo offers and puts Handler back to judging work the user let + // finish. Reachability is not the guarantee it looks like: this is a modal, + // and the PA bar row that opened it vanishes the moment an autonomous + // wrap-up or a dead PTY disarms the session underneath it. + final stillArmed = + ref.read(handlerStateProvider).value?.sessions[widget.terminalId] != + null; + if (service == null || !stillArmed) { + Navigator.of(context).maybePop(); + return; + } + final edit = handlerSessionSettingsEdit(_current, next); + // An all-null delta is not a cheap send: the bridge's edit path clears + // `lastJudgedContextHash` unconditionally, so a configure that changes + // nothing still buys a fresh judge pass — a real LLM call — over an agent + // that has not moved. + if (edit.judgeTool == null && + edit.judgeModel == null && + edit.personality == null) { + return; + } + // Pinned only once the send is real. A value pinned ahead of a dropped send + // is one no status frame can correct — `_current` prefers `_value` — so + // every later delta is computed against a `from` the bridge never held. + service.arm( + terminalId: widget.terminalId, + judgeTool: edit.judgeTool, + judgeModel: edit.judgeModel, + personality: edit.personality, + ); + setState(() => _value = next); + } + + @override + Widget build(BuildContext context) => Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: abDialogTitlePadding, + child: abDialogTitle( + 'Handler settings', + onClose: () => Navigator.of(context).maybePop(), + ), + ), + HandlerSessionSettings( + terminalId: widget.terminalId, + value: _current, + onChanged: _commit, + appliesNextPass: true, + ), + const SizedBox(height: AbTokens.space16), + ], + ); +} diff --git a/app/lib/widgets/mobile_bottom_nav.dart b/app/lib/widgets/mobile_bottom_nav.dart index a34bde3e..4195e5d6 100644 --- a/app/lib/widgets/mobile_bottom_nav.dart +++ b/app/lib/widgets/mobile_bottom_nav.dart @@ -19,8 +19,14 @@ class MobileBottomNav extends StatelessWidget { /// Same map the desktop [WorkspaceTabBar] renders. Mobile is the surface /// Handler exists for, and its `NEEDS YOU` pill lives in the agent header — - /// the OTHER swipe page — so without a count here an unanswered escalation - /// has nothing standing for it on the page the user is looking at. + /// the OTHER swipe page — so without a count here the focused session's + /// unanswered escalation has nothing standing for it on the page the user is + /// looking at. + /// + /// The focused session's, not the project's: the Handler tab renders one + /// session, so a badge counting the rest would name a number that tab cannot + /// account for. A sibling session speaks on its own drawer row instead + /// (`SessionHandlerBadge`). final Map badges; @override diff --git a/app/lib/widgets/new_session/environment_menu.dart b/app/lib/widgets/new_session/environment_menu.dart index f1f428bd..23ce3634 100644 --- a/app/lib/widgets/new_session/environment_menu.dart +++ b/app/lib/widgets/new_session/environment_menu.dart @@ -145,6 +145,7 @@ class ComposerChip extends StatelessWidget { required this.icon, required this.label, required this.onTap, + this.secondaryLabel, this.attention = false, this.enabled = true, }); @@ -153,6 +154,11 @@ class ComposerChip extends StatelessWidget { final String label; final void Function(BuildContext anchorContext) onTap; + /// A qualifier on the label that sheds FIRST — the chip's identity is the + /// primary label and must survive it. All-or-nothing rather than ellipsized: + /// half a model id names nothing, while the judge beside it still does. + final String? secondaryLabel; + /// Accent styling for "needs a pick" states (e.g. "Select project…"). final bool attention; @@ -188,6 +194,21 @@ class ComposerChip extends StatelessWidget { // glyph stand in, rather than overflowing the row. final showLabel = constraints.maxWidth >= kComposerChipFullMinWidth; + // The secondary is priced against the room the PAIR needs, so it + // is dropped while the primary still fits rather than pushing the + // primary into an ellipsis. + final secondary = secondaryLabel; + var showSecondary = false; + if (showLabel && secondary != null) { + final pair = _measureLabel( + context, + '$label $secondary', + labelStyle, + ).width; + showSecondary = + constraints.maxWidth >= + kComposerChipGlyphWidth + kComposerChipChevronWidth + pair; + } // Genuinely no room: nothing can be tapped in zero pixels. if (constraints.maxWidth <= 0) { return const SizedBox.shrink(); @@ -232,6 +253,16 @@ class ComposerChip extends StatelessWidget { style: labelStyle, ), ), + if (showSecondary) ...[ + const SizedBox(width: AbTokens.space6), + Text( + secondary!, + maxLines: 1, + softWrap: false, + overflow: TextOverflow.ellipsis, + style: labelStyle.copyWith(color: p.textMuted), + ), + ], const SizedBox(width: AbTokens.space6), AbIcon( AbIcons.chevronDown, @@ -454,7 +485,12 @@ Size _measureLabel(BuildContext context, String label, TextStyle style) { textScaler: MediaQuery.textScalerOf(context), maxLines: 1, )..layout(); - return painter.size; + final size = painter.size; + // A laid-out painter holds native paragraph memory that no GC finalizer + // reclaims, and this runs inside a LayoutBuilder on every chip that carries a + // secondary label — so leaking one per layout pass is a leak per frame. + painter.dispose(); + return size; } /// Uppercase mono section header, mirroring `AbMenu`'s header treatment diff --git a/app/lib/widgets/new_session/new_session_composer.dart b/app/lib/widgets/new_session/new_session_composer.dart index 7c121fe7..79ff6185 100644 --- a/app/lib/widgets/new_session/new_session_composer.dart +++ b/app/lib/widgets/new_session/new_session_composer.dart @@ -1,5 +1,7 @@ import 'dart:math' as math; +import 'package:antgrid_relay_client/antgrid_relay_client.dart' + show RpcException; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -16,10 +18,12 @@ import '../../design/widgets/ab_icon_button.dart'; import '../../design/widgets/ab_kbd.dart'; import '../../design/widgets/ab_loading.dart'; import '../../design/widgets/ab_menu.dart'; +import '../../design/widgets/ab_prompt_field.dart'; import '../../design/widgets/ab_snack_bar.dart'; import '../../design/widgets/ab_text_field.dart'; import '../../design/widgets/ab_switch.dart'; import '../../design/widgets/ab_tooltip.dart'; +import '../../launcher/host_control_client.dart' show HostControlException; // The send key moved to the design system (shared with the transcript // composer); re-exported so existing importers keep resolving it from here. @@ -41,7 +45,11 @@ import 'environment_menu.dart'; import 'project_menu.dart'; typedef StartNewSessionCallback = - Future Function(ProviderContainer ref, {bool allowActiveSessions}); + Future Function( + ProviderContainer ref, { + bool allowActiveSessions, + bool stashIfDirty, + }); /// Whether the Start/Send affordance is enabled. Single source of truth for /// both the reactive `canSend` (built from watched values in `build`) and the @@ -317,11 +325,13 @@ class _NewSessionComposerState extends ConsumerState { _reportedAbort = null; try { var allowActiveSessions = false; + var stashIfDirty = false; while (true) { try { await widget.submit( ref.container, allowActiveSessions: allowActiveSessions, + stashIfDirty: stashIfDirty, ); break; } on ActiveSessionsBranchSwitchException catch (e) { @@ -357,6 +367,35 @@ class _NewSessionComposerState extends ConsumerState { return; } allowActiveSessions = true; + } on DirtyWorktreeBranchSwitchException catch (e) { + if (stashIfDirty) { + rethrow; + } + if (!mounted) return; + if (_endedByCancel) return; + final confirm = await AbConfirmDialog.show( + context: context, + title: 'Stash uncommitted changes?', + body: + 'Switching to "${e.branch}" would overwrite uncommitted changes ' + 'in this folder. Antgrid can stash them first, then switch — ' + 'restore or discard the stash later from the Git tab.', + cancelLabel: 'Cancel', + confirmLabel: 'Stash & switch', + destructive: false, + ); + if (confirm != true || !mounted) return; + + final target = ref.read(selectedTargetProjectProvider); + final selection = ref.read(newSessionBranchSelectionProvider); + if (target == null || + target.id != e.targetId || + selection == null || + selection.targetId != e.targetId || + selection.branch != e.branch) { + return; + } + stashIfDirty = true; } } } on SessionLimitExceededException catch (e) { @@ -382,6 +421,28 @@ class _NewSessionComposerState extends ConsumerState { duration: const Duration(seconds: 8), ); } + } on HostControlException catch (e) { + // A local branch checkout's refusal (e.g. DIRTY_WORKTREE — uncommitted + // changes the switch would overwrite) is already user-facing text from + // the bridge, naming the files in the way; showing `e.toString()` + // instead would print the exception's type and code as if they were + // part of the sentence. + if (mounted && !_endedByCancel) { + showAbSnackBar( + context, + sessionRefusalCopy(e.code, e.message, 'Could not switch branch.'), + duration: const Duration(seconds: 8), + ); + } + } on RpcException catch (e) { + // Same refusal, over the remote control plane. + if (mounted && !_endedByCancel) { + showAbSnackBar( + context, + sessionRefusalCopy(e.code, e.message, 'Could not switch branch.'), + duration: const Duration(seconds: 8), + ); + } } catch (e) { // A start the user stopped reports the cancel and nothing else: the // failure it raced is not an outcome they asked about. @@ -687,7 +748,8 @@ class _NewSessionComposerState extends ConsumerState { // recents list above. child: ConstrainedBox( constraints: const BoxConstraints(maxHeight: 176), - child: _PromptField( + child: AbPromptField( + key: const Key('new-session-prompt-field'), controller: _prompt, focusNode: _promptFocus, enabled: !isCustom, @@ -924,68 +986,6 @@ class _NewSessionComposerState extends ConsumerState { } } -/// Multiline prompt input styled with the same tokened chrome as -/// [AbTextField] (which is single-line only, hence a bare field here). -/// `maxLines: null` grows with content; Enter/Shift+Enter handling lives on -/// the caller-owned [focusNode] ([_NewSessionComposerState._onPromptKeyEvent]). -class _PromptField extends StatelessWidget { - const _PromptField({ - required this.controller, - required this.focusNode, - required this.enabled, - required this.readOnly, - required this.hintText, - required this.onChanged, - }); - - final TextEditingController controller; - final FocusNode focusNode; - final bool enabled; - - /// Frozen but undimmed, unlike [enabled]: a prompt already on the wire is - /// still the thing the user is waiting on, so it has to stay readable — and - /// "busy" must not look like the custom-agent "this field is not yours". - final bool readOnly; - - final String hintText; - final ValueChanged onChanged; - - @override - Widget build(BuildContext context) { - final field = TextField( - key: const Key('new-session-prompt-field'), - controller: controller, - focusNode: focusNode, - enabled: enabled, - readOnly: readOnly, - // A caret blinking in a field that cannot take the keystroke invites - // exactly the edit this lock exists to refuse. - showCursor: !readOnly, - maxLines: null, - minLines: 3, - onChanged: onChanged, - style: AbTokens.sansStyle(color: context.antgrid.textPrimary), - cursorColor: context.antgrid.accent, - decoration: InputDecoration( - isCollapsed: true, - filled: false, - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - disabledBorder: InputBorder.none, - hintText: hintText, - hintStyle: AbTokens.sansStyle(color: context.antgrid.textMuted), - contentPadding: EdgeInsets.zero, - ), - ); - // Disabled-state contract: opacity 0.4, no interaction. - if (!enabled) { - return IgnorePointer(child: Opacity(opacity: 0.4, child: field)); - } - return field; - } -} - /// Isolation opt-in, as the last term of the context row's sentence: /// `Local · my-repo · main · isolated`. /// diff --git a/app/lib/widgets/port_entry.dart b/app/lib/widgets/port_entry.dart deleted file mode 100644 index 218574c7..00000000 --- a/app/lib/widgets/port_entry.dart +++ /dev/null @@ -1,96 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../design/ab_icons.dart'; -import '../design/ab_tokens.dart'; -import '../design/widgets/ab_chip.dart'; -import '../design/widgets/ab_icon_button.dart'; -import '../design/widgets/ab_tap_target.dart'; -import '../providers/recent_ports.dart'; -import '../storage/recent_ports_store.dart'; - -/// Quick-pick row of ports previously opened for [projectId] — the only -/// manual-entry affordance left in the preview empty state now that its top -/// address bar (see `PreviewScreen`) is where typing a port and hitting -/// Enter actually happens; there is no separate text field, scheme toggle, -/// or dialog duplicating that job here. Renders nothing once there are no -/// remembered ports for the project. -class RecentPortsRow extends ConsumerWidget { - const RecentPortsRow({ - super.key, - required this.projectId, - required this.onSelected, - }); - - final String projectId; - final void Function(int port, String scheme) onSelected; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final recent = ref.watch(recentPortsProvider(projectId)); - if (recent.isEmpty) return const SizedBox.shrink(); - return Wrap( - alignment: WrapAlignment.center, - spacing: AbTokens.space6, - runSpacing: AbTokens.space6, - children: [ - for (final entry in recent) - _RecentPortPill( - entry: entry, - onTap: () { - // Bumps it back to the front of the MRU list, same as opening - // it fresh via the address bar would. - ref - .read(recentPortsProvider(projectId).notifier) - .add(entry.port, entry.scheme); - onSelected(entry.port, entry.scheme); - }, - onRemove: () => ref - .read(recentPortsProvider(projectId).notifier) - .remove(entry.port), - ), - ], - ); - } -} - -class _RecentPortPill extends StatelessWidget { - final RecentPort entry; - final VoidCallback onTap; - final VoidCallback onRemove; - - const _RecentPortPill({ - required this.entry, - required this.onTap, - required this.onRemove, - }); - - @override - Widget build(BuildContext context) { - // Show the scheme only when it's https — http is the common default, so - // tagging every pill would be noise. - final label = entry.scheme == 'https' - ? 'https://${entry.port}' - : '${entry.port}'; - // The chip sets the row height; the forget button rides alongside it. - return AbCompactTapTargets( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - AbChip.toggle( - label: label, - selected: false, - size: AbChipSize.md, - onTap: onTap, - ), - AbIconButton( - icon: AbIcons.close, - tone: AbIconButtonTone.muted, - tooltip: 'Forget port ${entry.port}', - onTap: onRemove, - ), - ], - ), - ); - } -} diff --git a/app/lib/widgets/port_list_widget.dart b/app/lib/widgets/port_list_widget.dart deleted file mode 100644 index e5bbb3b1..00000000 --- a/app/lib/widgets/port_list_widget.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../design/ab_status_tone.dart'; -import '../design/ab_tokens.dart'; -import '../design/ab_colors.dart'; -import '../design/widgets/ab_list_row.dart'; -import '../design/widgets/ab_status_dot.dart'; -import '../models/preview_models.dart'; - -/// Displays a list of detected dev server ports. Users tap a port to open it -/// as a preview tab (or focus it, if already open as one). -class PortListWidget extends StatelessWidget { - final List ports; - - /// Ports already open as a tab — shown with the "selected" treatment - /// instead of a single [int?] selection, since several can be open at once. - final Set openPorts; - - /// Called with the tapped port and its target scheme ('http'/'https' as - /// detected by the bridge; http when unknown). - final void Function(int port, String scheme) onPortSelected; - - const PortListWidget({ - super.key, - required this.ports, - required this.openPorts, - required this.onPortSelected, - }); - - @override - Widget build(BuildContext context) { - return ListView.builder( - itemCount: ports.length, - padding: const EdgeInsets.symmetric(vertical: AbTokens.space8), - itemBuilder: (context, index) { - final port = ports[index]; - final isSelected = openPorts.contains(port.port); - final scheme = port.scheme ?? 'http'; - final label = port.label ?? port.processName; - // Only call out https — http is the norm and would just be noise. - final subtitle = scheme == 'https' - ? (label != null ? '$label · https' : 'https') - : label; - - return MouseRegion( - cursor: SystemMouseCursors.click, - child: AbListRow( - leading: AbStatusDot( - tone: isSelected ? AbStatusTone.info : AbStatusTone.disabled, - style: isSelected ? AbDotStyle.filled : AbDotStyle.hollow, - ), - title: Text( - 'Port ${port.port}', - style: AbTokens.monoStyle( - fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, - color: isSelected - ? context.antgrid.accent - : context.antgrid.textPrimary, - ), - ), - subtitle: subtitle != null ? Text(subtitle) : null, - selected: isSelected, - selectionStyle: AbRowSelection.surface, - density: AbRowDensity.md, - onTap: () => onPortSelected(port.port, scheme), - ), - ); - }, - ); - } -} diff --git a/app/lib/widgets/preview_empty_state.dart b/app/lib/widgets/preview_empty_state.dart index 412b11cc..5a93c312 100644 --- a/app/lib/widgets/preview_empty_state.dart +++ b/app/lib/widgets/preview_empty_state.dart @@ -3,22 +3,17 @@ import 'package:flutter/widgets.dart'; import '../design/ab_icons.dart'; import '../design/widgets/ab_empty_state.dart'; -/// Shown when no dev server ports are detected on the paired agent. Opening -/// one is done from the panel's address bar above (type a port, press -/// Enter) — this state is just the message; the optional [action] hosts a -/// quick-pick row of previously-used ports, not a text entry of its own. +/// Shown when no preview tab is open. Opening one is done from the panel's +/// address bar above (type a port, press Enter). class PreviewEmptyState extends StatelessWidget { - final Widget? action; - - const PreviewEmptyState({super.key, this.action}); + const PreviewEmptyState({super.key}); @override Widget build(BuildContext context) { - return AbEmptyState( + return const AbEmptyState( icon: AbIcons.browser, title: 'Open a Preview', subtitle: 'Enter a dev server port above\nto preview it here', - action: action, ); } } diff --git a/app/lib/widgets/projects_drawer.dart b/app/lib/widgets/projects_drawer.dart index 3c4b9e1e..d8f1334e 100644 --- a/app/lib/widgets/projects_drawer.dart +++ b/app/lib/widgets/projects_drawer.dart @@ -15,6 +15,7 @@ import '../design/widgets/ab_icon_button.dart'; import '../design/widgets/ab_list_row.dart'; import '../design/widgets/ab_loading.dart'; import '../design/widgets/ab_menu.dart'; +import '../design/widgets/ab_row_trailing.dart'; import '../design/widgets/ab_tap_target.dart'; import '../models/drawer_entry.dart'; import '../project/project_session_registry.dart' @@ -126,10 +127,17 @@ class _ProjectsDrawerState extends ConsumerState { child: AbDockedColumn( // Keeps a strip of the list on screen however short the window gets; // otherwise a tall checklist leaves the sidebar showing no projects at - // all. Borrowed from the token scale as a floor, not a measurement: - // drawer rows are AbRowDensity.sm and size to their content, so this - // is nothing to keep in sync with them. - minBodyExtent: AbTokens.rowHeightLg, + // all. Borrowed from the token scale as a floor rather than measured + // off a row — it answers how much list is worth keeping, not how tall + // any one row is. + // + // Scaled all the same, because the rows it holds room for are: a band + // floors on [AbIconButton.boxExtent], so above UI Size ~1.15 a fixed + // 44 falls short of the FIRST row and the strip stops containing a + // whole one. The dock pays for it, and the dock scrolls. + minBodyExtent: MediaQuery.textScalerOf( + context, + ).scale(AbTokens.rowHeightLg), header: _TopChrome( onRefresh: _refreshBusy ? null : _refreshFromButton, ), @@ -621,7 +629,14 @@ class _MachineProjects extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (final p in projects) - _AdvertisedProjectRow(machineUuid: machineUuid, project: p), + // Keyed: the row is stateful (focus latch) and the advert list is + // reordered and filtered live, so positional reconciliation would + // hand one project's state to another. + _AdvertisedProjectRow( + key: ValueKey(p.projectId), + machineUuid: machineUuid, + project: p, + ), ], ); }, @@ -634,95 +649,119 @@ class _MachineProjects extends ConsumerWidget { /// `.` regId is the key in [expandedDrawerIdsProvider] (its dot /// keeps it out of the machine-socket keep-alive set, which only counts /// bare-uuid ids). -class _AdvertisedProjectRow extends ConsumerWidget { +class _AdvertisedProjectRow extends ConsumerStatefulWidget { final String machineUuid; final AdvertisedProject project; const _AdvertisedProjectRow({ + super.key, required this.machineUuid, required this.project, }); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState<_AdvertisedProjectRow> createState() => + _AdvertisedProjectRowState(); +} + +class _AdvertisedProjectRowState extends ConsumerState<_AdvertisedProjectRow> { + /// Keyboard focus reveals the row's action alongside hover: an affordance + /// that only a pointer can summon is unreachable by keyboard entirely. + bool _focused = false; + + @override + Widget build(BuildContext context) { final regId = RemoteProject( - machineUuid: machineUuid, - projectId: project.projectId, + machineUuid: widget.machineUuid, + projectId: widget.project.projectId, ).registrationId; final expanded = ref.watch(expandedDrawerIdsProvider).contains(regId); final isWarm = ref.watch( projectSessionRegistryProvider.select((open) => open.contains(regId)), ); - final name = (project.label != null && project.label!.isNotEmpty) - ? project.label! - : project.projectId; + // Watched HERE and not inside the builder below. A `ref.watch` reached from + // a descendant element's build is closed and re-subscribed on every rebuild + // of this element — `ConsumerStatefulElement` retires whatever the build + // itself did not re-read before its children run — so hovering the row + // would cancel and resume the work-status subscription on each frame. + final needsUser = + !expanded && DrawerProjectAggregateDot.needsUser(ref, regId); + final label = widget.project.label; + final name = (label != null && label.isNotEmpty) + ? label + : widget.project.projectId; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ HoverableDrawerRow( - builder: (context, hovered, pointerOver) => AbListRow( - horizontalPadding: 0, - density: AbRowDensity.sm, - // No `hoverable`: matches the local project row, which never took - // it — the fill previews selection, and this row's tap expands. - // See `DrawerBand` for the rule. - leading: DrawerProjectLeading( - expanded: expanded, - pointerOver: pointerOver, - warm: isWarm, - ), - title: Text( - name, - overflow: TextOverflow.ellipsis, - style: drawerProjectTitleStyle(context), - ), + builder: (context, hovered, pointerOver) { + final revealed = hovered || _focused; // No permanent run-state glyph: it made the remote half of the // drawer read as busier than the local half for no reason the user // could name. The same collapsed-only attention dot a local project - // shows takes its place, so the two halves are one row grammar. - // Rollup first, hover actions last — the same order (and the - // same reserved-slot treatment) as `_DrawerEntryTrailing`, so the - // two halves of the drawer are one row grammar down to their - // metrics. An `AbIconButton` is a hard 24px box and the tallest - // thing in an `AbRowDensity.sm` row, so inserting it on - // pointer-enter grows the row 10px and shoves the whole list below - // it down — and leaves these rows 10px shorter than the local ones - // at rest, which is the same bug standing still. - trailing: Row( - mainAxisSize: MainAxisSize.min, - spacing: AbTokens.space4, - children: [ - if (!expanded) DrawerProjectAggregateDot(entryId: regId), - Visibility( - visible: hovered, - maintainState: true, - maintainAnimation: true, - maintainSize: true, - // Create a session in THIS project (not the machine): lands - // on New Session already targeting it — the user only picks - // the agent and hits Start. - child: AbIconButton( + // shows takes its place, built in the same order as + // `_DrawerEntryTrailing`'s — rollup inboard, action outermost — so + // the two halves of the drawer are one row grammar down to their + // metrics. + final aggregate = needsUser + ? DrawerProjectAggregateDot(entryId: regId) + : null; + final newSession = revealed + // Create a session in THIS project (not the machine): lands on + // New Session already targeting it — the user only picks the + // agent and hits Start. + ? AbIconButton( icon: AbIcons.add, tooltip: 'New session', - onTap: () => _newSessionForProject(context, ref), - ), - ), - ], - ), - margin: const EdgeInsets.symmetric(vertical: AbTokens.space2), - onTap: () => - ref.read(expandedDrawerIdsProvider.notifier).toggle(regId), - ), + onTap: _newSessionForProject, + ) + : null; + // The rail cell goes to whichever element is outermost, so the dot + // inherits it at rest instead of sitting a full cell inboard of + // every other row's trailing glyph. + final cells = []; + if (newSession == null) { + if (aggregate != null) { + cells.add(AbRowTrailingCell(child: aggregate)); + } + } else { + cells.add(aggregate); + cells.add(AbRowTrailingCell(child: newSession)); + } + return AbListRow( + horizontalPadding: 0, + density: AbRowDensity.sm, + contentFloor: AbRowContentFloor.iconButton, + // No `hoverable`: matches the local project row, which never took + // it — the fill previews selection, and this row's tap expands. + // See `DrawerBand` for the rule. + leading: DrawerProjectLeading( + expanded: expanded, + pointerOver: pointerOver, + warm: isWarm, + ), + title: Text( + name, + overflow: TextOverflow.ellipsis, + style: drawerProjectTitleStyle(context), + ), + trailing: AbRowTrailingCell.kit(cells), + margin: const EdgeInsets.symmetric(vertical: AbTokens.space2), + onFocusChange: (v) => setState(() => _focused = v), + onTap: () => + ref.read(expandedDrawerIdsProvider.notifier).toggle(regId), + ); + }, ), if (expanded) _ProjectSessions(regId: regId), ], ); } - void _newSessionForProject(BuildContext context, WidgetRef ref) { + void _newSessionForProject() { enterNewSessionForRemoteProject( ref.container, - machineUuid: machineUuid, - project: project, + machineUuid: widget.machineUuid, + project: widget.project, ); closeDrawerIfOverlay(context); } diff --git a/app/lib/widgets/session_handler_badge.dart b/app/lib/widgets/session_handler_badge.dart new file mode 100644 index 00000000..006e7de7 --- /dev/null +++ b/app/lib/widgets/session_handler_badge.dart @@ -0,0 +1,67 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../design/ab_colors.dart'; +import '../design/ab_tokens.dart'; +import '../design/widgets/ab_chip.dart'; +import '../design/widgets/ab_tooltip.dart'; +import '../providers/agent_transport.dart' show selectedRegistrationIdProvider; +import '../providers/providers.dart'; + +/// How many answers Handler is waiting on in one session. +/// +/// The row where the user picks a session is the only place a session that is +/// NOT in focus can speak. The Handler tab narrows to the focused session, its +/// tab badge counts that same session, and the agent header's pill yields to +/// the focused session's own count whenever that session is itself waiting — so +/// without this a sibling's unanswered question has nothing standing for it +/// anywhere in the app. +/// +/// Rendered for the focused PROJECT only. [handlerStateProvider] follows +/// project focus, so a drawer row belonging to another project would be +/// answered out of this project's sessions — a count attached to the wrong +/// name, which is worse than no count. Escalations across projects need a +/// surface of their own and do not have one yet. +/// +/// Accent and a bare number, matching the header pill this stands in for: the +/// count is the actionable part, and the tooltip — hover on a pointer, tap on +/// touch — carries the sentence. +class SessionHandlerBadge extends ConsumerWidget { + const SessionHandlerBadge({ + super.key, + required this.entryId, + required this.sessionId, + }); + + /// The project the row belongs to, not the focused one. + final String entryId; + final String sessionId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (ref.watch(selectedRegistrationIdProvider) != entryId) { + return const SizedBox.shrink(); + } + final pending = ref.watch( + handlerStateProvider.select( + (s) => s.value?.sessions[sessionId]?.pendingEscalations ?? 0, + ), + ); + if (pending == 0) return const SizedBox.shrink(); + return Padding( + // Owns its leading gap, so a call site mounts it without reserving space + // for a widget that usually renders nothing. + padding: const EdgeInsets.only(left: AbTokens.space6), + child: AbTooltip( + message: pending == 1 + ? 'Handler is waiting on an answer in this session.' + : 'Handler is waiting on $pending answers in this session.', + triggerMode: TooltipTriggerMode.tap, + child: AbChip.system( + label: '$pending', + color: context.antgrid.accent, + ), + ), + ); + } +} diff --git a/app/lib/widgets/session_mode_control.dart b/app/lib/widgets/session_mode_control.dart index 5c940b5a..fbbc2190 100644 --- a/app/lib/widgets/session_mode_control.dart +++ b/app/lib/widgets/session_mode_control.dart @@ -1,7 +1,9 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../design/ab_icons.dart'; import '../design/widgets/ab_confirm_dialog.dart'; +import '../design/widgets/ab_menu.dart'; import '../design/widgets/ab_snack_bar.dart'; import '../design/widgets/pulsing_opacity.dart'; import '../models/agent_work_status.dart'; @@ -14,6 +16,7 @@ import '../providers/new_session_picker.dart'; import '../providers/session_mode.dart'; import '../providers/sessions.dart'; import '../services/sessions_service.dart'; +import '../util/detached.dart'; import 'mode_segmented.dart'; import 'session_agent_mark.dart'; @@ -81,7 +84,11 @@ class SessionModeControl extends ConsumerWidget { // Both cells inert while a flip is in flight, so a second tap can't queue // a second one. No reason attached: the user just tapped. enabled: !inFlight, - onChanged: (target) => _switchMode(context, ref, active, target), + onChanged: (target) => detached( + 'SessionModeControl', + 'switch session mode', + () => _switchMode(context, ref.container, active, target), + ), ); // Dimming a control whose whole job is to look chooseable reads as broken, // so the pending state pulses instead. @@ -89,6 +96,72 @@ class SessionModeControl extends ConsumerWidget { } } +/// [SessionModeControl]'s state, redone as a single [AbLiveMenuRow] for a +/// text-menu host (the mobile overflow popup) instead of a segmented +/// control. A menu row has no room to show the option NOT being picked, so +/// the label names the action ("Switch to Terminal"/"Switch to Chat") +/// instead of the two-state choice. Same visibility/capability rules as +/// [SessionModeControl] — keep the two in lockstep by hand; neither is a +/// special case of the other's build method. +class SessionModeMenuItem extends ConsumerWidget { + const SessionModeMenuItem({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final active = ref.watch(activeSessionProvider); + if (active == null || !active.agentSessionResumable) { + return const SizedBox.shrink(); + } + final pending = ref.watch(pendingSessionModeProvider); + final inFlight = pending?.sessionId == active.id; + final mode = (ref.watch(activeSessionModeProvider) ?? active.mode) == 'chat' + ? 'chat' + : 'terminal'; + final target = mode == 'chat' ? 'terminal' : 'chat'; + final chatCapable = ref.watch(focusedToolChatCapableProvider(active.tool)); + final agent = + ref.watch(focusedMachineToolsProvider).value?.labels[active.tool] ?? + sessionAgentDisplayLabel(active, ref.watch(agentCatalogProvider)); + final chatEnabled = mode == 'chat' || chatCapable == true; + // Switching TO terminal is always reachable; switching to chat carries + // the same capability gate as the segmented control's Chat cell. + final targetEnabled = target == 'terminal' || chatEnabled; + + final row = AbLiveMenuRow( + label: target == 'chat' ? 'Switch to Chat' : 'Switch to Terminal', + icon: target == 'chat' ? AbIcons.comment : AbIcons.terminal, + enabled: targetEnabled, + disabledReason: chatCapable == null + ? "This machine hasn't said whether $agent supports chat sessions — " + 'it may still be connecting, or its bridge may be too old to ' + 'answer.' + : "$agent doesn't support chat sessions.", + onTap: () { + // Inert while a flip is in flight, so a second tap can't queue a + // second one — same contract as SessionModeControl. + if (inFlight) return; + // The popup route closes BEFORE the confirm dialog opens. This row is + // the content of a `showAbPanel` PopupRoute, which its own doc says + // pops itself; leaving it up stacks the dialog over a live modal + // barrier and then leaves the menu covering the session it just + // changed. The dialog anchors on the NAVIGATOR's context, which + // outlives the route being popped, and the container is read before + // the pop for the same reason. + final navigator = Navigator.of(context); + final host = navigator.context; + final container = ref.container; + navigator.pop(); + detached( + 'SessionModeMenuItem', + 'switch session mode', + () => _switchMode(host, container, active, target), + ); + }, + ); + return inFlight ? PulsingOpacity(child: row) : row; + } +} + /// Marker text of the `session:set-mode` failure where the old runtime never /// shut down. Kept in lockstep with `TEARDOWN_TIMEOUT_ERROR` in /// bridge/src/session-manager.ts. @@ -136,14 +209,15 @@ String? _modeSwitchWarning(AgentWorkStatus? status, String agent) => Future _switchMode( BuildContext context, - WidgetRef ref, + ProviderContainer container, SessionEntry session, String target, ) async { - // Captured before the dialog: the focused project can re-resolve while it is - // open, and a WidgetRef read past that point throws. Everything downstream - // goes through the container so an unmount mid-flip still clears `pending`. - final container = ref.container; + // Takes the container, never a `WidgetRef`: the focused project can + // re-resolve while the dialog is open, and one caller pops its own popup + // route before getting here — a ref read past either point throws. + // Everything downstream goes through it so an unmount mid-flip still clears + // `pending`. final agent = container.read(focusedMachineToolsProvider).value?.labels[session.tool] ?? sessionAgentDisplayLabel(session, container.read(agentCatalogProvider)); diff --git a/app/lib/widgets/session_row.dart b/app/lib/widgets/session_row.dart index 0b4c2f38..2edd9525 100644 --- a/app/lib/widgets/session_row.dart +++ b/app/lib/widgets/session_row.dart @@ -13,6 +13,7 @@ import '../design/widgets/ab_icon_button.dart'; import '../design/widgets/ab_list_row.dart'; import '../design/widgets/ab_loading.dart'; import '../design/widgets/ab_menu.dart'; +import '../design/widgets/ab_row_trailing.dart'; import '../design/widgets/ab_snack_bar.dart'; import '../design/widgets/ab_status_dot.dart'; import '../models/session_entry.dart'; @@ -38,6 +39,7 @@ import 'agent_work_status_dot.dart'; import 'drawer_entry_row.dart' show activateDrawerEntryById, ensureRemoteOnline; import 'session_delete_flow.dart'; import 'session_deleting_badge.dart'; +import 'session_handler_badge.dart'; import 'session_fork_dialog.dart'; import 'session_isolation_badge.dart'; import 'session_approval_badge.dart'; @@ -83,6 +85,10 @@ class _SessionRowState extends ConsumerState { // Keep kebab mounted and visible while the action menu is actively open. bool _menuOpen = false; + // A collapsed kebab is unreachable without a pointer, so the row's own focus + // highlight has to reveal it too. + bool _focused = false; + // Re-entrancy latch for _activate. A cold remote project tap kicks off an // up-to-30s pair+promote, so a rapid double-tap would otherwise launch two // concurrent activations that race the selected-target save/restore in @@ -154,10 +160,29 @@ class _SessionRowState extends ConsumerState { detached('SessionRow', 'session rename failed', _commitEdit); void _exitEdit() { - _editController?.dispose(); - _editFocus?.dispose(); + // Disposing a FocusNode detaches it, and `FocusManager._markDetached` + // removes it from `_dirtyNodes` — the very Set that + // `applyFocusChangesIfNeeded` is ITERATING when it notifies listeners. One + // caller is the field's own `onFocusChange`, and `detached` runs its action + // through `Future.sync`, so the whole path down to here executes inside + // that notification: disposing synchronously throws + // ConcurrentModificationError and kills the app. Defer the disposal so the + // notification unwinds first. The fields are cleared BEFORE it runs, so + // nothing reaches a disposed node in between and `dispose()` above cannot + // double-dispose. + // + // Post-frame rather than a microtask: a microtask still lands inside the + // frame that is showing the field, so the TextField would outlive the + // controller and focus node it is built against. The setState below is what + // takes it down, and the callback runs after that rebuild. + final controller = _editController; + final focus = _editFocus; _editController = null; _editFocus = null; + WidgetsBinding.instance.addPostFrameCallback((_) { + controller?.dispose(); + focus?.dispose(); + }); if (mounted) { setState(() => _editing = false); } else { @@ -272,30 +297,18 @@ class _SessionRowState extends ConsumerState { leadingGapOverride: AbTokens.drawerSessionLeadingGap, leading: SizedBox( width: AbTokens.drawerSessionLeadingSlot, - // Anchors the row's content height to the 24px iconButtonBox independently of - // trailing, so the row height stays strictly constant without - // reserving horizontal space for the kebab menu when unhovered. - height: AbTokens.iconButtonBox, - child: Center( - child: SizedBox( - width: AbTokens.drawerSessionLeadingSlot, - height: AbTokens.drawerLeadingSlot, - // Bias the dot slightly below its box centre. Row-centring lines up - // the dot with the title's line-box centre, but the visible glyphs - // of a text line sit a hair lower (the font reserves more space - // above the baseline than below), so a geometrically-centred dot - // reads as too high. The small downward nudge matches the optical - // centre of the text. - child: Align( - alignment: const Alignment(0, _dotOpticalYBias), - child: _leadingDot(work, deleting: deleting), - ), - ), + height: AbTokens.drawerLeadingSlot, + // Bias the dot slightly below its box centre. Row-centring lines up + // the dot with the title's line-box centre, but the visible glyphs + // of a text line sit a hair lower (the font reserves more space + // above the baseline than below), so a geometrically-centred dot + // reads as too high. The small downward nudge matches the optical + // centre of the text. + child: Align( + alignment: const Alignment(0, _dotOpticalYBias), + child: _leadingDot(work, deleting: deleting), ), ), - // Row height is anchored by the 24px leading slot, so swapping the - // title for the field doesn't change the height; the field expands - // to the full title width. title: (_editing && !deleting) ? _buildEditor() : Row( @@ -330,6 +343,10 @@ class _SessionRowState extends ConsumerState { ), SessionApprovalBadge(session: session), SessionSharedWorkspaceBadge(session: session), + SessionHandlerBadge( + entryId: widget.entryId, + sessionId: session.id, + ), SessionDeletingBadge(deleting: deleting), ], ), @@ -340,20 +357,27 @@ class _SessionRowState extends ConsumerState { // item on it (start/stop/rename/archive/delete, and the // working-directory rows pointing into a checkout that is going away) // acts on a session being removed. - trailing: ((_hovered || _menuOpen) && !_editing && !deleting) - ? _SessionMenu( - entryId: widget.entryId, - session: session, - onMenuOpened: () { - if (mounted) setState(() => _menuOpen = true); - }, - onMenuClosed: () { - if (mounted) setState(() => _menuOpen = false); - }, + trailing: + ((_hovered || _menuOpen || _focused) && !_editing && !deleting) + ? AbRowTrailingCell( + child: _SessionMenu( + entryId: widget.entryId, + session: session, + onMenuOpened: () { + if (mounted) setState(() => _menuOpen = true); + }, + onMenuClosed: () { + if (mounted) setState(() => _menuOpen = false); + }, + ), ) : null, selected: selected, enabled: !deleting, + contentFloor: AbRowContentFloor.iconButton, + onFocusChange: (v) { + if (mounted) setState(() => _focused = v); + }, selectionStyle: AbRowSelection.surface, hoverable: true, density: AbRowDensity.sm, @@ -490,7 +514,7 @@ class _SessionRowState extends ConsumerState { /// /// Deliberately a borderless, collapsed field — not [AbTextField] — /// matching the title [Text]'s font metrics, so swapping it in keeps the - /// row height (already anchored by the reserved kebab slot) stable while + /// row height (anchored by the row's own content floor) stable while /// expanding to the full title width. The selected-row fill and the accent /// cursor signal edit mode; a bordered box (`rowHeightSm`, 32px) would grow /// the row. The global `inputDecorationTheme` fills + outlines fields, so diff --git a/app/lib/widgets/terminal_view_wrapper.dart b/app/lib/widgets/terminal_view_wrapper.dart index d601fcb4..96912317 100644 --- a/app/lib/widgets/terminal_view_wrapper.dart +++ b/app/lib/widgets/terminal_view_wrapper.dart @@ -17,6 +17,7 @@ import '../models/terminal_models.dart'; import '../project/project_session.dart'; import '../providers/client_id.dart'; import '../providers/providers.dart'; +import '../providers/visible_surface.dart'; import '../services/app_settings_service.dart'; import '../services/terminal_service.dart'; import '../util/detached.dart'; @@ -36,6 +37,51 @@ import 'terminal_upload_strip.dart'; final bool _hasPhysicalKeyboard = !kIsWeb && (Platform.isWindows || Platform.isMacOS || Platform.isLinux); +/// The modifier keys `_TerminalViewWrapperState._realModifierState` mirrors. Both +/// the sided and the generic spelling, because which one arrives depends on the +/// platform AND on whether the press was injected. +final Set _modifierKeys = { + LogicalKeyboardKey.control, + LogicalKeyboardKey.controlLeft, + LogicalKeyboardKey.controlRight, + LogicalKeyboardKey.shift, + LogicalKeyboardKey.shiftLeft, + LogicalKeyboardKey.shiftRight, +}; + +/// What a numpad key MEANS with NumLock off, as the key a terminal sends for it. +/// +/// `ghosttyTerminalLogicalKeyMap` holds special keys only, and a numpad key +/// resolves to none, so Ghostty's shim falls through to printable text. With +/// NumLock ON the platform supplies the digit and that works; with NumLock OFF +/// there is no character, `keyLabel` is a multi-rune name ("Numpad 4"), and the +/// key reaches the PTY as nothing at all — the whole numpad is inert. +/// +/// Mapped to the NAVIGATION keys rather than Ghostty's own `NUMPAD_*` enum, +/// which is not a shortcut: libghostty has no idea whether NumLock is on, so it +/// encodes a keypad key only in application keypad mode (DECPAM) and emits +/// nothing at all otherwise — which is the state this map exists to rescue. +/// Navigation keys are also what the numpad genuinely IS in this mode. +/// Encoding through `sendKey` rather than writing bytes here is what keeps +/// application cursor mode (DECCKM) and the kitty protocol correct. +/// +/// Two absences are deliberate. Numpad Enter: Flutter already resolves it to +/// `LogicalKeyboardKey.enter`, which the shim encodes. Numpad 5: it is "Begin", +/// which a terminal has nothing to say about. +final Map _numpadKeys = + { + LogicalKeyboardKey.numpad0: GhosttyKey.GHOSTTY_KEY_INSERT, + LogicalKeyboardKey.numpad1: GhosttyKey.GHOSTTY_KEY_END, + LogicalKeyboardKey.numpad2: GhosttyKey.GHOSTTY_KEY_ARROW_DOWN, + LogicalKeyboardKey.numpad3: GhosttyKey.GHOSTTY_KEY_PAGE_DOWN, + LogicalKeyboardKey.numpad4: GhosttyKey.GHOSTTY_KEY_ARROW_LEFT, + LogicalKeyboardKey.numpad6: GhosttyKey.GHOSTTY_KEY_ARROW_RIGHT, + LogicalKeyboardKey.numpad7: GhosttyKey.GHOSTTY_KEY_HOME, + LogicalKeyboardKey.numpad8: GhosttyKey.GHOSTTY_KEY_ARROW_UP, + LogicalKeyboardKey.numpad9: GhosttyKey.GHOSTTY_KEY_PAGE_UP, + LogicalKeyboardKey.numpadDecimal: GhosttyKey.GHOSTTY_KEY_DELETE, + }; + // ANSI color resolution uses `app/lib/design/ansi_palette.dart` — Windows // Terminal's Campbell, re-solved per-lightness against Antgrid's backgrounds so // the renderer's contrast floor does not have to collapse normal/bright pairs @@ -230,6 +276,31 @@ class _TerminalViewWrapperState extends ConsumerState { /// non-driver view can take terminal-width ownership from another device. bool _claimRequestedByUser = false; + /// Ctrl/Shift as the USER is holding them, which is not always what + /// `HardwareKeyboard` believes. + /// + /// Flutter's Windows embedder re-synchronizes the SIDED modifier keys against + /// `GetKeyState` on every key event. An INJECTED chord — Windows clipboard + /// history's Win+V paste, a KVM or remote-desktop client, an automation tool + /// — sends `VK_CONTROL` with no scancode, which sets the generic VK but not + /// `VK_LCONTROL`, so the sync concludes the Ctrl it just delivered was never + /// down and synthesizes a key-up for it BEFORE the `V` arrives. + /// `isControlPressed` then reads false for exactly the event that needed it + /// and the paste chord below never matches, so Win+V typed a bare `v` into + /// the agent instead of pasting (measured on Flutter 3.44 / Windows 11). + /// + /// Only SYNTHESIZED events are recorded here, and only for Ctrl/Shift — the + /// two that decide the chords below. + /// + /// Three-valued, and that is the safety property: a key ABSENT from the map + /// means "no real event seen", which defers to `HardwareKeyboard` rather than + /// contradicting it. Ctrl-clicking into the terminal while already holding + /// Ctrl is exactly that case, and a two-valued mirror would have called the + /// chord released and eaten it. Cleared on every focus change, so a key-up + /// missed while the window was away leaves "unknown", never a stale answer. + final Map _realModifierState = + {}; + /// Last native (cols, rows) sent, so an `amDriver` view only re-sends when /// the local viewport actually changes the native grid. int? _lastSentCols; @@ -406,6 +477,7 @@ class _TerminalViewWrapperState extends ConsumerState { /// LayoutBuilder pass sends a resize that makes this device the driver. void _onFocusChange() { final active = _focusScope.hasFocus; + _realModifierState.clear(); if (active == _locallyActive) return; setState(() { _locallyActive = active; @@ -424,8 +496,9 @@ class _TerminalViewWrapperState extends ConsumerState { } /// Intercepts the paste chord and Ctrl+C (copy / agent-SIGINT shield) - /// before Ghostty consumes them as control characters, and encodes the - /// `Alt+` chords Ghostty's Dart shim drops on the floor. + /// before Ghostty consumes them as control characters, and encodes the two + /// things Ghostty's Dart shim drops on the floor: `Alt+` chords, + /// and the numpad with NumLock off (see [_numpadKeys]). /// /// Why an EARLY focus-manager handler and not `Shortcuts` or /// `HardwareKeyboard.addHandler`: the focus tree dispatches to the focused @@ -442,16 +515,19 @@ class _TerminalViewWrapperState extends ConsumerState { /// path passes `sanitizePaste: true`, which silently drops multi-line /// or control-char-bearing payloads. Pasting raw bytes preserves them. KeyEventResult _handleEarlyKey(KeyEvent event) { + // Ahead of the down/repeat guard: the mirror needs the key-UPs too, or a + // released modifier stays held here forever. + if (!_focusScope.hasFocus) return KeyEventResult.ignored; + _trackHeldModifier(event); if (event is! KeyDownEvent && event is! KeyRepeatEvent) { return KeyEventResult.ignored; } - if (!_focusScope.hasFocus) return KeyEventResult.ignored; _requestUserClaim(); final keyboard = HardwareKeyboard.instance; - final ctrl = keyboard.isControlPressed; + final ctrl = _realControl ?? keyboard.isControlPressed; final meta = keyboard.isMetaPressed; - final shift = keyboard.isShiftPressed; + final shift = _realShift ?? keyboard.isShiftPressed; // AltGr surfaces as Ctrl+Alt on Windows — the `!ctrl` guards below are what // keep AltGr+C (→ ć on some intl layouts) reaching the PTY untouched. final alt = keyboard.isAltPressed; @@ -519,6 +595,25 @@ class _TerminalViewWrapperState extends ConsumerState { if (agentRunning) return KeyEventResult.handled; } + // A numpad key the platform gave no character for — NumLock is off, so the + // key means Home/End/arrows/PageUp/PageDown/Insert/Delete. Ghostty's shim + // resolves it to no key enum and no printable text, so without this the + // whole numpad is dead in that mode. Excluded under Ctrl/Alt/Meta: those + // chords are Ghostty's (or the branches above) to encode. + if (!ctrl && !alt && !meta && (event.character ?? '').isEmpty) { + final numpad = _numpadKeys[event.logicalKey]; + if (numpad != null) { + final sent = widget.tab.ghostty.sendKey( + key: numpad, + action: event is KeyRepeatEvent + ? GhosttyKeyAction.GHOSTTY_KEY_ACTION_REPEAT + : GhosttyKeyAction.GHOSTTY_KEY_ACTION_PRESS, + mods: shift ? GhosttyModsMask.shift : 0, + ); + if (sent) return KeyEventResult.handled; + } + } + // Alt+ as an ESC-prefixed chord ("meta sends escape", DEC 1036). // Ghostty's engine encodes these correctly, but its Dart shim never hands // them over: `ghosttyTerminalLogicalKeyMap` holds only special keys, so a @@ -547,6 +642,38 @@ class _TerminalViewWrapperState extends ConsumerState { return KeyEventResult.ignored; } + /// Folds one key event into [_realModifierState]. See that field for why a + /// synthesized event is not evidence of anything. + void _trackHeldModifier(KeyEvent event) { + if (event.synthesized) return; + if (!_modifierKeys.contains(event.logicalKey)) return; + _realModifierState[event.logicalKey] = event is! KeyUpEvent; + } + + /// Whether any of [keys] is really held, or null when no real event has been + /// seen for any of them — see [_realModifierState]. + bool? _realState(List keys) { + var seen = false; + for (final key in keys) { + final down = _realModifierState[key]; + if (down == true) return true; + if (down != null) seen = true; + } + return seen ? false : null; + } + + bool? get _realControl => _realState(const [ + LogicalKeyboardKey.control, + LogicalKeyboardKey.controlLeft, + LogicalKeyboardKey.controlRight, + ]); + + bool? get _realShift => _realState(const [ + LogicalKeyboardKey.shift, + LogicalKeyboardKey.shiftLeft, + LogicalKeyboardKey.shiftRight, + ]); + /// Serves one paste chord: an image on the clipboard is uploaded and its /// host path typed; anything else pastes as text, exactly as it always has. /// @@ -811,9 +938,17 @@ class _TerminalViewWrapperState extends ConsumerState { // chord and a link that scrolled out from under a resting pointer all // reach here with nothing shown, and all of them get the sheet — which a // `defaultTargetPlatform` test silently exempted the first of. - onOpenHyperlink: (uri) => openTerminalHyperlink( + onOpenHyperlink: (uri) => openContentLink( context, uri, + fileService: () => widget.terminalService.session + .existingServicesForCheckout(widget.terminalService.checkoutId) + ?.fileService, + previewService: () => widget.terminalService.session + .existingServicesForCheckout(widget.terminalService.checkoutId) + ?.previewService, + revealView: (view) => + ref.read(workspaceMenuControlProvider)?.reveal(view), disclosed: _hoveredLink.value?.uri == uri, ), onHyperlinkHover: _onHyperlinkHover, diff --git a/app/lib/widgets/transcript/markdown_body.dart b/app/lib/widgets/transcript/markdown_body.dart index 74717bc5..ca37b486 100644 --- a/app/lib/widgets/transcript/markdown_body.dart +++ b/app/lib/widgets/transcript/markdown_body.dart @@ -1,11 +1,15 @@ import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:markdown_widget/markdown_widget.dart'; import '../../design/ab_colors.dart'; import '../../design/ab_icons.dart'; import '../../design/ab_tokens.dart'; import '../../design/widgets/ab_icon_button.dart'; +import '../../providers/providers.dart'; +import '../../providers/visible_surface.dart'; +import '../../util/external_url.dart'; import '../markdown_document_config.dart'; import '../markdown_heading_configs.dart'; @@ -13,12 +17,12 @@ import '../markdown_heading_configs.dart'; /// transcript ListView scrolls), AbTokens-themed, code fences get a copy /// button. Mirrors `markdown_document_config.dart` — the file viewer's own /// config — so a document and a message render the same markdown alike. -class TranscriptMarkdown extends StatelessWidget { +class TranscriptMarkdown extends ConsumerWidget { final String data; const TranscriptMarkdown({super.key, required this.data}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final c = context.antgrid; return MarkdownBlock( data: data, @@ -39,12 +43,28 @@ class TranscriptMarkdown extends StatelessWidget { // Underline is the whole affordance — links take body color, no tint. // The package default is GitHub blue (#0969DA), a light-theme link // color that lands near 3:1 on our dark surfaces. + // `onTap` routes through the same file/preview/browser split every + // other link-bearing surface uses — see [openContentLink]. LinkConfig( style: AbTokens.sansStyle( fontSize: AbTokens.fontMd, color: c.textPrimary, height: 1.55, ).copyWith(decoration: TextDecoration.underline), + onTap: (url) => openContentLink( + context, + url, + fileService: () => focusedCheckoutServiceOrNull( + ref.container, + (s) => s.fileService, + ), + previewService: () => focusedCheckoutServiceOrNull( + ref.container, + (s) => s.previewService, + ), + revealView: (view) => + ref.read(workspaceMenuControlProvider)?.reveal(view), + ), ), CodeConfig( // Match body (fontMd), not the smaller fontSm, so an inline-code run diff --git a/app/lib/widgets/window_title_bar.dart b/app/lib/widgets/window_title_bar.dart index 99128489..10e67c95 100644 --- a/app/lib/widgets/window_title_bar.dart +++ b/app/lib/widgets/window_title_bar.dart @@ -21,7 +21,7 @@ import '../providers/providers.dart'; import '../providers/recent_sessions.dart'; import '../providers/session_setup.dart'; import '../providers/sessions.dart'; -import '../utils/platform_utils.dart'; +import '../util/detached.dart'; import '../window/window_capabilities.dart'; import '../window/window_chrome.dart'; import 'agent_panel.dart'; @@ -160,10 +160,8 @@ class _DragRegion extends ConsumerWidget { /// [WindowTitleBarContents._searchGutter]'s reserve measure it from here, so /// neither can disagree with what the buttons actually occupy. @visibleForTesting -double iconSlotExtent(BuildContext context) => math.max( - MediaQuery.textScalerOf(context).scale(AbTokens.iconButtonBox), - isMobilePlatform ? AbTokens.tapTargetMin : 0.0, -); +double iconSlotExtent(BuildContext context) => + AbIconButton.footprintWidth(context); /// Empty stand-in occupying exactly one [iconSlotExtent], for a pane toggle no /// route has published. A box that doesn't track the button's real footprint @@ -441,7 +439,13 @@ class WindowTitleBarContents extends ConsumerWidget { /// `agent_panel.dart` — the mobile header and the desktop `AgentBar` — kept as /// one widget so the two cannot drift apart. class TitleBarBreadcrumb extends ConsumerWidget { - const TitleBarBreadcrumb({super.key}); + const TitleBarBreadcrumb({super.key, this.showBranchPill = true}); + + /// False on the mobile agent-panel header ([AgentPanel]), which folds the + /// pill into its overflow menu instead — a phone-width row has no space to + /// spare for an unshrinkable sibling beside the title. Desktop's + /// [AgentBar] keeps the default, where the pill still sits inline. + final bool showBranchPill; @override Widget build(BuildContext context, WidgetRef ref) { @@ -483,30 +487,59 @@ class TitleBarBreadcrumb extends ConsumerWidget { ), SessionSharedWorkspaceBadge(session: active), ], - if (gitBranch != null) ...[ + if (showBranchPill && gitBranch != null) ...[ const SizedBox(width: AbTokens.space8), // Bounded, not Flexible: the breadcrumb is the only child that should // absorb slack, and a second flexible sibling would split it evenly // and truncate the name long before the row is actually tight. The // cap is what keeps a long branch from making the badge + pill an // unshrinkable floor on a narrow window. - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 160), - child: AbBranchPill( - branch: gitBranch, - onTap: () async { - await Clipboard.setData(ClipboardData(text: gitBranch)); - if (!context.mounted) return; - showAbSnackBar( - context, - 'Copied "$gitBranch"', - duration: const Duration(seconds: 2), - ); - }, - ), - ), + const SessionBranchPill(maxWidth: 160), ], ], ); } } + +/// The active session's git branch pill — tap to copy. Extracted so both the +/// inline breadcrumb ([TitleBarBreadcrumb]) and the mobile overflow menu +/// ([AgentPanel]'s header) share one behavior instead of drifting apart. +/// Renders nothing while there is no branch to show. +class SessionBranchPill extends ConsumerWidget { + const SessionBranchPill({super.key, this.maxWidth}); + + /// Caps the pill's width when it sits beside the breadcrumb — an + /// unshrinkable sibling would otherwise floor the title's own space on a + /// narrow window. Null renders it at its natural width, for a slot (the + /// mobile overflow menu) nothing else competes with for room. + final double? maxWidth; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final terminalState = ref.watch(terminalStateProvider).value; + final gitBranch = terminalState?.gitBranch; + if (gitBranch == null) return const SizedBox.shrink(); + + final pill = AbBranchPill( + branch: gitBranch, + ahead: terminalState?.gitAhead ?? 0, + behind: terminalState?.gitBehind ?? 0, + onTap: () => detached('WindowTitleBar', 'copy branch name', () async { + await Clipboard.setData(ClipboardData(text: gitBranch)); + if (!context.mounted) return; + showAbSnackBar( + context, + 'Copied "$gitBranch"', + duration: const Duration(seconds: 2), + ); + }), + ); + final width = maxWidth; + return width == null + ? pill + : ConstrainedBox( + constraints: BoxConstraints(maxWidth: width), + child: pill, + ); + } +} diff --git a/app/test/analytics/crash_native_db_test.dart b/app/test/analytics/crash_native_db_test.dart new file mode 100644 index 00000000..abfa5461 --- /dev/null +++ b/app/test/analytics/crash_native_db_test.dart @@ -0,0 +1,56 @@ +import 'dart:io'; + +import 'package:antgrid/analytics/crash_reporting.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; + +void main() { + // The failure this guards is silent by construction: `sentry_flutter` leaves + // `nativeDatabasePath` null, sentry-native then uses `.sentry-native` + // relative to the CWD, and a Store-launched MSIX has an unwritable one — so + // `sentry_init` fails and Windows ships with no native crash capture at all, + // with no handler process and no release-health session to reveal it. An + // ABSOLUTE path under the app's own support directory is the entire fix, so + // that is what is asserted rather than any particular spelling of it. + test('native crash database resolves under the given support directory', () { + final supportDir = Directory.systemTemp.path; + final dbPath = nativeCrashDatabasePath(supportDir); + + expect(p.isAbsolute(dbPath), isTrue); + expect(p.dirname(dbPath), supportDir); + expect(p.basename(dbPath), '.sentry-native'); + // The cwd-relative default is the bug; anything relative is a regression. + expect(dbPath, isNot('.sentry-native')); + }); + + // Only the sentry-native C SDK reads the option. Asserting the getter against + // the same expression would be a tautology, so pin the contract that actually + // matters: the platforms we ship a C-SDK build for are covered. + test('the C-SDK desktop platforms are the ones that get a database path', () { + if (Platform.isWindows || Platform.isLinux) { + expect(usesNativeCrashDatabase, isTrue); + } else { + expect(usesNativeCrashDatabase, isFalse); + } + }); + + test('init stays inert without consent and still runs the app', () async { + var ran = false; + await initCrashReporting( + enabled: false, + dsn: 'https://key@example.invalid/1', + runApp: () async => ran = true, + ); + expect(ran, isTrue); + }); + + test('init stays inert without a DSN and still runs the app', () async { + var ran = false; + await initCrashReporting( + enabled: true, + dsn: '', + runApp: () async => ran = true, + ); + expect(ran, isTrue); + }); +} diff --git a/app/test/control_plane_client_test.dart b/app/test/control_plane_client_test.dart index 2ceac69c..95d318c2 100644 --- a/app/test/control_plane_client_test.dart +++ b/app/test/control_plane_client_test.dart @@ -537,6 +537,7 @@ void main() { 'projectId': 'p1', 'branch': 'dev', 'allowActiveSessions': true, + 'stashIfDirty': false, }); }, ); diff --git a/app/test/demo/demo_isolation_test.dart b/app/test/demo/demo_isolation_test.dart index 71ab18d0..eb559dbf 100644 --- a/app/test/demo/demo_isolation_test.dart +++ b/app/test/demo/demo_isolation_test.dart @@ -27,7 +27,6 @@ import 'package:antgrid/services/app_settings_service.dart'; import 'package:antgrid/storage/cached_sessions_store.dart'; import 'package:antgrid/storage/drawer_collapsed_store.dart'; import 'package:antgrid/storage/project_store.dart'; -import 'package:antgrid/storage/recent_ports_store.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; @@ -116,28 +115,6 @@ void main() { }); }); - group('recent ports', () { - test('the demo dev server is never remembered', () async { - useInMemoryPrefs(); - final store = await RecentPortsStore.open(); - addTearDown(store.close); - - await store.add(kDemoProjectId, 5173, 'http'); - - expect(store.list(kDemoProjectId), isEmpty); - }); - - test('a real project still remembers its ports', () async { - useInMemoryPrefs(); - final store = await RecentPortsStore.open(); - addTearDown(store.close); - - await store.add('real-project', 5173, 'http'); - - expect(store.list('real-project'), hasLength(1)); - }); - }); - test('project preferences resolve to defaults, off disk', () async { final container = await demoContainer(); enterDemoMode(container); diff --git a/app/test/design/widgets/ab_list_row_test.dart b/app/test/design/widgets/ab_list_row_test.dart index 10025db0..e7fb12ee 100644 --- a/app/test/design/widgets/ab_list_row_test.dart +++ b/app/test/design/widgets/ab_list_row_test.dart @@ -1,5 +1,7 @@ +import 'package:antgrid/design/ab_tokens.dart'; import 'package:antgrid/design/widgets/ab_list_row.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import '../test_harness.dart'; @@ -8,6 +10,10 @@ const _long = 'A title long enough that it cannot possibly survive on a single line at ' 'any reasonable row width, which is exactly the point of measuring it.'; +/// Vertical padding an [AbRowDensity.sm] row adds around its content, so a +/// measured row height can be reduced to the content the floor acts on. +const _smPadding = AbTokens.space6 * 2; + Future _titleHeight(WidgetTester tester, {int? maxLines}) async { await pumpAntgrid( tester, @@ -19,6 +25,36 @@ Future _titleHeight(WidgetTester tester, {int? maxLines}) async { return tester.getSize(find.text(_long)).height; } +/// Height of a `sm` row carrying [floor], optionally with a subtitle (which +/// makes its natural content taller than an icon button) and at [scale]. +Future _rowHeight( + WidgetTester tester, { + required AbRowContentFloor floor, + double scale = 1.0, + bool subtitle = false, +}) async { + await pumpAntgrid( + tester, + Builder( + builder: (context) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(scale)), + child: SizedBox( + width: 300, + child: AbListRow( + title: const Text('project-name'), + subtitle: subtitle ? const Text('main') : null, + density: AbRowDensity.sm, + contentFloor: floor, + ), + ), + ), + ), + ); + return tester.getSize(find.byType(AbListRow)).height; +} + void main() { // Every dense list in the app leans on this default; raising it would grow // all of them at once. @@ -53,4 +89,148 @@ void main() { ); expect(tester.getSize(find.text(_long)).height, greaterThan(single)); }); + + group('AbRowContentFloor', () { + testWidgets('none leaves a short row at its natural content height', ( + tester, + ) async { + // The floor is opt-in and every other list in the app declines it, so + // this is the guard for ~24 call sites at once. + expect( + const AbListRow(title: Text('t')).contentFloor, + AbRowContentFloor.none, + ); + + final height = await _rowHeight(tester, floor: AbRowContentFloor.none); + final title = tester.getSize(find.text('project-name')).height; + + expect(title, lessThan(AbTokens.iconButtonBox)); + expect(height, closeTo(title + _smPadding, 0.01)); + }); + + testWidgets('iconButton raises a short row to the button box', ( + tester, + ) async { + expect( + await _rowHeight(tester, floor: AbRowContentFloor.iconButton), + closeTo(AbTokens.iconButtonBox + _smPadding, 0.01), + ); + }); + + testWidgets('the floor is a floor, not a cap', (tester) async { + final floored = await _rowHeight( + tester, + floor: AbRowContentFloor.iconButton, + subtitle: true, + ); + final natural = await _rowHeight( + tester, + floor: AbRowContentFloor.none, + subtitle: true, + ); + + expect(natural, greaterThan(AbTokens.iconButtonBox + _smPadding)); + expect(floored, closeTo(natural, 0.01)); + }); + + testWidgets('the floor tracks the text scaler', (tester) async { + // A literal would be right at exactly one UI Size, which is why the + // caller passes an enum and the row derives the value. + final height = await _rowHeight( + tester, + floor: AbRowContentFloor.iconButton, + scale: 1.3, + ); + expect(height - _smPadding, closeTo(31.2, 0.01)); + }); + }); + + testWidgets('onFocusChange reports the keyboard focus highlight', ( + tester, + ) async { + final reported = []; + await pumpAntgrid( + tester, + SizedBox( + width: 300, + child: AbListRow( + title: const Text('project-name'), + onTap: () {}, + onFocusChange: reported.add, + ), + ), + ); + final unfocused = tester.getSize(find.byType(AbListRow)); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + + expect(reported, contains(true)); + // The callback is a report, not a style hook: a row that collapses its + // actions needs the bit, and every row that ignores it must be untouched. + expect(tester.getSize(find.byType(AbListRow)), unfocused); + + // A bit that is only ever set is a latch, not a report. + tester.binding.focusManager.primaryFocus?.unfocus(); + await tester.pumpAndSettle(); + expect(reported.last, isFalse); + + await pumpAntgrid( + tester, + SizedBox( + width: 300, + child: AbListRow(title: const Text('project-name'), onTap: () {}), + ), + ); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); + + // The detector that owns the focus highlight is mounted only for an enabled, + // interactive row, and it reports nothing on its way out. Without a closing + // report a row that reveals its trash on focus keeps it revealed with nothing + // focused — reachable from any affordance that disables the row it sits on. + for (final (name, dropped) + in <(String, AbListRow Function(ValueChanged))>[ + ( + 'loses its tap handler', + (report) => AbListRow( + title: const Text('project-name'), + onFocusChange: report, + ), + ), + ( + 'is disabled', + (report) => AbListRow( + title: const Text('project-name'), + onTap: () {}, + enabled: false, + onFocusChange: report, + ), + ), + ]) { + testWidgets('a focused row that $name reports the focus it drops', ( + tester, + ) async { + final reported = []; + Future pump(AbListRow row) => + pumpAntgrid(tester, SizedBox(width: 300, child: row)); + + await pump( + AbListRow( + title: const Text('project-name'), + onTap: () {}, + onFocusChange: reported.add, + ), + ); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + expect(reported.last, isTrue); + + await pump(dropped(reported.add)); + await tester.pumpAndSettle(); + expect(reported.last, isFalse); + }); + } } diff --git a/app/test/design/widgets/ab_row_trailing_test.dart b/app/test/design/widgets/ab_row_trailing_test.dart new file mode 100644 index 00000000..5d3e50bf --- /dev/null +++ b/app/test/design/widgets/ab_row_trailing_test.dart @@ -0,0 +1,277 @@ +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:antgrid/design/ab_icons.dart'; +import 'package:antgrid/design/ab_tokens.dart'; +import 'package:antgrid/design/widgets/ab_icon_button.dart'; +import 'package:antgrid/design/widgets/ab_row_trailing.dart'; +import 'package:antgrid/design/widgets/ab_status_dot.dart'; +import 'package:antgrid/design/widgets/ab_tap_target.dart'; + +import '../test_harness.dart'; + +const _platforms = [ + TargetPlatform.windows, + TargetPlatform.android, +]; + +/// Runs [body] with [platform] reported by `defaultTargetPlatform`. +/// +/// The binding's foundation-var invariant check runs at the end of the test +/// BODY, before any tearDown, so the override has to be lifted here. +Future _onPlatform( + TargetPlatform platform, + Future Function() body, +) async { + debugDefaultTargetPlatformOverride = platform; + try { + await body(); + } finally { + debugDefaultTargetPlatformOverride = null; + } +} + +Future _pumpScaled(WidgetTester tester, double scale, Widget child) => + pumpAntgrid( + tester, + Builder( + builder: (context) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(scale)), + child: child, + ), + ), + ); + +void main() { + tearDown(() => debugDefaultTargetPlatformOverride = null); + + group('AbIconButton.footprintWidth', () { + testWidgets('equals the width the button lays out, on every platform and ' + 'text scale', (tester) async { + // The rail's premise is that a cell can be as wide as a button without + // asking one. AbTapTarget makes that width, footprintWidth is a hand + // copy of its rule, and nothing in the type system holds the two + // together. + for (final platform in _platforms) { + await _onPlatform(platform, () async { + for (final scale in const [1.0, 1.3, 2.0]) { + await _pumpScaled( + tester, + scale, + AbCompactTapTargets( + child: AbIconButton(icon: AbIcons.trash, onTap: () {}), + ), + ); + + final measured = tester.getSize(find.byType(AbIconButton)).width; + final declared = AbIconButton.footprintWidth( + tester.element(find.byType(AbIconButton)), + ); + final scaledBox = AbTokens.iconButtonBox * scale; + final byTapTargetRule = platform == TargetPlatform.android + ? math.max(AbTokens.tapTargetMin, scaledBox) + : scaledBox; + + expect( + measured, + closeTo(byTapTargetRule, 0.01), + reason: + 'AbTapTarget moved: on $platform at text scale $scale a ' + 'compact AbIconButton lays out ${measured}px where its own ' + 'documented rule gives ${byTapTargetRule}px. Retune the rule ' + 'and AbIconButton.footprintWidth together.', + ); + expect( + declared, + closeTo(measured, 0.01), + reason: + 'AbIconButton.footprintWidth drifted from AbTapTarget: on ' + '$platform at text scale $scale it declares ${declared}px ' + 'while the button occupies ${measured}px. Every ' + 'AbRowTrailingCell in the panel is now ' + '${(declared - measured).abs()}px off the rail.', + ); + } + }); + } + }); + }); + + group('AbRowTrailingCell', () { + testWidgets('an empty cell reserves the footprint and no height', ( + tester, + ) async { + for (final platform in _platforms) { + await _onPlatform(platform, () async { + // Keyed per platform: an identical const widget across the two + // iterations is short-circuited by the framework, so the cell would + // keep the width it measured under the previous override. + await pumpAntgrid(tester, AbRowTrailingCell(key: ValueKey(platform))); + + final width = AbIconButton.footprintWidth( + tester.element(find.byType(AbRowTrailingCell)), + ); + expect( + tester.getSize(find.byType(AbRowTrailingCell)), + Size(width, 0), + reason: + 'A reserved-but-empty cell on $platform must hold the column ' + 'open without contributing a height of its own.', + ); + }); + } + }); + + testWidgets('a dot and a button in cells share one optical centre', ( + tester, + ) async { + for (final platform in _platforms) { + await _onPlatform(platform, () async { + await pumpAntgrid( + tester, + Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final tenant in [ + const AbStatusDot(), + AbIconButton(icon: AbIcons.trash, onTap: () {}), + ]) + SizedBox( + width: 300, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [AbRowTrailingCell(child: tenant)], + ), + ), + ], + ), + ); + + final dot = tester.getCenter(find.byType(AbStatusDot)).dx; + final button = tester.getCenter(find.byType(AbIconButton)).dx; + expect( + dot, + closeTo(button, 0.01), + reason: + 'On $platform a bare dot centres at ${dot}px and a padded ' + 'glyph at ${button}px against the same edge — the two row ' + 'classes no longer share a trailing column.', + ); + }); + } + }); + + testWidgets('kit drops a null without charging its gap', (tester) async { + const dense = [ + AbRowTrailingCell(child: AbStatusDot()), + AbRowTrailingCell(child: AbStatusDot()), + ]; + const sparse = [ + AbRowTrailingCell(child: AbStatusDot()), + null, + AbRowTrailingCell(child: AbStatusDot()), + ]; + + Future spanOf(List cells) async { + await pumpAntgrid(tester, AbRowTrailingCell.kit(cells)!); + final found = find.byType(AbRowTrailingCell); + return tester + .getRect(found.at(0)) + .expandToInclude(tester.getRect(found.at(1))); + } + + final sparseSpan = await spanOf(sparse); + final denseSpan = await spanOf(dense); + final footprint = AbIconButton.footprintWidth( + tester.element(find.byType(AbRowTrailingCell).first), + ); + + expect( + sparseSpan.width, + closeTo(2 * footprint + AbTokens.space4, 0.01), + reason: + 'A dropped cell still bought a ${AbTokens.space4}px gap — the ' + "phantom gap that put a project row's + inboard of the rail.", + ); + expect(sparseSpan.width, closeTo(denseSpan.width, 0.01)); + }); + + test('kit returns null when nothing survives', () { + expect(AbRowTrailingCell.kit(const [null, null]), isNull); + expect(AbRowTrailingCell.kit(const []), isNull); + }); + }); + + group('AbRowTrailingSwap', () { + testWidgets('desktop shares one cell, mobile keeps both tenants', ( + tester, + ) async { + var taps = 0; + Future pumpSwap(bool revealed) => pumpAntgrid( + tester, + AbRowTrailingSwap( + revealed: revealed, + resting: const AbStatusDot(), + action: AbIconButton(icon: AbIcons.trash, onTap: () => taps++), + ), + ); + + await _onPlatform(TargetPlatform.windows, () async { + await pumpSwap(false); + final atRest = tester.getSize(find.byType(AbRowTrailingSwap)); + + await tester.tap(find.byType(AbIconButton), warnIfMissed: false); + await tester.pump(); + expect( + taps, + 0, + reason: + 'The faded-out action is still laid out, so it takes hits ' + 'unless IgnorePointer is wired to the same flag as the fade.', + ); + + await pumpSwap(true); + await tester.pumpAndSettle(); + expect(find.byType(AbStatusDot), findsOneWidget); + expect( + tester.getSize(find.byType(AbRowTrailingSwap)), + atRest, + reason: + 'Revealing the action resized the cell, so a machine band dot ' + 'slides on pointer-enter — the reason the two tenants share one ' + 'cell at all.', + ); + + await tester.tap(find.byType(AbIconButton)); + await tester.pump(); + expect(taps, 1); + }); + + await _onPlatform(TargetPlatform.android, () async { + // Touch has no pointer to reveal with, so a swap would retire the + // liveness dot for good; both tenants stand side by side instead. + for (final revealed in const [false, true]) { + await pumpSwap(revealed); + expect(find.byType(AbStatusDot), findsOneWidget); + expect(find.byType(AbIconButton), findsOneWidget); + + final footprint = AbIconButton.footprintWidth( + tester.element(find.byType(AbIconButton)), + ); + expect( + tester.getSize(find.byType(AbRowTrailingSwap)).width, + closeTo(AbTokens.dotSizeSm + AbTokens.space4 + footprint, 0.01), + reason: + 'The mobile swap at revealed=$revealed must lay out the ' + 'resting slot, the gap and a full-footprint action cell.', + ); + } + }); + }); + }); +} diff --git a/app/test/git_sync_state_test.dart b/app/test/git_sync_state_test.dart new file mode 100644 index 00000000..1cd76c86 --- /dev/null +++ b/app/test/git_sync_state_test.dart @@ -0,0 +1,291 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:antgrid/models/ab_message.dart'; +import 'package:antgrid/models/git_sync_state.dart'; +import 'package:antgrid/widgets/git_sync_failure_handoff.dart'; + +void main() { + group('GitSyncState', () { + test('parses the wire shape the bridge sends', () { + final s = GitSyncState.fromJson(const { + 'branch': 'main', + 'remote': 'origin', + 'remoteBranch': 'main', + 'ahead': 2, + 'behind': 3, + 'hasUpstream': true, + 'hasRemote': true, + }); + expect(s.ahead, 2); + expect(s.behind, 3); + expect(s.remoteRefLabel, 'origin/main'); + expect(s.canPush, isTrue); + expect(s.canPull, isTrue); + expect(s.canPublish, isFalse); + }); + + test('offers Publish, not Push, for a branch with no upstream', () { + const s = GitSyncState(branch: 'feature', hasRemote: true); + expect(s.canPublish, isTrue); + // Push means "send commits the upstream lacks", and there is no upstream + // to measure against — the two must never both be offered. + expect(s.canPush, isFalse); + expect(s.canPull, isFalse); + }); + + test('offers nothing in a repository with no remote', () { + const s = GitSyncState(branch: 'main'); + expect(s.canPublish, isFalse); + expect(s.canPush, isFalse); + expect(s.canPull, isFalse); + }); + + test('renders no remote ref label when only one half resolved', () { + const s = GitSyncState(branch: 'main', remote: 'origin'); + // A dangling "origin/" would reach user-visible copy. + expect(s.remoteRefLabel, isNull); + }); + + test('a missing state field means no probe ran, not an unknown state', () { + final s = GitSyncState.fromJson(const {'ahead': 0, 'behind': 0}); + expect(s.state, isNull); + }); + }); + + group('GitSyncFailureKind.fromWire', () { + test('maps every kind the bridge can send', () { + const pairs = { + 'no-remote': GitSyncFailureKind.noRemote, + 'no-upstream': GitSyncFailureKind.noUpstream, + 'ambiguous-remote': GitSyncFailureKind.ambiguousRemote, + 'not-fast-forward': GitSyncFailureKind.notFastForward, + 'rejected': GitSyncFailureKind.rejected, + 'diverged': GitSyncFailureKind.diverged, + 'auth': GitSyncFailureKind.auth, + 'conflict': GitSyncFailureKind.conflict, + 'dirty-tree': GitSyncFailureKind.dirtyTree, + 'detached': GitSyncFailureKind.detached, + }; + pairs.forEach((wire, kind) { + expect(GitSyncFailureKind.fromWire(wire), kind, reason: wire); + }); + }); + + test('reads an unrecognized kind as unknown rather than throwing', () { + // A newer bridge shipping a kind this app predates must still surface the + // failure — with the stderr intact, which is what the handoff forwards. + expect( + GitSyncFailureKind.fromWire('some-future-kind'), + GitSyncFailureKind.unknown, + ); + }); + }); + + group('git:sync-result parsing', () { + Map frame(Map extra) => { + 'type': 'git:sync-result', + 'id': 'm1', + 'timestamp': 0, + 'projectId': 'p1', + ...extra, + }; + + test('folds a failure into the shape the handoff reads', () { + final parsed = parseAbMessage( + frame({ + 'op': 'push', + 'success': false, + 'branch': 'main', + 'remote': 'origin', + 'remoteBranch': 'main', + 'error': 'rejected', + 'failureKind': 'not-fast-forward', + 'command': 'git push', + 'stderr': '! [rejected] main -> main (non-fast-forward)', + }), + ); + expect(parsed, isA()); + final failure = (parsed as GitSyncResultMessage).failure!; + expect(failure.kind, GitSyncFailureKind.notFastForward); + expect(failure.stderr, contains('non-fast-forward')); + expect(failure.warrantsAgent, isTrue); + }); + + test('a success carries no failure', () { + final parsed = + parseAbMessage(frame({'op': 'pull', 'success': true, 'branch': 'main'})) + as GitSyncResultMessage; + expect(parsed.failure, isNull); + }); + + test('a null branch survives a detached HEAD result', () { + final parsed = + parseAbMessage(frame({'op': 'push', 'success': false, 'branch': null})) + as GitSyncResultMessage; + expect(parsed.branch, isNull); + expect(parsed.failure!.kind, GitSyncFailureKind.unknown); + }); + + test('rejects a frame whose op it cannot attribute', () { + // Clearing the wrong button is worse than clearing none: the wall-clock + // latch still unsticks whichever one is spinning. + expect( + parseAbMessage(frame({'op': 'rebase', 'success': true, 'branch': 'x'})), + isNull, + ); + }); + }); + + group('composeSyncFailureReport', () { + const failure = GitSyncFailure( + op: GitSyncOp.push, + kind: GitSyncFailureKind.notFastForward, + message: 'rejected', + branch: 'main', + remote: 'origin', + remoteBranch: 'main', + command: 'git push', + stderr: '! [rejected] main -> main (non-fast-forward)\n' + "error: failed to push some refs to 'origin'", + ); + + const sync = GitSyncState( + branch: 'main', + remote: 'origin', + remoteBranch: 'main', + ahead: 2, + behind: 3, + hasUpstream: true, + hasRemote: true, + ); + + test('carries the command, the verbatim stderr and the counts', () { + final report = composeSyncFailureReport(failure: failure, sync: sync); + expect(report, contains('`git push` failed.')); + expect(report, contains('non-fast-forward')); + expect(report, contains("failed to push some refs to 'origin'")); + expect(report, contains('2 ahead and 3 behind')); + expect(report, contains('`origin/main`')); + }); + + test('tells the agent not to discard commits or force-push', () { + // The load-bearing line: without it a helpful agent reaches for + // `reset --hard` or `push --force` to make the error stop. + final report = composeSyncFailureReport(failure: failure, sync: sync); + expect(report, contains('without discarding my local commits')); + expect(report, contains('without force-pushing')); + }); + + test('a diverged pull asks for reconciliation, not a discard', () { + final report = composeSyncFailureReport( + failure: const GitSyncFailure( + op: GitSyncOp.pull, + kind: GitSyncFailureKind.diverged, + message: 'diverged', + branch: 'main', + ), + sync: sync, + ); + expect(report, contains('reconcile the two histories')); + expect(report, contains('without discarding my local commits')); + }); + + test('an auth failure never invites the agent to store a secret', () { + final report = composeSyncFailureReport( + failure: const GitSyncFailure( + op: GitSyncOp.push, + kind: GitSyncFailureKind.auth, + message: 'authentication failed', + branch: 'main', + ), + ); + expect(report, contains('do not store any secret in the repo')); + }); + + test('falls back to the bridge message when there is no stderr', () { + final report = composeSyncFailureReport( + failure: const GitSyncFailure( + op: GitSyncOp.push, + kind: GitSyncFailureKind.ambiguousRemote, + message: "'feature' has no upstream and this repository has 2 remotes", + branch: 'feature', + ), + ); + expect(report, contains('has no upstream')); + expect(report, contains('which remote this branch should track')); + }); + + test('counts the working tree, deduping a path listed on both sides', () { + // A staged path edited again appears twice in the entry list; the report + // must not claim two dirty files where there is one. + final report = composeSyncFailureReport( + failure: failure, + sync: sync, + changed: const [ + GitFileStatusEntry(path: 'a.dart', status: 'M', staged: true), + GitFileStatusEntry(path: 'a.dart', status: 'M', staged: false), + GitFileStatusEntry(path: 'new.dart', status: 'U', staged: false), + ], + ); + expect(report, contains('Working tree: 1 modified, 1 untracked.')); + }); + + test('omits the working-tree line when nothing has changed', () { + final report = composeSyncFailureReport(failure: failure, sync: sync); + expect(report, isNot(contains('Working tree:'))); + }); + }); + + group('GitSyncFailure.warrantsAgent', () { + test('is false for the states the user fixes in one tap', () { + for (final kind in [ + GitSyncFailureKind.noRemote, + GitSyncFailureKind.detached, + ]) { + expect( + const GitSyncFailure( + op: GitSyncOp.push, + kind: GitSyncFailureKind.noRemote, + message: 'x', + ).copyKind(kind).warrantsAgent, + isFalse, + reason: kind.name, + ); + } + }); + + test('is true for everything that needs judgement, unknown included', () { + for (final kind in [ + GitSyncFailureKind.notFastForward, + GitSyncFailureKind.diverged, + GitSyncFailureKind.auth, + GitSyncFailureKind.conflict, + GitSyncFailureKind.dirtyTree, + GitSyncFailureKind.ambiguousRemote, + GitSyncFailureKind.unknown, + ]) { + expect( + const GitSyncFailure( + op: GitSyncOp.push, + kind: GitSyncFailureKind.unknown, + message: 'x', + ).copyKind(kind).warrantsAgent, + isTrue, + reason: kind.name, + ); + } + }); + }); +} + +extension on GitSyncFailure { + GitSyncFailure copyKind(GitSyncFailureKind kind) => GitSyncFailure( + op: op, + kind: kind, + message: message, + branch: branch, + remote: remote, + remoteBranch: remoteBranch, + command: command, + stderr: stderr, + ); +} diff --git a/app/test/helpers/hover.dart b/app/test/helpers/hover.dart new file mode 100644 index 00000000..ffdc9736 --- /dev/null +++ b/app/test/helpers/hover.dart @@ -0,0 +1,16 @@ +import 'package:flutter/gestures.dart' show PointerDeviceKind; +import 'package:flutter_test/flutter_test.dart'; + +/// Puts a mouse pointer on [target]'s centre and leaves it there. +/// +/// Rows that reveal their actions on hover drop those widgets entirely at rest, +/// so a test asserting on one has to bring the pointer over first — nothing is +/// findable without this. The pointer is removed on teardown because the +/// framework asserts on a live one when the test ends. +Future hoverRow(WidgetTester tester, Finder target) async { + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + await gesture.moveTo(tester.getCenter(target)); + await tester.pump(); +} diff --git a/app/test/helpers/test_store_overrides.dart b/app/test/helpers/test_store_overrides.dart index c90c82aa..952d72b3 100644 --- a/app/test/helpers/test_store_overrides.dart +++ b/app/test/helpers/test_store_overrides.dart @@ -16,7 +16,6 @@ import 'package:antgrid/providers/drawer_order.dart'; import 'package:antgrid/providers/first_run.dart'; import 'package:antgrid/providers/projects.dart'; import 'package:antgrid/providers/recent_agents.dart'; -import 'package:antgrid/providers/recent_ports.dart'; import 'package:antgrid/providers/update_available.dart'; import 'package:antgrid/project/project_session_registry.dart' show projectStatusCacheProvider; @@ -28,7 +27,6 @@ import 'package:antgrid/storage/drawer_order_store.dart'; import 'package:antgrid/storage/first_run_store.dart'; import 'package:antgrid/storage/project_store.dart'; import 'package:antgrid/storage/recent_agents_store.dart'; -import 'package:antgrid/storage/recent_ports_store.dart'; import 'package:antgrid/storage/update_handoff_store.dart'; class TestStoreOverrides { @@ -37,7 +35,6 @@ class TestStoreOverrides { final RecentAgentsStore recentAgentsStore; final DrawerOrderStore drawerOrderStore; final CachedSessionsStore cachedSessionsStore; - final RecentPortsStore recentPortsStore; TestStoreOverrides._({ required this.overrides, @@ -45,7 +42,6 @@ class TestStoreOverrides { required this.recentAgentsStore, required this.drawerOrderStore, required this.cachedSessionsStore, - required this.recentPortsStore, }); /// Releases the stores, but deliberately does not AWAIT them. @@ -66,7 +62,6 @@ class TestStoreOverrides { Future close() async { unawaited(recentAgentsStore.close()); unawaited(cachedSessionsStore.close()); - unawaited(recentPortsStore.close()); } } @@ -77,7 +72,6 @@ Future buildTestStoreOverrides() async { drawerOrderStore, drawerCollapsedStore, cachedSessionsStore, - recentPortsStore, firstRunStore, updateHandoffStore, prefs, @@ -87,7 +81,6 @@ Future buildTestStoreOverrides() async { DrawerOrderStore.open(), DrawerCollapsedStore.open(), CachedSessionsStore.open(), - RecentPortsStore.open(), FirstRunStore.open(), UpdateHandoffStore.open(), openAppSettingsPrefs(), @@ -110,7 +103,6 @@ Future buildTestStoreOverrides() async { drawerOrderStoreProvider.overrideWithValue(drawerOrderStore), drawerCollapsedStoreProvider.overrideWithValue(drawerCollapsedStore), cachedSessionsStoreProvider.overrideWithValue(cachedSessionsStore), - recentPortsStoreProvider.overrideWithValue(recentPortsStore), firstRunStoreProvider.overrideWithValue(firstRunStore), updateHandoffStoreProvider.overrideWithValue(updateHandoffStore), projectStatusCacheProvider.overrideWithValue( @@ -124,6 +116,5 @@ Future buildTestStoreOverrides() async { recentAgentsStore: recentAgentsStore, drawerOrderStore: drawerOrderStore, cachedSessionsStore: cachedSessionsStore, - recentPortsStore: recentPortsStore, ); } diff --git a/app/test/launcher/bootstrap_payload_test.dart b/app/test/launcher/bootstrap_payload_test.dart index a27547ad..b7a06e53 100644 --- a/app/test/launcher/bootstrap_payload_test.dart +++ b/app/test/launcher/bootstrap_payload_test.dart @@ -97,5 +97,29 @@ void main() { expect(j['ownerBuild'], BuildInfo.summary); } }); + + // The host reads its bootstrap once and treats a missing flag as OFF, so a + // construction site that forgets to pass consent must fall SILENT — never + // report on a user who was never asked. Both constructors, both directions. + test('telemetry consent defaults to false and is carried when granted', () { + for (final p in [ + BootstrapPayload(projectId: 'p1', projectPath: '/tmp/p1'), + BootstrapPayload.machineOnly(), + ]) { + final j = jsonDecode(p.toJsonLine().trim()) as Map; + expect(j['telemetryEnabled'], isFalse); + } + for (final p in [ + BootstrapPayload( + projectId: 'p1', + projectPath: '/tmp/p1', + telemetryEnabled: true, + ), + BootstrapPayload.machineOnly(telemetryEnabled: true), + ]) { + final j = jsonDecode(p.toJsonLine().trim()) as Map; + expect(j['telemetryEnabled'], isTrue); + } + }); }); } diff --git a/app/test/models/handler_state_test.dart b/app/test/models/handler_state_test.dart index 62cb37e6..766f952a 100644 --- a/app/test/models/handler_state_test.dart +++ b/app/test/models/handler_state_test.dart @@ -405,4 +405,219 @@ void main() { expect(w.blockedTotal, 1); }); }); + + group('forTerminal', () { + HandlerSnapshot snap(String id, String terminalId) => HandlerSnapshot( + snapshotId: id, + terminalId: terminalId, + at: 1, + action: 'reset_hard', + trigger: 'git reset --hard HEAD~1', + summary: 'stashed 3 files', + state: 'available', + ); + HandlerActivityRecord rec(String id, String terminalId) => + HandlerActivityRecord( + recordId: id, + at: 1, + terminalId: terminalId, + decision: 'handle', + reason: 'answered the lint prompt', + ); + HandlerWrapUp wrap(String id, String terminalId) => HandlerWrapUp( + wrapUpId: id, + terminalId: terminalId, + at: 1, + goal: 'ship it', + outcomes: const [], + blockedTotal: 0, + blockedReasons: const [], + ); + + final mixed = HandlerState( + defaultTool: 'claude', + sessions: {'t1': _session('t1', pending: 1), 't2': _session('t2', pending: 2)}, + escalations: [ + _esc('e1', urgency: 'normal', at: 1), + HandlerEscalation( + escalationId: 'e2', + terminalId: 't2', + question: 'q', + reasoning: 'r', + draftReply: 'd', + urgency: 'normal', + at: 2, + ), + ], + activity: [rec('r1', 't1'), rec('r2', 't2')], + snapshots: [snap('s1', 't1'), snap('s2', 't2')], + wrapUps: [wrap('w1', 't1'), wrap('w2', 't2')], + pendingUndo: const {'s1', 's2'}, + pendingInstructions: const { + 't1': ['rename the codec'], + 't2': ['bump the fixture'], + }, + ); + + test('keeps every collection to the terminal asked for', () { + final one = mixed.forTerminal('t1'); + expect(one.sessions.keys, ['t1']); + expect(one.escalations.map((e) => e.escalationId), ['e1']); + expect(one.activity.map((a) => a.recordId), ['r1']); + expect(one.snapshots.map((s) => s.snapshotId), ['s1']); + expect(one.wrapUps.map((w) => w.wrapUpId), ['w1']); + expect(one.pendingInstructionsFor('t1'), ['rename the codec']); + expect(one.pendingInstructionsFor('t2'), isEmpty); + }); + + test('drops a pending undo whose offer it no longer holds', () { + // The id would otherwise mark a row that is not on this screen, and + // outlive the offer it belongs to. + expect(mixed.forTerminal('t1').pendingUndo, {'s1'}); + }); + + test('carries the project judge default through', () { + // Project-wide by definition, and the session card resolves its judge + // label against it. + expect(mixed.forTerminal('t1').defaultTool, 'claude'); + expect(mixed.forTerminal(null).defaultTool, 'claude'); + }); + + test('narrows an unfocused screen to nothing, never to everything', () { + // An unresolved focus names no session, so answering it with the whole + // project's state would undo the narrowing exactly when it is needed. + final none = mixed.forTerminal(null); + expect(none.anyArmed, isFalse); + expect(none.escalations, isEmpty); + expect(none.activity, isEmpty); + expect(none.snapshots, isEmpty); + expect(none.wrapUps, isEmpty); + }); + + test('a terminal with nothing armed still keeps its leftovers', () { + // Disarm is not the end of the session: the undo it took and the report + // it wrote are read afterwards, on this same tab. With every session + // gone, every offer is an orphan — and an orphan belongs to no live + // session, so no focus can be moved to reach it. + final after = mixed.copyWith(sessions: const {}).forTerminal('t1'); + expect(after.anyArmed, isFalse); + expect(after.snapshots.map((s) => s.snapshotId), ['s1', 's2']); + expect(after.wrapUps.map((w) => w.wrapUpId), ['w1', 'w2']); + }); + + test('an offer whose session is gone is never stranded', () { + // A wrap-up DISARMS the session it reports on, so filtering these by + // terminalId alone would hide the account of every finished session — and + // the undo offer behind it — behind a focus that can never name it again. + // A live session still owns its own, so the narrowing holds where it can. + final t2Done = mixed.copyWith( + sessions: {'t1': _session('t1', pending: 1)}, + ); + final one = t2Done.forTerminal('t1'); + expect(one.snapshots.map((s) => s.snapshotId), ['s1', 's2']); + expect(one.wrapUps.map((w) => w.wrapUpId), ['w1', 'w2']); + // The per-session collections stay narrowed regardless. + expect(one.sessions.keys, ['t1']); + expect(one.activity.map((a) => a.recordId), ['r1']); + }); + + test('leaves the project-wide count alone for the surfaces that need it', () { + // The agent bar's NEEDS YOU pill reads the unnarrowed state; narrowing in + // place would take away the only thing saying another session is waiting. + expect(mixed.pendingEscalations, 3); + expect(mixed.forTerminal('t1').pendingEscalations, 1); + }); + }); + + group('personality on the wire', () { + Map wire({String? personality}) => { + 'terminalId': 't1', + 'state': 'watching', + 'pendingEscalations': 0, + 'armedAt': 1, + 'goal': 'ship it', + 'backlog': const [], + 'personality': ?personality, + }; + + test('a reported posture round-trips', () { + for (final preset in HandlerPersonality.values) { + final s = HandlerSessionState.fromWire( + wire(personality: handlerPersonalityToWire(preset)), + )!; + expect(s.personality, preset); + } + }); + + test('a bridge that says nothing leaves it null', () { + // Never defaulted to watchdog here: the sheet supplies the default it + // shows, and a model that invents one cannot tell "not reported" from + // "reported as the default". + expect(HandlerSessionState.fromWire(wire())!.personality, isNull); + }); + + test('an unrecognised posture is null, not a confident guess', () { + expect(handlerPersonalityFromWire('yolo'), isNull); + expect(handlerPersonalityFromWire(42), isNull); + }); + }); + + group('HandlerEntitlement.fromWire', () { + test('a refusal round-trips with the plan it names', () { + final e = HandlerEntitlement.fromWire({ + 'reason': 'not_entitled', + 'tier': 'free', + })!; + expect(e.reason, HandlerEntitlementReason.notEntitled); + expect(e.tier, 'free'); + }); + + test('an unreadable claim names no tier, because it has none to name', () { + final e = HandlerEntitlement.fromWire({'reason': 'unreadable'})!; + expect(e.reason, HandlerEntitlementReason.unreadable); + expect(e.tier, isNull); + }); + + test('a reason this app cannot name is still a refusal', () { + // Dropping it would restore the silence the field exists to end, and + // guessing which of the two known reasons it is would prescribe a fix + // that may be the wrong one — so the refusal stands with no reason. + final e = HandlerEntitlement.fromWire({'reason': 'seat_revoked'})!; + expect(e.reason, isNull); + }); + + test('nothing, or a malformed payload, is not a refusal', () { + // Presence is the whole signal, so inventing one out of noise would gate + // arming with nothing to say about why. + expect(HandlerEntitlement.fromWire(null), isNull); + expect(HandlerEntitlement.fromWire('not_entitled'), isNull); + expect(HandlerEntitlement.fromWire({'tier': 'free'}), isNull); + }); + }); + + group('entitlement on the project state', () { + const refused = HandlerEntitlement( + reason: HandlerEntitlementReason.notEntitled, + tier: 'free', + ); + + test('a refusal can be cleared, not only set', () { + // An upgrade lifts it, and a gate that could only ever latch on would + // outlive the thing it describes with no frame able to correct it. + final gated = const HandlerState.initial().copyWith(entitlement: refused); + expect(gated.entitlement, refused); + expect(gated.copyWith(clearEntitlement: true).entitlement, isNull); + // An untouched copy carries it, like every other field here. + expect(gated.copyWith(defaultTool: 'claude-code').entitlement, refused); + }); + + test('it survives a narrowing that names no session', () { + // Project-scoped like defaultTool: the shield that most needs it sits + // over a session that is not armed, which is exactly when forTerminal + // has nothing to narrow to. + final state = const HandlerState.initial().copyWith(entitlement: refused); + expect(state.forTerminal(null).entitlement, refused); + expect(state.forTerminal('t1').entitlement, refused); + }); + }); } diff --git a/app/test/providers/agent_transport_machine_creds_test.dart b/app/test/providers/agent_transport_machine_creds_test.dart index b3f26d8a..52c5156d 100644 --- a/app/test/providers/agent_transport_machine_creds_test.dart +++ b/app/test/providers/agent_transport_machine_creds_test.dart @@ -66,6 +66,7 @@ class _CapturingLauncher extends LocalAgentLauncher { DeviceRecord? capturedDevice; String? capturedLicenseApiUrl; String? capturedRelayUrl; + bool? capturedTelemetryEnabled; // Provide a minimal HostController so the super() constructor doesn't fail; // openProject is fully overridden so _host is never used. @@ -77,11 +78,13 @@ class _CapturingLauncher extends LocalAgentLauncher { DeviceRecord? device, String? licenseApiUrl, String? relayUrl, + bool telemetryEnabled = false, }) async { callCount++; capturedDevice = device; capturedLicenseApiUrl = licenseApiUrl; capturedRelayUrl = relayUrl; + capturedTelemetryEnabled = telemetryEnabled; // Use a LocalTransport with port 0 / empty token — connect() is never // called, so _ch stays null and send/dispose are safe no-ops. return LaunchResult( @@ -220,6 +223,13 @@ void main() { equals(_testRelayUrl), reason: 'relayUrl must be passed when a device record exists', ); + expect( + fakeLauncher.capturedTelemetryEnabled, + isTrue, + reason: + 'the telemetry setting must reach the host bootstrap — the seeded ' + 'default is on, so a dropped argument reads as a silent opt-out', + ); }, timeout: const Timeout(Duration(seconds: 15)), ); diff --git a/app/test/providers/entry_cleanup_test.dart b/app/test/providers/entry_cleanup_test.dart index 2e6e5941..34d60e79 100644 --- a/app/test/providers/entry_cleanup_test.dart +++ b/app/test/providers/entry_cleanup_test.dart @@ -13,13 +13,11 @@ import 'package:antgrid/providers/entry_cleanup.dart'; import 'package:antgrid/providers/projects.dart' show projectStoreProvider; import 'package:antgrid/providers/providers.dart' show preferencesServiceProvider, storageServiceProvider; -import 'package:antgrid/providers/recent_ports.dart'; import 'package:antgrid/services/preferences_service.dart'; import 'package:antgrid/services/storage_service.dart'; import 'package:antgrid/storage/agent_catalog_store.dart'; import 'package:antgrid/storage/cached_sessions_store.dart'; import 'package:antgrid/storage/project_store.dart'; -import 'package:antgrid/storage/recent_ports_store.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_secure_storage/test/test_flutter_secure_storage_platform.dart'; import 'package:flutter_secure_storage_platform_interface/flutter_secure_storage_platform_interface.dart'; @@ -52,12 +50,9 @@ void main() { }); test( - 'purgeEntryState clears recentPorts + statusCache even when the cachedSessions ' + 'purgeEntryState clears statusCache even when the cachedSessions ' 'clear throws (failure isolation)', () async { - final recentPorts = await RecentPortsStore.open(); - addTearDown(recentPorts.close); - await recentPorts.add('p1', 3000, 'http'); await cache.write('p1', const ProjectStatus.empty()); final container = ProviderContainer( @@ -66,7 +61,6 @@ void main() { cachedSessionsStoreProvider.overrideWith( (ref) => throw StateError('cached sessions store unavailable'), ), - recentPortsStoreProvider.overrideWithValue(recentPorts), projectStatusCacheProvider.overrideWithValue(cache), ], ); @@ -76,7 +70,6 @@ void main() { // Must not throw despite the first clear failing. await purgeEntryState(ref, 'p1'); - expect(recentPorts.list('p1'), isEmpty); expect(await cache.read('p1'), isNull); }, ); @@ -84,9 +77,6 @@ void main() { test( 'purgeEntryState surfaces a swallowed store failure to onError (not silent)', () async { - final recentPorts = await RecentPortsStore.open(); - addTearDown(recentPorts.close); - await recentPorts.add('p1', 3000, 'http'); await cache.write('p1', const ProjectStatus.empty()); final container = ProviderContainer( @@ -94,7 +84,6 @@ void main() { cachedSessionsStoreProvider.overrideWith( (ref) => throw StateError('cached sessions store unavailable'), ), - recentPortsStoreProvider.overrideWithValue(recentPorts), projectStatusCacheProvider.overrideWithValue(cache), ], ); @@ -111,7 +100,6 @@ void main() { // The swallowed failure is reported with its store label... expect(failures, ['cachedSessions']); // ...and the other stores still cleared (isolation preserved). - expect(recentPorts.list('p1'), isEmpty); expect(await cache.read('p1'), isNull); }, ); @@ -133,8 +121,6 @@ void main() { test('wipes every account-derived cache', () async { final cachedSessions = await CachedSessionsStore.open(); addTearDown(cachedSessions.close); - final recentPorts = await RecentPortsStore.open(); - addTearDown(recentPorts.close); final catalog = AgentCatalogStore(); final pairedStore = StorageService(); final prefsService = PreferencesService(); @@ -154,7 +140,6 @@ void main() { cachedSessions.putLabel(entryId, 'Biller'); cachedSessions.putStatus(entryId, 'attention'); await cachedSessions.flushNow(); - await recentPorts.add(entryId, 3000, 'http'); await cache.write(entryId, const ProjectStatus.empty()); await catalog.write(const { 'claude': AgentDescriptor( @@ -175,7 +160,6 @@ void main() { final container = ProviderContainer( overrides: [ cachedSessionsStoreProvider.overrideWithValue(cachedSessions), - recentPortsStoreProvider.overrideWithValue(recentPorts), projectStatusCacheProvider.overrideWithValue(cache), agentCatalogStoreProvider.overrideWithValue(catalog), storageServiceProvider.overrideWithValue(pairedStore), @@ -196,7 +180,6 @@ void main() { expect(cachedSessions.has(entryId), isFalse); expect(cachedSessions.label(entryId), isNull); expect(cachedSessions.statusOf(entryId), isNull); - expect(recentPorts.list(entryId), isEmpty); expect(await cache.read(entryId), isNull); expect(await catalog.read(), isEmpty); expect(secureBacking, isNot(contains(scopedStorageKey('paired_agents')))); @@ -211,15 +194,9 @@ void main() { expect(reopenedSessions.entries(), isEmpty); expect(reopenedSessions.labels(), isEmpty); expect(reopenedSessions.allStatuses(), isEmpty); - final reopenedPorts = await RecentPortsStore.open(); - addTearDown(reopenedPorts.close); - expect(reopenedPorts.list(entryId), isEmpty); }); test('a failing store is reported and does not strand the rest', () async { - final recentPorts = await RecentPortsStore.open(); - addTearDown(recentPorts.close); - await recentPorts.add('p1', 3000, 'http'); await cache.write('p1', const ProjectStatus.empty()); final pairedStore = StorageService(); secureBacking[scopedStorageKey('paired_agents')] = '[]'; @@ -230,7 +207,6 @@ void main() { cachedSessionsStoreProvider.overrideWith( (ref) => throw StateError('cached sessions store unavailable'), ), - recentPortsStoreProvider.overrideWithValue(recentPorts), projectStatusCacheProvider.overrideWithValue(cache), agentCatalogStoreProvider.overrideWithValue(AgentCatalogStore()), storageServiceProvider.overrideWithValue(pairedStore), @@ -247,7 +223,6 @@ void main() { ); expect(failures, ['cachedSessions']); - expect(recentPorts.list('p1'), isEmpty); expect(await cache.read('p1'), isNull); expect(secureBacking, isNot(contains(scopedStorageKey('paired_agents')))); }); diff --git a/app/test/providers/forget_machine_test.dart b/app/test/providers/forget_machine_test.dart index 14c093b0..376276e6 100644 --- a/app/test/providers/forget_machine_test.dart +++ b/app/test/providers/forget_machine_test.dart @@ -9,12 +9,10 @@ import 'package:antgrid/models/session_entry.dart'; import 'package:antgrid/project/project_status.dart'; import 'package:antgrid/project/project_status_cache.dart'; import 'package:antgrid/providers/cached_sessions.dart'; -import 'package:antgrid/providers/recent_ports.dart'; import 'package:antgrid/project/project_session_registry.dart' show projectStatusCacheProvider; import 'package:antgrid/storage/cached_sessions_store.dart'; import 'package:antgrid/storage/recent_agents_store.dart'; -import 'package:antgrid/storage/recent_ports_store.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -45,7 +43,6 @@ void main() { // forgetMachine purges each forgotten agent's per-entry footprint, so the // container must provide the stores purgeEntryState reads. final cachedSessions = await CachedSessionsStore.open(); - final recentPorts = await RecentPortsStore.open(); final statusTmp = await Directory.systemTemp.createTemp( 'antgrid-forget-buildc-', ); @@ -54,14 +51,12 @@ void main() { overrides: [ recentAgentsStoreProvider.overrideWithValue(recentStore), cachedSessionsStoreProvider.overrideWithValue(cachedSessions), - recentPortsStoreProvider.overrideWithValue(recentPorts), projectStatusCacheProvider.overrideWithValue(statusCache), ], ); addTearDown(container.dispose); addTearDown(recentStore.close); addTearDown(cachedSessions.close); - addTearDown(recentPorts.close); addTearDown(() async { try { await statusTmp.delete(recursive: true); @@ -156,8 +151,6 @@ void main() { final cachedSessions = await CachedSessionsStore.open(); addTearDown(cachedSessions.close); - final recentPorts = await RecentPortsStore.open(); - addTearDown(recentPorts.close); await cachedSessions.put('M.project', [ SessionEntry( id: 's1', @@ -184,7 +177,6 @@ void main() { overrides: [ recentAgentsStoreProvider.overrideWithValue(recentStore), cachedSessionsStoreProvider.overrideWithValue(cachedSessions), - recentPortsStoreProvider.overrideWithValue(recentPorts), projectStatusCacheProvider.overrideWithValue(statusCache), ], ); diff --git a/app/test/providers/handler_tab_scope_test.dart b/app/test/providers/handler_tab_scope_test.dart new file mode 100644 index 00000000..2cbdad90 --- /dev/null +++ b/app/test/providers/handler_tab_scope_test.dart @@ -0,0 +1,132 @@ +// The Handler workspace tab answers for the session in focus, the way the +// files and git tabs answer for the checkout in focus. These pin the two +// providers that make that true: the state the tab renders, and the count it +// advertises — which reads THROUGH that same state, so the two can never +// disagree about what the tab holds. +import 'package:antgrid/models/handler_state.dart'; +import 'package:antgrid/models/workspace_view.dart'; +import 'package:antgrid/providers/providers.dart'; +import 'package:antgrid/providers/sessions.dart'; +import 'package:antgrid/providers/value_controller.dart'; +import 'package:antgrid/providers/visible_surface.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +HandlerSessionState _session(String terminalId, {required int pending}) => + HandlerSessionState( + terminalId: terminalId, + runState: pending > 0 + ? HandlerRunState.needsYou + : HandlerRunState.watching, + pendingEscalations: pending, + armedAt: 1, + goal: 'ship it', + backlog: const [], + escalations: const [], + ); + +final _twoSessions = HandlerState.initial().copyWith( + sessions: {'t1': _session('t1', pending: 1), 't2': _session('t2', pending: 2)}, +); + +ProviderContainer _container({String? focused}) { + final container = ProviderContainer( + overrides: [ + activeSessionIdProvider.overrideWith(() => ValueController(focused)), + handlerStateProvider.overrideWith((ref) => Stream.value(_twoSessions)), + ], + ); + addTearDown(container.dispose); + return container; +} + +/// Activated with `listen`, never a bare `read`: in riverpod 3 a read closes +/// its subscription immediately, and the stream is then disposed mid-load with +/// nothing to emit. +Future _settle(ProviderContainer c) async { + c.listen(handlerStateProvider, (_, _) {}); + await c.read(handlerStateProvider.future); +} + +void main() { + group('focusedSessionHandlerStateProvider', () { + test('renders the focused session and nothing beside it', () async { + final c = _container(focused: 't2'); + await _settle(c); + final state = c.read(focusedSessionHandlerStateProvider); + expect(state.sessions.keys, ['t2']); + expect(state.pendingEscalations, 2); + }); + + test('is empty before a session resolves', () async { + // The service is still constructing after a project switch, and an empty + // tab is the honest answer for the frame that gap lasts. + final c = ProviderContainer( + overrides: [ + activeSessionIdProvider.overrideWith(() => ValueController('t1')), + handlerStateProvider.overrideWith( + (ref) => const Stream.empty(), + ), + ], + ); + addTearDown(c.dispose); + final state = c.read(focusedSessionHandlerStateProvider); + expect(state.anyArmed, isFalse); + expect(state.sessions, isEmpty); + expect(state.escalations, isEmpty); + expect(state.pendingEscalations, 0); + // Read too: the badge derives from this state, so a loading frame that + // threw would take the whole tab strip with it. + expect( + c.read(workspaceBadgesProvider), + isNot(contains(WorkspaceView.handler)), + ); + }); + }); + + group('workspaceBadgesProvider', () { + test('counts the focused session, not the project', () async { + final c = _container(focused: 't1'); + await _settle(c); + // 3 is the project-wide total; a badge carrying it would send the user to + // a tab narrowed past two of the three. + expect(c.read(workspaceBadgesProvider)[WorkspaceView.handler], 1); + }); + + test('drops the badge for a focused session with nothing pending', () async { + // The quiet session is PRESENT in the fixture: an absent id answers 0 by + // short-circuiting the lookup, so it would pass over a rule that badged + // every session it could actually find. + final c = ProviderContainer( + overrides: [ + activeSessionIdProvider.overrideWith(() => ValueController('t3')), + handlerStateProvider.overrideWith( + (ref) => Stream.value( + _twoSessions.copyWith( + sessions: { + ..._twoSessions.sessions, + 't3': _session('t3', pending: 0), + }, + ), + ), + ), + ], + ); + addTearDown(c.dispose); + await _settle(c); + expect( + c.read(workspaceBadgesProvider), + isNot(contains(WorkspaceView.handler)), + ); + }); + + test('with no session focused there is nothing to badge', () async { + final c = _container(); + await _settle(c); + expect( + c.read(workspaceBadgesProvider), + isNot(contains(WorkspaceView.handler)), + ); + }); + }); +} diff --git a/app/test/providers/local_host_warmup_test.dart b/app/test/providers/local_host_warmup_test.dart index 9e7536ca..843abdb2 100644 --- a/app/test/providers/local_host_warmup_test.dart +++ b/app/test/providers/local_host_warmup_test.dart @@ -14,7 +14,7 @@ import 'package:antgrid/providers/device_provisioning.dart' import 'package:antgrid/providers/local_host_warmup.dart'; import 'package:antgrid/providers/value_controller.dart'; import 'package:antgrid/services/app_settings_service.dart' - show defaultRelayUrlProvider; + show defaultRelayUrlProvider, telemetryEnabledProvider; import 'package:antgrid/services/auth_service.dart' show CurrentUser; import 'package:antgrid/services/keychain_device_store.dart'; @@ -30,6 +30,10 @@ class _RecordingLauncher extends LocalAgentLauncher { /// these tests guard. final clientIds = []; + /// The consent the provider read for each spawn — pinned because a host that + /// reports without it is the failure this whole path exists to prevent. + final telemetryFlags = []; + /// When set, warmHost records its call then parks until completed — holds a /// respawn open so a second event can be delivered mid-flight. Completer? block; @@ -40,9 +44,11 @@ class _RecordingLauncher extends LocalAgentLauncher { String? licenseApiUrl, String? relayUrl, bool forceRespawn = false, + bool telemetryEnabled = false, }) async { calls.add((hasDevice: device != null, forceRespawn: forceRespawn)); clientIds.add(device?.clientId); + telemetryFlags.add(telemetryEnabled); final gate = block; if (gate != null) await gate.future; } @@ -100,6 +106,7 @@ void main() { ), // signed out defaultRelayUrlProvider.overrideWithValue('ws://test.relay'), licenseApiUrlProvider.overrideWithValue('http://test.license'), + telemetryEnabledProvider.overrideWithValue(true), ], ); addTearDown(container.dispose); @@ -113,6 +120,9 @@ void main() { // Initial warm-up: machine-less, no respawn. expect(launcher.calls, [(hasDevice: false, forceRespawn: false)]); + // The provider must READ the setting and hand it down; dropping the + // argument would leave the host permanently unable to report. + expect(launcher.telemetryFlags, [isTrue]); // Simulate sign-in: device now provisioned + currentUser non-null. Flipping // _authState re-resolves currentUserProvider, which fires the warm-up's listener. @@ -147,6 +157,7 @@ void main() { currentUserProvider.overrideWith((ref) => ref.watch(_authState)), defaultRelayUrlProvider.overrideWithValue('ws://test.relay'), licenseApiUrlProvider.overrideWithValue('http://test.license'), + telemetryEnabledProvider.overrideWithValue(true), ], ); addTearDown(container.dispose); @@ -184,6 +195,7 @@ void main() { currentUserProvider.overrideWith((ref) => ref.watch(_authState)), defaultRelayUrlProvider.overrideWithValue('ws://test.relay'), licenseApiUrlProvider.overrideWithValue('http://test.license'), + telemetryEnabledProvider.overrideWithValue(true), ], ); addTearDown(container.dispose); @@ -230,6 +242,7 @@ void main() { currentUserProvider.overrideWith((ref) => ref.watch(_authState)), defaultRelayUrlProvider.overrideWithValue('ws://test.relay'), licenseApiUrlProvider.overrideWithValue('http://test.license'), + telemetryEnabledProvider.overrideWithValue(true), ], ); addTearDown(container.dispose); @@ -257,6 +270,9 @@ void main() { ), currentUserProvider.overrideWith((ref) => ref.watch(_authState)), defaultRelayUrlProvider.overrideWithValue('ws://test.relay'), + // Present so the swallowed failure is the launcher's own throw, not an + // unresolved provider read on the way to it. + telemetryEnabledProvider.overrideWithValue(true), ], ); addTearDown(container.dispose); @@ -302,6 +318,7 @@ class _ThrowingLauncher extends LocalAgentLauncher { String? licenseApiUrl, String? relayUrl, bool forceRespawn = false, + bool telemetryEnabled = false, }) async { throw StateError('spawn boom'); } diff --git a/app/test/providers/projects_remove_test.dart b/app/test/providers/projects_remove_test.dart index 5105c149..ada4658b 100644 --- a/app/test/providers/projects_remove_test.dart +++ b/app/test/providers/projects_remove_test.dart @@ -8,10 +8,8 @@ import 'package:antgrid/project/project_status.dart'; import 'package:antgrid/project/project_status_cache.dart'; import 'package:antgrid/providers/cached_sessions.dart'; import 'package:antgrid/providers/projects.dart'; -import 'package:antgrid/providers/recent_ports.dart'; import 'package:antgrid/storage/cached_sessions_store.dart'; import 'package:antgrid/storage/project_store.dart'; -import 'package:antgrid/storage/recent_ports_store.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -56,7 +54,7 @@ void main() { }); test( - 'remove purges cached sessions, recent ports, and the status cache file', + 'remove purges cached sessions and the status cache file', () async { final projectStore = await ProjectStore.open(); await projectStore.upsert(_project('p1')); @@ -66,15 +64,10 @@ void main() { await cachedSessions.put('p1', [_session('a')]); await cachedSessions.put('p2', [_session('b')]); - final recentPorts = await RecentPortsStore.open(); - await recentPorts.add('p1', 3000, 'http'); - await recentPorts.add('p2', 8080, 'http'); - final container = ProviderContainer( overrides: [ projectStoreProvider.overrideWithValue(projectStore), cachedSessionsStoreProvider.overrideWithValue(cachedSessions), - recentPortsStoreProvider.overrideWithValue(recentPorts), projectStatusCacheProvider.overrideWithValue(statusCache), // Real registry whose onEvict WRITES a status file — this reproduces // the eviction-writes-status race the delete path must defeat. @@ -97,7 +90,6 @@ void main() { ); addTearDown(container.dispose); addTearDown(cachedSessions.close); - addTearDown(recentPorts.close); // Mark p1 warm so the delete-path eviction actually fires onEvict (which // writes the status file we then expect to be purged). @@ -110,12 +102,10 @@ void main() { // p1 fully purged... expect(container.read(projectsProvider).map((p) => p.projectId), ['p2']); expect(cachedSessions.get('p1'), isEmpty); - expect(recentPorts.list('p1'), isEmpty); expect(await statusCache.read('p1'), isNull); // ...p2 untouched. expect(cachedSessions.get('p2').map((s) => s.id), ['b']); - expect(recentPorts.list('p2').map((e) => e.port), [8080]); }, ); } diff --git a/app/test/screens/handler_pill_reveal_test.dart b/app/test/screens/handler_pill_reveal_test.dart new file mode 100644 index 00000000..3dbc9ec1 --- /dev/null +++ b/app/test/screens/handler_pill_reveal_test.dart @@ -0,0 +1,153 @@ +// The agent header's NEEDS YOU pill against the REAL shell rather than the +// control alone: the tab it reveals belongs to WorkspaceShell, and so does the +// per-session UI restore that a focus change arms, so nothing short of the +// shell can say whether the reveal survives the switch it makes. +import 'package:antgrid/models/handler_state.dart'; +import 'package:antgrid/models/session_entry.dart'; +import 'package:antgrid/models/workspace_view.dart'; +import 'package:antgrid/providers/providers.dart'; +import 'package:antgrid/providers/sessions.dart'; +import 'package:antgrid/providers/value_controller.dart'; +import 'package:antgrid/services/sessions_service.dart'; +import 'package:antgrid/widgets/workspace_panel.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../helpers/workspace_shell_harness.dart'; + +SessionEntry _entry(String id) => SessionEntry( + id: id, + name: 'Session $id', + createdAt: 0, + lastUsedAt: 0, + archived: false, + running: true, + mode: 'terminal', +); + +HandlerSessionState _session( + String terminalId, { + required HandlerRunState runState, + int pendingEscalations = 0, +}) => HandlerSessionState( + terminalId: terminalId, + runState: runState, + pendingEscalations: pendingEscalations, + armedAt: 1, + goal: 'ship it', + backlog: const [], + escalations: const [], +); + +HandlerEscalation _escalation(String terminalId) => HandlerEscalation( + escalationId: '$terminalId-1', + terminalId: terminalId, + question: 'q', + reasoning: 'r', + draftReply: 'd', + urgency: 'normal', + at: 1, +); + +/// Bounded pumps rather than `pumpAndSettle`: the shell always has something +/// animating, so it never reaches a quiet frame. +Future _settle(WidgetTester tester) async { + for (var i = 0; i < 5; i++) { + await tester.pump(const Duration(milliseconds: 200)); + } +} + +int _panelIndex(WidgetTester tester) => tester + .widget( + find.descendant( + of: find.byType(WorkspacePanel), + matching: find.byType(IndexedStack), + ), + ) + .index!; + +/// The platform override is cleared inside the body, not from a tearDown: the +/// binding asserts every foundation debug variable is unset before tearDowns +/// run, and the shell's default panel mode reads the platform for the whole +/// body. +Future _withShell( + WidgetTester tester, + Future Function(ProviderContainer container) body, +) async { + try { + debugDefaultTargetPlatformOverride = TargetPlatform.windows; + tester.view.physicalSize = const Size(1400, 900); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + final container = await pumpWorkspaceShell( + tester, + extraOverrides: [ + activeSessionIdProvider.overrideWith(() => ValueController('t1')), + sessionsStateProvider.overrideWith( + (ref) => Stream.value( + SessionsState( + projectId: testAgentDeviceId, + sessions: [_entry('t1'), _entry('t2')], + ), + ), + ), + handlerStateProvider.overrideWith( + (ref) => Stream.value( + const HandlerState.initial().copyWith( + sessions: { + 't1': _session('t1', runState: HandlerRunState.watching), + 't2': _session( + 't2', + runState: HandlerRunState.needsYou, + pendingEscalations: 1, + ), + }, + escalations: [_escalation('t2')], + ), + ), + ), + ], + ); + await _settle(tester); + await body(container); + } finally { + debugDefaultTargetPlatformOverride = null; + } +} + +/// Leaves [id] holding a saved workspace tab of its own, the way any session +/// the user has already worked in does, then hands focus back to `t1`. +Future _giveSavedTab(WidgetTester tester, ProviderContainer c) async { + c.read(activeSessionIdProvider.notifier).set('t2'); + await _settle(tester); + await tester.tap( + find.descendant(of: find.byType(WorkspacePanel), matching: find.text('Git')), + ); + await _settle(tester); + c.read(activeSessionIdProvider.notifier).set('t1'); + await _settle(tester); +} + +void main() { + // The restore is armed by the focus change the pill itself makes, and it + // re-applies the target session's own saved tab a frame later — so a reveal + // that fires before it lands is silently undone, leaving the pill's one + // navigation looking like a dead tap. + testWidgets('the tab it reveals survives the focus switch it makes', ( + tester, + ) async { + await _withShell(tester, (container) async { + await _giveSavedTab(tester, container); + expect(_panelIndex(tester), isNot(WorkspaceView.handler.index)); + + await tester.tap(find.text('NEEDS YOU 1')); + await _settle(tester); + + expect(container.read(activeSessionIdProvider), 't2'); + expect(_panelIndex(tester), WorkspaceView.handler.index); + }); + }); +} diff --git a/app/test/screens/preview_screen_test.dart b/app/test/screens/preview_screen_test.dart index 1ee9f325..ea56b3b9 100644 --- a/app/test/screens/preview_screen_test.dart +++ b/app/test/screens/preview_screen_test.dart @@ -36,46 +36,10 @@ void main() { debugDefaultTargetPlatformOverride = null; }); - testWidgets('shows port list when ports available', (tester) async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - - final state = PreviewState( - ports: [ - const PortInfo(port: 3000, processName: 'node'), - const PortInfo(port: 8080, label: 'vite'), - ], - ); - - await tester.pumpWidget(buildTestWidget(previewState: AsyncData(state))); - await tester.pump(); - - expect(find.text('Port 3000'), findsOneWidget); - expect(find.text('Port 8080'), findsOneWidget); - expect(find.text('node'), findsOneWidget); - expect(find.text('vite'), findsOneWidget); - - debugDefaultTargetPlatformOverride = null; - }); - - testWidgets('marks https ports in the port list', (tester) async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - - final state = PreviewState( - ports: [ - const PortInfo(port: 3000, processName: 'node'), - const PortInfo(port: 8443, label: 'vite', scheme: 'https'), - ], - ); - - await tester.pumpWidget(buildTestWidget(previewState: AsyncData(state))); - await tester.pump(); - - // http is the norm and stays unmarked; https is called out. - expect(find.text('node'), findsOneWidget); - expect(find.text('vite · https'), findsOneWidget); - - debugDefaultTargetPlatformOverride = null; - }); + // Detected ports with no tab open used to render as a standalone list; + // that surface is gone (see PreviewScreen's own doc on `state.ports`) — + // detected ports now show only through the open-tabs UI, covered by + // preview_tab_bar_test.dart. testWidgets('shows loading when preview state is loading', (tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; diff --git a/app/test/services/file_service_test.dart b/app/test/services/file_service_test.dart index 45da015e..52e305a6 100644 --- a/app/test/services/file_service_test.dart +++ b/app/test/services/file_service_test.dart @@ -372,6 +372,150 @@ void main() { await session.close(); }); + test('loadStashes sends git:stash-list with seeded projectId', () async { + final t = FakeAgentTransport(); + final session = await _newSession(t); + final svc = FileService.fromSession(session); + + svc.loadStashes(); + await Future.delayed(Duration.zero); + + final msg = t.sent.firstWhere((m) => m['type'] == 'git:stash-list'); + expect(msg['projectId'], 'p'); + + await svc.dispose(); + await session.close(); + }); + + test('git:stash-list-result populates git.stashes', () async { + final t = FakeAgentTransport(); + final session = await _newSession(t); + final svc = FileService.fromSession(session); + + t.emit('git:stash-list-result', { + 'projectId': 'p', + 'stashes': [ + { + 'ref': 'stash@{0}', + 'branch': 'main', + 'message': 'Before switching to dev', + 'createdAt': 1700000000, + }, + ], + }); + await Future.delayed(Duration.zero); + + expect(svc.currentState.git.stashes, hasLength(1)); + expect(svc.currentState.git.stashes.single.ref, 'stash@{0}'); + expect(svc.currentState.git.stashes.single.branch, 'main'); + + await svc.dispose(); + await session.close(); + }); + + test('restoreStash sends git:stash-pop with ref', () async { + final t = FakeAgentTransport(); + final session = await _newSession(t); + final svc = FileService.fromSession(session); + + svc.restoreStash('stash@{0}'); + await Future.delayed(Duration.zero); + + final msg = t.sent.firstWhere((m) => m['type'] == 'git:stash-pop'); + expect(msg['projectId'], 'p'); + expect(msg['ref'], 'stash@{0}'); + + await svc.dispose(); + await session.close(); + }); + + test('dropStash sends git:stash-drop with ref', () async { + final t = FakeAgentTransport(); + final session = await _newSession(t); + final svc = FileService.fromSession(session); + + svc.dropStash('stash@{0}'); + await Future.delayed(Duration.zero); + + final msg = t.sent.firstWhere((m) => m['type'] == 'git:stash-drop'); + expect(msg['projectId'], 'p'); + expect(msg['ref'], 'stash@{0}'); + + await svc.dispose(); + await session.close(); + }); + + // Neither result asks for the list back: the agent follows every pop and + // drop with a fresh `git:stash-list-result` on BOTH outcomes, so a request + // from here is a second round trip for a list already on the wire. + test( + 'git:stash-pop-result failure surfaces gitOpFeedback without re-asking', + () async { + final t = FakeAgentTransport(); + final session = await _newSession(t); + final svc = FileService.fromSession(session); + + t.emit('git:stash-pop-result', { + 'projectId': 'p', + 'ref': 'stash@{0}', + 'success': false, + 'error': 'conflict', + }); + await Future.delayed(Duration.zero); + + expect(svc.currentState.gitOpFeedback, 'conflict'); + expect(t.sent.where((m) => m['type'] == 'git:stash-list'), isEmpty); + + await svc.dispose(); + await session.close(); + }, + ); + + test( + 'git:stash-drop-result success stays silent and re-asks nothing', + () async { + final t = FakeAgentTransport(); + final session = await _newSession(t); + final svc = FileService.fromSession(session); + + t.emit('git:stash-drop-result', { + 'projectId': 'p', + 'ref': 'stash@{0}', + 'success': true, + }); + await Future.delayed(Duration.zero); + + expect(svc.currentState.gitOpFeedback, isNull); + expect(t.sent.where((m) => m['type'] == 'git:stash-list'), isEmpty); + + await svc.dispose(); + await session.close(); + }, + ); + + // A one-way claim spent by a build whose send never runs hides the banner for + // the service's whole life, so `loadStashes` also registers a hydrator: the + // list has to survive a reconnect, and nothing else ever re-reads it. + test('loadStashes re-asks on every re-establish', () async { + final t = FakeAgentTransport(); + final session = await _newSession(t); + final svc = FileService.fromSession(session); + + svc.loadStashes(); + await Future.delayed(Duration.zero); + expect(t.sent.where((m) => m['type'] == 'git:stash-list'), hasLength(1)); + + t.redriveHydrators(); + await Future.delayed(Duration.zero); + expect( + t.sent.where((m) => m['type'] == 'git:stash-list').length, + greaterThan(1), + ); + + await svc.dispose(); + await session.close(); + }); + test('git:stage-result failure surfaces gitOpFeedback', () async { final t = FakeAgentTransport(); final session = await _newSession(t); @@ -671,4 +815,175 @@ void main() { }, ); }); + + group('History tab', () { + test('loadHistory replaces the list; loadMoreHistory appends', () async { + final t = FakeAgentTransport(); + final session = await _newSession(t); + final svc = FileService.fromSession(session); + + svc.loadHistory(); + expect(svc.currentState.git.history.initialLoad, isTrue); + expect(t.sent.last['type'], 'git:log'); + expect(t.sent.last['skip'], 0); + + t.emit('git:log-result', { + 'projectId': 'p', + 'commits': [ + { + 'sha': 'a' * 40, + 'shortSha': 'aaaaaaa', + 'subject': 'first', + 'authorName': 'Ada', + 'authorEmail': 'ada@example.com', + 'authorDate': '2026-01-01T00:00:00Z', + }, + ], + 'skip': 0, + 'hasMore': true, + }); + await Future.delayed(Duration.zero); + expect(svc.currentState.git.history.commits, hasLength(1)); + expect(svc.currentState.git.history.initialLoad, isFalse); + expect(svc.currentState.git.history.hasMore, isTrue); + + svc.loadMoreHistory(); + expect(t.sent.last['type'], 'git:log'); + expect(t.sent.last['skip'], 1); + + t.emit('git:log-result', { + 'projectId': 'p', + 'commits': [ + { + 'sha': 'b' * 40, + 'shortSha': 'bbbbbbb', + 'subject': 'second', + 'authorName': 'Ada', + 'authorEmail': 'ada@example.com', + 'authorDate': '2025-12-31T00:00:00Z', + }, + ], + 'skip': 1, + 'hasMore': false, + }); + await Future.delayed(Duration.zero); + expect( + svc.currentState.git.history.commits.map((c) => c.subject), + ['first', 'second'], + ); + expect(svc.currentState.git.history.hasMore, isFalse); + + // No more pages and nothing loading — a scroll-triggered call must not + // fire a third request. + svc.loadMoreHistory(); + expect(t.sent.where((m) => m['type'] == 'git:log'), hasLength(2)); + + await svc.dispose(); + await session.close(); + }); + + test('a dropped git:log leaves an error after the timeout', () async { + final t = FakeAgentTransport(); + final session = await _newSession(t); + final svc = FileService.fromSession( + session, + gitActionTimeout: const Duration(milliseconds: 40), + ); + + svc.loadHistory(); + await Future.delayed(const Duration(milliseconds: 150)); + expect(svc.currentState.git.history.loadingMore, isFalse); + expect(svc.currentState.git.history.error, isNotNull); + + await svc.dispose(); + await session.close(); + }); + + test( + 'toggleCommitExpanded fetches a commit\'s files once and caches them', + () async { + final t = FakeAgentTransport(); + final session = await _newSession(t); + final svc = FileService.fromSession(session); + + svc.toggleCommitExpanded('sha1'); + expect(svc.currentState.git.history.expandedShas, {'sha1'}); + expect(t.sent.last['type'], 'git:commit-files'); + expect(t.sent.last['sha'], 'sha1'); + + t.emit('git:commit-files-result', { + 'projectId': 'p', + 'sha': 'sha1', + 'files': [ + { + 'path': 'a.txt', + 'status': 'M', + 'additions': 3, + 'deletions': 1, + }, + ], + }); + await Future.delayed(Duration.zero); + expect(svc.currentState.git.history.filesBySha['sha1'], hasLength(1)); + + // Collapse, then re-expand: the cache means no second fetch. + svc.toggleCommitExpanded('sha1'); + expect(svc.currentState.git.history.expandedShas, isEmpty); + svc.toggleCommitExpanded('sha1'); + expect(svc.currentState.git.history.expandedShas, {'sha1'}); + expect(t.sent.where((m) => m['type'] == 'git:commit-files'), hasLength(1)); + + await svc.dispose(); + await session.close(); + }, + ); + + test('requestCommitDiff opens a commit-scoped diff distinct from a ' + 'working-tree diff for the same path', () async { + final t = FakeAgentTransport(); + final session = await _newSession(t); + final svc = FileService.fromSession(session); + + svc.requestCommitDiff('sha1', 'a.txt'); + expect(svc.currentState.git.diffLoading, isTrue); + expect(svc.currentState.git.diffCommitSha, 'sha1'); + expect(t.sent.last['type'], 'git:commit-diff'); + expect(t.sent.last['sha'], 'sha1'); + expect(t.sent.last['path'], 'a.txt'); + + // A working-tree diff-content reply for the SAME path must not + // overwrite the commit-scoped one that's in flight. + t.emit('git:diff-content', { + 'projectId': 'p', + 'path': 'a.txt', + 'diff': 'stale working-tree diff', + 'additions': 9, + 'deletions': 9, + }); + await Future.delayed(Duration.zero); + expect(svc.currentState.git.diffLoading, isTrue); + expect(svc.currentState.git.diffContent, isNull); + + t.emit('git:commit-diff-content', { + 'projectId': 'p', + 'sha': 'sha1', + 'path': 'a.txt', + 'diff': '@@ -1 +1 @@', + 'additions': 1, + 'deletions': 0, + }); + await Future.delayed(Duration.zero); + expect(svc.currentState.git.diffLoading, isFalse); + expect(svc.currentState.git.diffContent, '@@ -1 +1 @@'); + expect(svc.currentState.git.diffCommitSha, 'sha1'); + + // Switching to the working-tree diff for a different path clears the + // commit scope. + svc.requestDiff('b.txt'); + expect(svc.currentState.git.diffCommitSha, isNull); + + await svc.dispose(); + await session.close(); + }); + }); } diff --git a/app/test/services/handler_service_test.dart b/app/test/services/handler_service_test.dart index a30d2ee7..9fd25fa7 100644 --- a/app/test/services/handler_service_test.dart +++ b/app/test/services/handler_service_test.dart @@ -397,7 +397,7 @@ void main() { t.emit('handler:status', {'projectId': 'p', 'sessions': []}); await Future.delayed(Duration.zero); - final judge = svc.lastKnownJudge('t1'); + final judge = svc.lastKnownSettings('t1'); expect(judge?.tool, 'codex'); expect(judge?.model, 'm'); @@ -426,7 +426,7 @@ void main() { ], }); await Future.delayed(Duration.zero); - expect(svc.lastKnownJudge('t1')?.tool, 'codex'); + expect(svc.lastKnownSettings('t1')?.tool, 'codex'); // …then a re-arm switching to 'opencode'. No new snapshot yet: reopening a // picker in this window must seed the NEW pick — a stale seed committed by @@ -437,7 +437,7 @@ void main() { judgeModel: '', ); - final judge = svc.lastKnownJudge('t1'); + final judge = svc.lastKnownSettings('t1'); expect(judge?.tool, 'opencode'); // Explicit '' clears the model, mirroring the bridge's applyJudgeChoice. expect(judge?.model, isNull); @@ -467,7 +467,7 @@ void main() { ], }); await Future.delayed(Duration.zero); - expect(svc.lastKnownJudge('t1')?.tool, 'codex'); + expect(svc.lastKnownSettings('t1')?.tool, 'codex'); // …then re-armed with the judge cleared back to default. The session // stays armed (still present in the snapshot) with null judge fields — @@ -479,7 +479,7 @@ void main() { }); await Future.delayed(Duration.zero); - final judge = svc.lastKnownJudge('t1'); + final judge = svc.lastKnownSettings('t1'); expect(judge?.tool, isNull); expect(judge?.model, isNull); diff --git a/app/test/services/preview_service_test.dart b/app/test/services/preview_service_test.dart index 6562fb05..0c11a721 100644 --- a/app/test/services/preview_service_test.dart +++ b/app/test/services/preview_service_test.dart @@ -239,6 +239,78 @@ void main() { await session.close(); }); + test('WebSocket frames wait for open and retain browser order', () async { + final t = _GateFirstWsSendTransport(); + final session = await _newSession(t); + addTearDown(() async => session.close()); + final svc = session.previewService; + final port = await freePort(); + // Not discarded: `openTab` passes allowFallback:false, so a lost port + // race binds no proxy and every assertion below would then be aimed at + // whatever else holds the port. + expect(await svc.openTab(port), SelectPortResult.opened); + addTearDown(() async => svc.closeTab(port)); + + final ws = await WebSocket.connect('ws://localhost:$port/_blazor'); + addTearDown(() async => ws.close()); + // A gate left held would leave the outbound queue's tail pending forever. + addTearDown(t.releaseOpen); + + ws.add('signalr-handshake'); + ws.add([0, 1, 2, 255]); + await Future.delayed(const Duration(milliseconds: 50)); + + // The open send is deliberately held incomplete. No data send may even + // start while it is still being sealed/routed. + expect(t.tunnelFrames.map((m) => m['type']), ['tunnel:ws-open']); + + // Close the browser socket while the gate still holds: the close frame + // must queue BEHIND the data it follows, not race ahead of it. Asserting + // only that a close eventually arrives would pass on plain + // fire-and-forget sends, which is the property under test. + await ws.close(); + await Future.delayed(const Duration(milliseconds: 50)); + expect(t.tunnelFrames.map((m) => m['type']), ['tunnel:ws-open']); + + t.releaseOpen(); + await _waitUntil( + () => t.tunnelFrames.any((m) => m['type'] == 'tunnel:ws-close'), + ); + expect(t.tunnelFrames.map((m) => m['type']), [ + 'tunnel:ws-open', + 'tunnel:ws-data', + 'tunnel:ws-data', + 'tunnel:ws-close', + ]); + expect(t.tunnelFrames[1]['data'], 'signalr-handshake'); + expect(t.tunnelFrames[1]['binary'], isNull); + expect(t.tunnelFrames[2]['binary'], isTrue); + expect(t.tunnelFrames[2]['data'], 'AAEC/w=='); + }); + + test('a tunnel whose open cannot be delivered closes the browser socket', () async { + final t = _GateFirstWsSendTransport(); + final session = await _newSession(t); + addTearDown(() async => session.close()); + final svc = session.previewService; + final port = await freePort(); + expect(await svc.openTab(port), SelectPortResult.opened); + addTearDown(() async => svc.closeTab(port)); + + // A send with no session keys installed completes SUCCESSFULLY and + // delivers nothing — the state a relay reconnect passes through, and + // exactly when a previewed page's own socket reconnects. + t.setEstablished(false); + + final ws = await WebSocket.connect('ws://localhost:$port/_blazor'); + // The browser must see a real close it can reconnect from, rather than + // holding a socket against a tunnel the bridge never heard of. Drained + // rather than awaiting `done`: the close frame is only processed once + // something reads the stream. + await ws.drain().timeout(const Duration(seconds: 2)); + expect(t.sent.any((m) => m['type'] == 'tunnel:ws-open'), isFalse); + }); + test( 'openTab (relay) with a path lands the tab there behind the proxy', () async { @@ -753,3 +825,40 @@ class _LocalFakeTransport extends FakeAgentTransport { @override bool get isLocal => true; } + +class _GateFirstWsSendTransport extends FakeAgentTransport { + final Completer _openGate = Completer(); + final List> started = >[]; + + /// [started] records every frame the session sends — a project bind alone + /// emits several before any tunnel exists — so order assertions have to be + /// made against the tunnel's own frames. + List> get tunnelFrames => [ + for (final m in started) + if ((m['type'] as String).startsWith('tunnel:')) m, + ]; + + void releaseOpen() { + if (!_openGate.isCompleted) _openGate.complete(); + } + + @override + Future send( + Map message, { + String channel = 'control', + }) async { + started.add(message); + if (message['type'] == 'tunnel:ws-open') await _openGate.future; + await super.send(message, channel: channel); + } +} + +Future _waitUntil(bool Function() condition) async { + final deadline = DateTime.now().add(const Duration(seconds: 2)); + while (!condition()) { + if (DateTime.now().isAfter(deadline)) { + throw TimeoutException('condition was not met'); + } + await Future.delayed(const Duration(milliseconds: 10)); + } +} diff --git a/app/test/storage/recent_ports_store_test.dart b/app/test/storage/recent_ports_store_test.dart deleted file mode 100644 index 21e5b1bc..00000000 --- a/app/test/storage/recent_ports_store_test.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:antgrid/storage/recent_ports_store.dart'; - -import '../helpers/prefs_test_mock.dart'; - -void main() { - setUp(() { - useInMemoryPrefs(); - }); - - test( - 'removeProject drops all ports for the project, leaves others', - () async { - final store = await RecentPortsStore.open(); - addTearDown(store.close); - await store.add('p1', 3000, 'http'); - await store.add('p1', 5173, 'http'); - await store.add('p2', 8080, 'https'); - - await store.removeProject('p1'); - - expect(store.list('p1'), isEmpty); - expect(store.list('p2').map((e) => e.port), [8080]); - }, - ); - - test('removeProject persists the removal across reopen', () async { - final store = await RecentPortsStore.open(); - await store.add('p1', 3000, 'http'); - await store.add('p2', 8080, 'http'); - - await store.removeProject('p1'); - - final reopened = await RecentPortsStore.open(); - expect(reopened.list('p1'), isEmpty); - expect(reopened.list('p2').map((e) => e.port), [8080]); - }); - - test('removeProject is a no-op for an unknown project', () async { - final store = await RecentPortsStore.open(); - addTearDown(store.close); - await store.add('p1', 3000, 'http'); - - await store.removeProject('does-not-exist'); - - expect(store.list('p1').map((e) => e.port), [3000]); - }); -} diff --git a/app/test/util/external_url_test.dart b/app/test/util/external_url_test.dart new file mode 100644 index 00000000..ea78704c --- /dev/null +++ b/app/test/util/external_url_test.dart @@ -0,0 +1,42 @@ +import 'package:antgrid/util/external_url.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('terminalFilePath', () { + test('extracts a POSIX absolute path', () { + expect( + terminalFilePath('file:///home/user/project/src/app.ts'), + '/home/user/project/src/app.ts', + ); + }); + + test('strips the extra leading slash before a Windows drive letter', () { + expect( + terminalFilePath('file:///C:/Users/dev/project/main.dart'), + 'C:/Users/dev/project/main.dart', + ); + }); + + test('percent-decodes escaped characters', () { + expect( + terminalFilePath('file:///home/user/my%20project/a%26b.txt'), + '/home/user/my project/a&b.txt', + ); + }); + + test('tolerates a hostname authority (some tools emit one)', () { + expect( + terminalFilePath('file://myhost/home/user/project/app.ts'), + '/home/user/project/app.ts', + ); + }); + + test('returns null for a non-file scheme', () { + expect(terminalFilePath('https://example.com/app.ts'), isNull); + }); + + test('returns null for an unparseable string', () { + expect(terminalFilePath('not a uri at all: %zz'), isNull); + }); + }); +} diff --git a/app/test/widgets/diff_viewer_test.dart b/app/test/widgets/diff_viewer_test.dart index c35a275c..5754a019 100644 --- a/app/test/widgets/diff_viewer_test.dart +++ b/app/test/widgets/diff_viewer_test.dart @@ -54,6 +54,7 @@ Widget _host({ deletions: 1, onViewFile: () {}, onClose: () {}, + onSendToAgent: (context, message) async {}, ), ), ), @@ -198,6 +199,7 @@ void main() { deletions: 1, onViewFile: () {}, onClose: () {}, + onSendToAgent: (context, message) async {}, ), ], ), @@ -344,6 +346,7 @@ void main() { deletions: 0, onViewFile: () {}, onClose: () {}, + onSendToAgent: (context, message) async {}, ), ), ), diff --git a/app/test/widgets/drawer_entry_row_status_test.dart b/app/test/widgets/drawer_entry_row_status_test.dart index 2bb0a1d0..935170ad 100644 --- a/app/test/widgets/drawer_entry_row_status_test.dart +++ b/app/test/widgets/drawer_entry_row_status_test.dart @@ -97,9 +97,10 @@ void main() { // projectWorkStatusProvider: that would assert against the override rather // than against the path a real advert takes. // - // Asserted by TYPE, not by key: the dot carries no key, so a - // `find.byKey` here passes whether or not it is rendered — which is what - // let the collapsed case go unnoticed when the rollup was added. + // Asserted by TYPE, and absence means absence: the row decides whether the + // dot exists at all (`DrawerProjectAggregateDot.needsUser`), so + // `findsNothing` is a widget missing from the tree rather than one that is + // mounted with nothing to draw. final callToAction = { AgentWorkStatus.attention, AgentWorkStatus.unread, diff --git a/app/test/widgets/drawer_rail_test.dart b/app/test/widgets/drawer_rail_test.dart new file mode 100644 index 00000000..dd78bd28 --- /dev/null +++ b/app/test/widgets/drawer_rail_test.dart @@ -0,0 +1,606 @@ +// The drawer's trailing rail: every outermost glyph in the panel — dot, +// trash, kebab, `+`, refresh — sits in one column, and no row changes height +// when its actions are revealed. +// +// Horizontal assertions because the rail is a POSITION, not a widget: nothing +// in the type system connects a status dot centred in one row to a button +// centred in the next, so only a measurement can hold them together. +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart' show PointerDeviceKind; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:antgrid/connection/supervisor_state.dart'; +import 'package:antgrid/design/ab_tokens.dart'; +import 'package:antgrid/design/widgets/ab_icon_button.dart'; +import 'package:antgrid/design/widgets/ab_status_dot.dart'; +import 'package:antgrid/launcher/host_controller.dart'; +import 'package:antgrid/models/ab_project.dart'; +import 'package:antgrid/models/drawer_entry.dart'; +import 'package:antgrid/models/session_entry.dart'; +import 'package:antgrid/project/project_session_registry.dart'; +import 'package:antgrid/project/project_status.dart'; +import 'package:antgrid/providers/account_agents.dart'; +import 'package:antgrid/providers/auth.dart'; +import 'package:antgrid/providers/control_plane.dart'; +import 'package:antgrid/providers/host_status.dart'; +import 'package:antgrid/providers/supervisor_status.dart'; +import 'package:antgrid/services/control_plane_client.dart'; +import 'package:antgrid/storage/recent_agents_store.dart'; +import 'package:antgrid/widgets/drawer_entry_row.dart'; +import 'package:antgrid/widgets/projects_drawer.dart'; +import 'package:antgrid/widgets/session_row.dart'; + +import '../helpers/hover.dart'; +import '../helpers/prefs_test_mock.dart'; +import '../helpers/test_store_overrides.dart'; + +const _machineUuid = 'machine-1'; +const _machineName = 'RadhaAI'; + +/// Holds a cached session, so its trash is withheld and the `+` is its +/// outermost action. +const _warmProjectId = 'alpha-local'; +const _warmName = 'Alpha local'; + +/// Holds none, so it offers BOTH actions and the trash is the outermost one — +/// the case that proves the rail belongs to whatever ends up last. +const _bareProjectId = 'beta-local'; +const _bareName = 'Beta local'; + +const _advertisedProjectId = 'gamma'; +const _advertisedName = 'Gamma'; +const _sessionName = 'Trace the reflow'; + +const _removeTip = 'Remove from history'; +const _forgetTip = 'Forget agent'; +const _newSessionTip = 'New session'; +const _kebabTip = 'Session actions'; + +/// Slack for the comparison itself, not for misalignment: the defect this +/// suite exists to catch moves a glyph by ~9px, and a 24px button beside a 6px +/// dot is 9px out the moment either stops being centred in its own cell. +const double _railEpsilon = 0.01; + +typedef _Variant = ({String name, TargetPlatform platform, double scale}); + +const _variants = <_Variant>[ + (name: 'desktop @1.0', platform: TargetPlatform.windows, scale: 1.0), + (name: 'desktop @1.3', platform: TargetPlatform.windows, scale: 1.3), + // Touch is where a cell that forgot [AbTokens.tapTargetMin] shows: every + // button widens to 44 while a bare dot does not. + (name: 'mobile @1.0', platform: TargetPlatform.android, scale: 1.0), +]; + +AbProject _project(String id, String name) => AbProject( + projectId: id, + folder: '/tmp/$id', + displayName: name, + hostDeviceUuid: id, + hostMachineName: '', + lastOpenedAt: DateTime(2026, 1, 1), +); + +SessionEntry _session() => SessionEntry( + id: 'sess-1', + name: _sessionName, + createdAt: 0, + lastUsedAt: 0, + archived: false, + running: false, +); + +RecentAgent _machine() => RecentAgent( + agentDeviceId: _machineUuid, + agentLabel: 'Remote pair', + agentEd25519Pubkey: 'pub', + relayUrl: 'wss://relay.example.com', + pairedAt: DateTime(2026, 1, 1), + lastConnectedAt: DateTime(2026, 1, 2), + hostMachineName: _machineName, +); + +/// Runs [body] with [platform] in force, lifting it inside the body rather +/// than in a `tearDown`: flutter_test runs `debugAssertAllFoundationVarsUnset` +/// at the end of the test BODY, before any teardown gets a turn. +Future _onPlatform( + TargetPlatform platform, + Future Function() body, +) async { + debugDefaultTargetPlatformOverride = platform; + try { + await body(); + } finally { + debugDefaultTargetPlatformOverride = null; + } +} + +/// A viewport tall enough that the whole drawer — header, list, docked setup +/// section and pinned footer — is laid out at once. The list is a +/// [ReorderableListView]: a row scrolled out of it is never built, and a +/// finder that misses because of that would read as a missing widget. +void _useTallView(WidgetTester tester) { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1000, 2000); + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); +} + +Future _seed(TestStoreOverrides stores) async { + await stores.projectStore.upsert(_project(_warmProjectId, _warmName)); + await stores.projectStore.upsert(_project(_bareProjectId, _bareName)); + await stores.cachedSessionsStore.put(_warmProjectId, [_session()]); + // Cancels the store's 200ms write debounce so no timer outlives the tree. + await stores.cachedSessionsStore.flushNow(); + await stores.recentAgentsStore.upsert(_machine()); +} + +Future _pumpDrawer( + WidgetTester tester, { + required TestStoreOverrides stores, + double textScale = 1.0, +}) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + ...stores.overrides, + currentUserProvider.overrideWith((_) async => null), + accountAgentsProvider.overrideWith((_) async => const []), + for (final id in const [_warmProjectId, _bareProjectId, _machineUuid]) + projectStatusProvider( + id, + ).overrideWith((_) => Stream.value(const ProjectStatus.empty())), + // Both bands render their liveness glyph only when there is something + // to report, and that glyph is the resting tenant of the rail cell. + hostStatusProvider.overrideWith( + (_) => Stream.value(const HostStatus(HostPhase.up)), + ), + supervisorStatusProvider( + _machineUuid, + ).overrideWith((_) => Stream.value(const Connected())), + controlPlaneStateProvider(_machineUuid).overrideWith( + (_) => Stream.value( + const ControlPlaneState( + projects: [ + AdvertisedProject( + projectId: _advertisedProjectId, + label: _advertisedName, + path: '/gamma', + running: true, + ), + ], + ), + ), + ), + ], + child: MaterialApp( + home: Builder( + builder: (context) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(textScale)), + child: const Scaffold(body: ProjectsDrawer()), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + // A machine's advertised projects exist only once its band is open. + await tester.tap(find.text(_machineName)); + await tester.pumpAndSettle(); +} + +/// The [HoverableDrawerRow] wrapping the row labelled [label] — the shell +/// every project row and machine band is built inside. +Finder _row(String label) => find.ancestor( + of: find.text(label), + matching: find.byType(HoverableDrawerRow), +); + +Finder _entryRow(String label) => + find.ancestor(of: find.text(label), matching: find.byType(DrawerEntryRow)); + +/// The band itself, excluding the hairline a [HoverableDrawerRow] carries above +/// it — that rule belongs to the block, not to the row's own metrics. +Finder _band(String label) => + find.ancestor(of: find.text(label), matching: find.byType(DrawerBand)); + +Finder _within(Finder row, Finder glyph) => + find.descendant(of: row, matching: glyph); + +/// The rail's x, read off the one element that is on it without a pointer or a +/// row around it. Every case compares against this rather than against each +/// other, because flutter_test's mouse is a single device — two hovers in one +/// test assert — and equality against a shared reference is transitive +/// anyway. +double _railX(WidgetTester tester) { + final refresh = find.byTooltip('Refresh'); + expect( + refresh, + findsOneWidget, + reason: 'the PROJECTS header refresh is the rail reference', + ); + return tester.getCenter(refresh).dx; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late TestStoreOverrides stores; + + setUp(() async { + useInMemoryPrefs(); + stores = await buildTestStoreOverrides(); + }); + + tearDown(() async { + await stores.close(); + }); + + group('the trailing rail', () { + for (final v in _variants) { + testWidgets('${v.name}: the resting glyphs share the column', ( + tester, + ) async { + await _onPlatform(v.platform, () async { + _useTallView(tester); + await _seed(stores); + await _pumpDrawer(tester, stores: stores, textScale: v.scale); + + final rail = _railX(tester); + + final hostDot = _within( + find.byType(LocalMachineBand), + find.byType(AbStatusDot), + ); + expect(hostDot, findsOneWidget); + expect( + tester.getCenter(hostDot).dx, + closeTo(rail, _railEpsilon), + reason: + "LocalMachineBand's host dot is off the rail the PROJECTS " + 'refresh sits on', + ); + + final bandTrash = _within( + _row(_machineName), + find.byTooltip(_forgetTip), + ); + expect(bandTrash, findsOneWidget); + expect( + tester.getCenter(bandTrash).dx, + closeTo(rail, _railEpsilon), + reason: "the machine band's terminal cell is off the rail", + ); + + if (v.platform != TargetPlatform.android) { + // Desktop puts both of a band's tenants in the SAME cell, so the + // liveness dot answers for the rail exactly as the trash does. + // Touch renders them side by side instead — see the mobile case. + final bandDot = _within( + _row(_machineName), + find.byType(AbStatusDot), + ); + expect(bandDot, findsOneWidget); + expect( + tester.getCenter(bandDot).dx, + closeTo(rail, _railEpsilon), + reason: + "the band's liveness dot and its trash do not share a cell", + ); + } + + await tester.pumpWidget(const SizedBox()); + }); + }); + + for (final target in <({ + String name, + String hoverLabel, + Finder Function() glyph, + })>[ + ( + name: "a local project row's outermost action", + hoverLabel: _bareName, + glyph: () => + _within(_entryRow(_bareName), find.byTooltip(_removeTip)), + ), + ( + name: "an advertised project row's +", + hoverLabel: _advertisedName, + glyph: () => + _within(_row(_advertisedName), find.byTooltip(_newSessionTip)), + ), + ( + name: "a session row's kebab", + hoverLabel: _sessionName, + glyph: () => + _within(find.byType(SessionRow), find.byTooltip(_kebabTip)), + ), + ]) { + testWidgets('${v.name}: ${target.name} lands on the rail', ( + tester, + ) async { + await _onPlatform(v.platform, () async { + _useTallView(tester); + await _seed(stores); + await _pumpDrawer(tester, stores: stores, textScale: v.scale); + + final rail = _railX(tester); + // Deliberately not settled: a hovered project row arms a 300ms + // session prefetch, and advancing the clock past it would build a + // real ProjectSession. + await hoverRow(tester, find.text(target.hoverLabel)); + + final glyph = target.glyph(); + expect( + glyph, + findsOneWidget, + reason: 'hovering ${target.hoverLabel} must reveal its action', + ); + expect( + tester.getCenter(glyph).dx, + closeTo(rail, _railEpsilon), + reason: '${target.name} is off the rail', + ); + + await tester.pumpWidget(const SizedBox()); + }); + }); + } + } + }); + + group('no vertical jitter', () { + for (final scale in const [1.0, 1.3]) { + for (final target in <({ + String name, + String label, + Finder Function() row, + Finder Function() action, + bool collapsedAtRest, + })>[ + ( + name: 'a local project row holding sessions', + label: _warmName, + row: () => _entryRow(_warmName), + action: () => + _within(_entryRow(_warmName), find.byTooltip(_newSessionTip)), + collapsedAtRest: true, + ), + ( + name: 'a local project row holding none', + label: _bareName, + row: () => _entryRow(_bareName), + action: () => + _within(_entryRow(_bareName), find.byTooltip(_removeTip)), + collapsedAtRest: true, + ), + ( + name: 'a machine band', + label: _machineName, + row: () => _band(_machineName), + action: () => _within(_row(_machineName), find.byTooltip(_forgetTip)), + // A band swaps its cell's tenant instead of collapsing it, so the + // trash is laid out at rest and only fades in. + collapsedAtRest: false, + ), + ( + name: 'an advertised project row', + label: _advertisedName, + row: () => _row(_advertisedName), + action: () => + _within(_row(_advertisedName), find.byTooltip(_newSessionTip)), + collapsedAtRest: true, + ), + ]) { + testWidgets( + '${target.name} keeps its height on hover, scale $scale', + (tester) async { + await _onPlatform(TargetPlatform.windows, () async { + _useTallView(tester); + await _seed(stores); + await _pumpDrawer(tester, stores: stores, textScale: scale); + + final resting = tester.getSize(target.row()).height; + if (target.collapsedAtRest) { + expect( + target.action(), + findsNothing, + reason: + '${target.name} must drop its action at rest, or this ' + 'measures nothing', + ); + } + + await hoverRow(tester, find.text(target.label)); + + expect( + target.action(), + findsOneWidget, + reason: "hover must reveal ${target.name}'s action", + ); + expect( + tester.getSize(target.row()).height, + resting, + reason: + '${target.name} grew on pointer-enter, shoving every row ' + 'below it down', + ); + + await tester.pumpWidget(const SizedBox()); + }); + }, + ); + } + } + }); + + group('band metrics', () { + for (final scale in const [1.0, 1.3]) { + testWidgets('both bands measure the same at scale $scale', ( + tester, + ) async { + await _onPlatform(TargetPlatform.windows, () async { + _useTallView(tester); + await _seed(stores); + await _pumpDrawer(tester, stores: stores, textScale: scale); + + final local = _band('This machine'); + final machine = _band(_machineName); + expect(local, findsOneWidget); + expect(machine, findsOneWidget); + + final localHeight = tester.getSize(local).height; + expect( + tester.getSize(machine).height, + localHeight, + reason: + 'the two bands sit in one run and must read as one row class', + ); + // Pinned to the floor's own arithmetic rather than to a literal: the + // content floor scales, the padding and margin do not. + expect( + localHeight, + closeTo( + AbIconButton.boxExtent(tester.element(local)) + + 2 * AbTokens.space6 + + 2 * AbTokens.space2, + _railEpsilon, + ), + reason: 'a band is content floor + row padding + row margin', + ); + + await tester.pumpWidget(const SizedBox()); + }); + }); + } + }); + + testWidgets('keyboard focus reveals the actions a pointer would', ( + tester, + ) async { + await _onPlatform(TargetPlatform.windows, () async { + final entry = LocalProjectEntry(_project(_bareProjectId, _bareName)); + await tester.pumpWidget( + ProviderScope( + overrides: [ + ...stores.overrides, + projectStatusProvider( + _bareProjectId, + ).overrideWith((_) => Stream.value(const ProjectStatus.empty())), + ], + child: MaterialApp(home: Scaffold(body: DrawerEntryRow(entry))), + ), + ); + await tester.pump(); + + final trash = find.byTooltip(_removeTip); + final newSession = find.byTooltip(_newSessionTip); + expect(trash, findsNothing); + expect(newSession, findsNothing); + + // Tab rather than a direct focus request: the reveal hangs off the focus + // HIGHLIGHT, which only a traversal or a key press turns on. + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + + expect( + trash, + findsOneWidget, + reason: + 'a collapsed action a pointer alone can summon is unreachable ' + 'without a mouse', + ); + expect(newSession, findsOneWidget); + + await tester.pumpWidget(const SizedBox()); + }); + }); + + testWidgets('mobile keeps the band liveness dot beside its revealed trash', ( + tester, + ) async { + await _onPlatform(TargetPlatform.android, () async { + _useTallView(tester); + await _seed(stores); + await _pumpDrawer(tester, stores: stores); + + final band = _row(_machineName); + final dot = _within(band, find.byType(AbStatusDot)); + final trash = _within(band, find.byTooltip(_forgetTip)); + + // Asserted on the dot's presence, not on the width of a slot: the slot is + // reserved either way, so measuring it would pass with nothing in it. + expect( + dot, + findsOneWidget, + reason: + 'touch reveals the trash permanently, and a swap would hide the ' + "machine's only liveness report forever", + ); + expect(trash, findsOneWidget); + expect( + tester.getCenter(trash).dx, + closeTo(_railX(tester), _railEpsilon), + reason: 'the action, not the dot, owns the terminal cell on touch', + ); + expect( + tester.getCenter(dot).dx, + lessThan(tester.getCenter(trash).dx), + reason: 'the resting glyph sits inboard of the cell it cannot share', + ); + + await tester.pumpWidget(const SizedBox()); + }); + }); + + testWidgets('a revealed action outlives the hover its own modal ends', ( + tester, + ) async { + await _onPlatform(TargetPlatform.macOS, () async { + _useTallView(tester); + await _seed(stores); + await _pumpDrawer(tester, stores: stores); + + final trash = _within(_entryRow(_bareName), find.byTooltip(_removeTip)); + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + await gesture.moveTo(tester.getCenter(_entryRow(_bareName))); + await tester.pump(); + expect(trash, findsOneWidget); + + await tester.tap(trash); + await tester.pumpAndSettle(); + // The modal covers the row, so the pointer has already left it; moving + // the mouse away only makes that explicit. + await gesture.moveTo(Offset.zero); + await tester.pumpAndSettle(); + + expect( + trash, + findsOneWidget, + reason: + 'the row must not collapse its actions out from under a dialog ' + "one of them opened — the button's own in-flight state goes with " + 'it', + ); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect( + trash, + findsNothing, + reason: 'and the latch must release once the dialog is answered', + ); + + await tester.pumpWidget(const SizedBox()); + }); + }); +} diff --git a/app/test/widgets/file_tree_view_test.dart b/app/test/widgets/file_tree_view_test.dart index 8e587abb..5e9247ba 100644 --- a/app/test/widgets/file_tree_view_test.dart +++ b/app/test/widgets/file_tree_view_test.dart @@ -1,5 +1,4 @@ import 'package:flutter/foundation.dart'; -import 'package:flutter/gestures.dart' show PointerDeviceKind; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:antgrid/design/widgets/ab_empty_state.dart'; @@ -9,6 +8,8 @@ import 'package:antgrid/models/ab_message.dart'; import 'package:antgrid/models/file_tree_models.dart'; import 'package:antgrid/widgets/file_tree_view.dart'; +import '../helpers/hover.dart'; + void main() { FileNode makeTree() { return const FileNode( @@ -657,17 +658,6 @@ void main() { } } - /// Brings a row's action buttons under the pointer. They are mounted at - /// full size but `Visibility(visible: hovered)`, so they are findable - /// without this and untappable until it runs. - Future hoverRow(WidgetTester tester, Finder target) async { - final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); - await gesture.addPointer(location: Offset.zero); - addTearDown(gesture.removePointer); - await gesture.moveTo(tester.getCenter(target)); - await tester.pump(); - } - testWidgets('an unstaged file exposes Stage and Discard, not Unstage', ( tester, ) async { @@ -688,6 +678,8 @@ void main() { onDiscard: (_) {}, ), () async { + await hoverRow(tester, find.text('main.dart')); + expect(find.byTooltip('Stage Changes'), findsOneWidget); expect(find.byTooltip('Discard Changes'), findsOneWidget); expect(find.byTooltip('Unstage Changes'), findsNothing); @@ -715,6 +707,8 @@ void main() { onDiscard: (_) {}, ), () async { + await hoverRow(tester, find.text('main.dart')); + expect(find.byTooltip('Unstage Changes'), findsOneWidget); expect(find.byTooltip('Stage Changes'), findsNothing); // Discard on a staged-only row is a revert to HEAD, not a no-op — @@ -746,12 +740,16 @@ void main() { onResolveConflict: (p) => resolved = p, ), () async { + // Revealed first: a conflicted row withholds the other three even + // when everything it could offer is on screen, which is a claim an + // unrevealed row cannot make. + await hoverRow(tester, find.text('main.dart')); + expect(find.byTooltip('Stage Changes'), findsNothing); expect(find.byTooltip('Unstage Changes'), findsNothing); expect(find.byTooltip('Discard Changes'), findsNothing); expect(discarded, isFalse); - await hoverRow(tester, find.text('main.dart')); await tester.tap(find.byTooltip('Mark Resolved')); await tester.pump(); expect(resolved, 'project/lib/main.dart'); @@ -908,6 +906,8 @@ void main() { onDiscard: (_) {}, ), () async { + await hoverRow(tester, find.text('main.dart')); + expect(find.byTooltip('Mark Resolved'), findsNothing); }, ); diff --git a/app/test/widgets/git_panel_checkout_back_test.dart b/app/test/widgets/git_panel_checkout_back_test.dart index 694df7fb..9425691f 100644 --- a/app/test/widgets/git_panel_checkout_back_test.dart +++ b/app/test/widgets/git_panel_checkout_back_test.dart @@ -79,6 +79,27 @@ void main() { ), ); await tester.pump(); + // The mounted panel is bound to the FOCUSED checkout (wt-1, per + // activeSessionProvider above) and eagerly claims its first `git:log` — + // see GitPanel._maybeLoadHistory. `main`'s FileService is constructed too + // (ProjectSession builds it eagerly) but never backs an on-screen panel in + // this test, so it never claims one; only wt-1's needs answering here. + final transport = session.transport as FakeAgentTransport; + for ( + var i = 0; + i < 5 && transport.sent.every((m) => m['type'] != 'git:log'); + i++ + ) { + await tester.pump(); + } + transport.emit('git:log-result', { + 'projectId': 'test', + 'checkoutId': 'wt-1', + 'commits': const [], + 'skip': 0, + 'hasMore': false, + }); + await tester.pump(); } // Both checkouts are left viewing a file, so the assertion distinguishes diff --git a/app/test/widgets/git_panel_header_test.dart b/app/test/widgets/git_panel_header_test.dart index 9910cd8e..7408b0c3 100644 --- a/app/test/widgets/git_panel_header_test.dart +++ b/app/test/widgets/git_panel_header_test.dart @@ -16,11 +16,11 @@ import 'package:antgrid/test_helpers/fake_agent_transport.dart'; import 'package:antgrid/widgets/git_panel.dart'; import 'package:antgrid/widgets/workspace_tab_bar.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/gestures.dart' show PointerDeviceKind; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../helpers/hover.dart'; import '../helpers/prefs_test_mock.dart'; void main() { @@ -92,6 +92,25 @@ void main() { ), ), ); + // The panel eagerly claims (and, one post-frame callback later, sends) + // its first `git:log` the moment its FileService is ready — see + // GitPanel._maybeLoadHistory. Answering it is what keeps that send's + // 15s reply-timeout timer from outliving the test; waiting for it to + // actually appear in `sent` (rather than a fixed pump count) is what + // keeps this robust against exactly how many frames that takes. + for ( + var i = 0; + i < 5 && transport.sent.every((m) => m['type'] != 'git:log'); + i++ + ) { + await tester.pump(); + } + transport.emit('git:log-result', { + 'projectId': 'p', + 'commits': const [], + 'skip': 0, + 'hasMore': false, + }); if (tree != null) { transport.emit('tree:full', {'projectId': 'p', 'root': tree}); } @@ -116,17 +135,6 @@ void main() { } } - /// Brings a tree row's action buttons under the pointer — they are mounted - /// at full size but `Visibility(visible: hovered)`, so they are findable - /// without this and untappable until it runs. - Future hoverRow(WidgetTester tester, Finder target) async { - final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); - await gesture.addPointer(location: Offset.zero); - addTearDown(gesture.removePointer); - await gesture.moveTo(tester.getCenter(target)); - await tester.pump(); - } - Map? sentOfType(String type) { for (final m in transport.sent.reversed) { if (m['type'] == type) return m; @@ -255,10 +263,10 @@ void main() { }, ]); - // Scoped to the header: a.dart's own row carries the same -4 badge. - final header = find - .ancestor(of: find.text('Changes'), matching: find.byType(Row)) - .first; + // Scoped to the header's own title row: a.dart's own row carries the + // same -4 badge, and the sub-tab strip above the header repeats the word + // "Changes" too — neither must be mistaken for the header's totals. + final header = find.byKey(gitChangesHeaderTitleKey); expect( find.descendant(of: header, matching: find.text('+2,010')), findsOneWidget, @@ -276,9 +284,7 @@ void main() { {'path': 'a.dart', 'status': 'R', 'staged': true, 'oldPath': 'z.dart'}, ]); - final header = find - .ancestor(of: find.text('Changes'), matching: find.byType(Row)) - .first; + final header = find.byKey(gitChangesHeaderTitleKey); expect( find.descendant(of: header, matching: find.textContaining('+')), findsNothing, @@ -297,19 +303,16 @@ void main() { {'path': 'a.dart', 'status': 'M', 'staged': true}, ], width: 300); - final title = tester.getRect(find.text('Changes')); + final titleRow = tester.getRect(find.byKey(gitChangesHeaderTitleKey)); final commit = tester.getRect(find.byType(AbButton).last); expect( commit.top, - greaterThanOrEqualTo(title.bottom), + greaterThanOrEqualTo(titleRow.bottom), reason: 'the actions belong on their own row, below the title', ); - // The title row spans the header (its text sits in an Expanded), so its + // The title row spans the header (it sits in an Expanded), so its // trailing edge is where a right-aligned action has to end. - final titleRow = tester.getRect( - find.ancestor(of: find.text('Changes'), matching: find.byType(Row)).first, - ); expect(commit.right, closeTo(titleRow.right, 8)); }); @@ -318,9 +321,9 @@ void main() { {'path': 'a.dart', 'status': 'M', 'staged': true}, ]); - final title = tester.getRect(find.text('Changes')); + final titleRow = tester.getRect(find.byKey(gitChangesHeaderTitleKey)); final commit = tester.getRect(find.byType(AbButton).last); - expect(commit.top, lessThan(title.bottom)); + expect(commit.top, lessThan(titleRow.bottom)); }); // The state the panel used to render as an anonymous red dot on one row: git diff --git a/app/test/widgets/git_panel_sync_test.dart b/app/test/widgets/git_panel_sync_test.dart new file mode 100644 index 00000000..c6daca29 --- /dev/null +++ b/app/test/widgets/git_panel_sync_test.dart @@ -0,0 +1,310 @@ +// Push and Pull are the two header actions that reach the network, and the +// only ones whose refusal is handed to the agent rather than to a toast — so +// these pin what each button sends, when each is dead, and that a failure +// leaves an affordance behind instead of vanishing with the snackbar. +import 'package:antgrid/design/widgets/ab_button.dart'; +import 'package:antgrid/project/project_session.dart'; +import 'package:antgrid/project/project_session_registry.dart'; +import 'package:antgrid/providers/agent_transport.dart'; +import 'package:antgrid/providers/visible_surface.dart'; +import 'package:antgrid/storage/cached_sessions_store.dart'; +import 'package:antgrid/test_helpers/fake_agent_transport.dart'; +import 'package:antgrid/widgets/git_panel.dart'; +import 'package:antgrid/widgets/workspace_tab_bar.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../helpers/prefs_test_mock.dart'; + +void main() { + late FakeAgentTransport transport; + late ProjectSession session; + + /// The `git:sync-state` shape the bridge sends. Defaults describe a branch + /// level with its upstream, which is the state both buttons are dead in. + Map syncState({ + int ahead = 0, + int behind = 0, + bool hasUpstream = true, + bool hasRemote = true, + String? branch = 'main', + }) => { + 'projectId': 'p', + 'branch': branch, + 'remote': hasRemote ? 'origin' : null, + 'remoteBranch': hasRemote ? 'main' : null, + 'ahead': ahead, + 'behind': behind, + 'hasUpstream': hasUpstream, + 'hasRemote': hasRemote, + }; + + Future pump( + WidgetTester tester, { + Map? sync, + // Wide enough to stay off the header's stacked layout, so the actions sit + // on one row where the tooltips are reachable. + double width = 900, + }) async { + useInMemoryPrefs(); + transport = FakeAgentTransport(); + final cache = await CachedSessionsStore.open(); + session = ProjectSession( + projectId: 'p', + transport: transport, + mode: ProjectSessionMode.local, + cachedSessionsStore: cache, + onClose: () async => transport.dispose(), + ); + final c = ProviderContainer( + overrides: [ + selectedRegistrationIdProvider.overrideWithValue('p'), + projectSessionProvider('p').overrideWith((ref) => session), + ], + ); + addTearDown(c.dispose); + addTearDown(session.close); + c.read(visibleWorkspaceViewProvider.notifier).set(WorkspaceView.git); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: c, + child: MaterialApp( + home: Scaffold( + body: SizedBox(width: width, child: const GitPanel()), + ), + ), + ), + ); + // The panel eagerly claims (and, one post-frame callback later, sends) + // its first `git:log` the moment its FileService is ready — see + // GitPanel._maybeLoadHistory. Answering it is what keeps that send's + // 15s reply-timeout timer from outliving the test; waiting for it to + // actually appear in `sent` (rather than a fixed pump count) is what + // keeps this robust against exactly how many frames that takes. + for ( + var i = 0; + i < 5 && transport.sent.every((m) => m['type'] != 'git:log'); + i++ + ) { + await tester.pump(); + } + transport.emit('git:log-result', { + 'projectId': 'p', + 'commits': const [], + 'skip': 0, + 'hasMore': false, + }); + transport.emit('git:status', {'projectId': 'p', 'files': const []}); + if (sync != null) transport.emit('git:sync-state', sync); + await tester.pump(); + await tester.pump(); + } + + /// Land a `git:sync-result` for the op currently in flight. + /// + /// Every test that presses Push or Pull must end with one: the send arms a + /// wall-clock action timer that only the reply cancels, and a test that + /// disposes the tree with it still running fails on a pending Timer. + Future finishSync( + WidgetTester tester, { + String op = 'push', + bool success = true, + String? failureKind, + }) async { + transport.emit('git:sync-result', { + 'projectId': 'p', + 'op': op, + 'success': success, + 'branch': 'main', + if (!success) 'error': 'rejected', + 'failureKind': ?failureKind, + }); + await tester.pump(); + } + + Map? sentOfType(String type) { + for (final m in transport.sent.reversed) { + if (m['type'] == type) return m; + } + return null; + } + + testWidgets('asks for the sync state on open, without probing the remote', ( + tester, + ) async { + await pump(tester); + final asked = sentOfType('git:sync-status'); + expect(asked, isNotNull); + // A probe is a network round trip; the hydrator must never make one, or + // every reconnect costs an `ls-remote`. + expect(asked!['probeRemote'], isNull); + }); + + testWidgets('hides the sync control entirely when there is no remote', ( + tester, + ) async { + await pump(tester, sync: syncState(hasRemote: false, hasUpstream: false)); + expect(find.byTooltip('Push'), findsNothing); + expect(find.byTooltip('Pull'), findsNothing); + }); + + testWidgets('Push sends git:sync and names the op', (tester) async { + await pump(tester, sync: syncState(ahead: 2)); + await tester.tap(find.byTooltip('Push 2 commits')); + await tester.pump(); + expect(sentOfType('git:sync')?['op'], 'push'); + await finishSync(tester); + }); + + testWidgets('Pull sends git:sync and names the op', (tester) async { + await pump(tester, sync: syncState(behind: 1)); + await tester.tap(find.byTooltip('Pull 1 commit')); + await tester.pump(); + expect(sentOfType('git:sync')?['op'], 'pull'); + await finishSync(tester, op: 'pull'); + }); + + testWidgets('both stay mounted but dead on a branch level with its upstream', ( + tester, + ) async { + await pump(tester, sync: syncState()); + // Mounted: a control that vanishes at zero moves its neighbour under a + // finger already travelling toward it. + expect(find.byTooltip('Push'), findsOneWidget); + expect(find.byTooltip('Pull'), findsOneWidget); + await tester.tap(find.byTooltip('Push'), warnIfMissed: false); + await tester.tap(find.byTooltip('Pull'), warnIfMissed: false); + await tester.pump(); + expect(sentOfType('git:sync'), isNull); + }); + + testWidgets('a branch with no upstream offers Publish instead of Push', ( + tester, + ) async { + await pump(tester, sync: syncState(hasUpstream: false)); + expect(find.text('Publish Branch'), findsOneWidget); + // Push and Pull measure against an upstream that does not exist. + expect(find.byTooltip('Push'), findsNothing); + expect(find.byTooltip('Pull'), findsNothing); + + await tester.tap(find.text('Publish Branch')); + await tester.pump(); + expect(sentOfType('git:sync')?['op'], 'push'); + await finishSync(tester); + }); + + testWidgets('a second press while a sync is in flight sends nothing', ( + tester, + ) async { + await pump(tester, sync: syncState(ahead: 1, behind: 1)); + await tester.tap(find.byTooltip('Push 1 commit')); + await tester.pump(); + expect(transport.sent.where((m) => m['type'] == 'git:sync').length, 1); + + // Both are disabled together: they mutate the same branch, and a pull + // racing a push is a state neither result can describe. + await tester.tap(find.byTooltip('Pull 1 commit'), warnIfMissed: false); + await tester.pump(); + expect(transport.sent.where((m) => m['type'] == 'git:sync').length, 1); + await finishSync(tester); + }); + + testWidgets('a failure leaves a strip offering the agent', (tester) async { + await pump(tester, sync: syncState(ahead: 2, behind: 3)); + await tester.tap(find.byTooltip('Push 2 commits')); + await tester.pump(); + + transport.emit('git:sync-result', { + 'projectId': 'p', + 'op': 'push', + 'success': false, + 'branch': 'main', + 'remote': 'origin', + 'remoteBranch': 'main', + 'error': 'rejected', + 'failureKind': 'not-fast-forward', + 'command': 'git push', + 'stderr': '! [rejected] main -> main (non-fast-forward)', + }); + await tester.pump(); + + expect(find.textContaining('Push failed'), findsOneWidget); + expect(find.text('Ask agent to fix'), findsOneWidget); + // The buttons come back — the failure ended the op. + await tester.tap(find.byTooltip('Push 2 commits')); + await tester.pump(); + expect(transport.sent.where((m) => m['type'] == 'git:sync').length, 2); + await finishSync(tester); + }); + + testWidgets('a failure the user fixes themselves offers no agent handoff', ( + tester, + ) async { + await pump(tester, sync: syncState(ahead: 1)); + await tester.tap(find.byTooltip('Push 1 commit')); + await tester.pump(); + + transport.emit('git:sync-result', { + 'projectId': 'p', + 'op': 'push', + 'success': false, + 'branch': null, + 'error': 'HEAD is detached — check out a branch first', + 'failureKind': 'detached', + }); + await tester.pump(); + // Settled by the frame above — the result IS what ends the op. + + expect(find.textContaining('Push failed'), findsOneWidget); + // Sending the agent to run `git switch` is worse than the one tap the + // branch picker already is. + expect(find.text('Ask agent to fix'), findsNothing); + }); + + testWidgets('a success clears the strip and the counts follow the bridge', ( + tester, + ) async { + await pump(tester, sync: syncState(ahead: 2)); + await tester.tap(find.byTooltip('Push 2 commits')); + await tester.pump(); + transport.emit('git:sync-result', { + 'projectId': 'p', + 'op': 'push', + 'success': false, + 'branch': 'main', + 'error': 'rejected', + 'failureKind': 'rejected', + }); + await tester.pump(); + expect(find.text('Ask agent to fix'), findsOneWidget); + + // A later success must not leave the previous failure's offer standing. + await tester.tap(find.byTooltip('Push 2 commits')); + await tester.pump(); + transport.emit('git:sync-result', { + 'projectId': 'p', + 'op': 'push', + 'success': true, + 'branch': 'main', + 'summary': 'Pushed main to origin/main', + }); + transport.emit('git:sync-state', syncState()); + await tester.pump(); + + expect(find.text('Ask agent to fix'), findsNothing); + expect(find.byTooltip('Push'), findsOneWidget); + }); + + testWidgets('the commit button still renders beside the sync control', ( + tester, + ) async { + // The header is width-constrained and the sync control ate part of that + // budget; Commit is what must survive. + await pump(tester, sync: syncState(ahead: 2, behind: 1)); + expect(find.byType(AbButton), findsWidgets); + expect(find.text('Commit'), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} diff --git a/app/test/widgets/handler/handler_backlog_drawer_test.dart b/app/test/widgets/handler/handler_backlog_drawer_test.dart index 89dff553..4404dadd 100644 --- a/app/test/widgets/handler/handler_backlog_drawer_test.dart +++ b/app/test/widgets/handler/handler_backlog_drawer_test.dart @@ -4,11 +4,12 @@ import 'package:antgrid/design/ab_colors.dart'; import 'package:antgrid/design/ab_icons.dart'; import 'package:antgrid/design/ab_tokens.dart'; import 'package:antgrid/design/widgets/ab_button.dart'; -import 'package:antgrid/design/widgets/ab_chip.dart'; import 'package:antgrid/design/widgets/ab_empty_state.dart'; import 'package:antgrid/design/widgets/ab_icon.dart'; import 'package:antgrid/design/widgets/ab_icon_button.dart'; +import 'package:antgrid/design/widgets/ab_composer_send_button.dart'; import 'package:antgrid/design/widgets/ab_menu.dart'; +import 'package:antgrid/design/widgets/ab_prompt_field.dart'; import 'package:antgrid/design/widgets/ab_text_field.dart'; import 'package:antgrid/models/handler_state.dart'; import 'package:antgrid/project/project_session.dart'; @@ -20,6 +21,7 @@ import 'package:antgrid/storage/cached_sessions_store.dart'; import 'package:antgrid/storage/first_run_store.dart'; import 'package:antgrid/test_helpers/fake_agent_transport.dart'; import 'package:antgrid/widgets/handler/handler_backlog_drawer.dart'; +import 'package:antgrid/widgets/handler/handler_instruction_composer.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -233,6 +235,37 @@ Future _drainSnackBar(WidgetTester tester) async { await tester.pumpAndSettle(); } +/// The composer's field and its send key. Both hosts of +/// [HandlerInstructionComposer] carry the same two keys, so the finders written +/// here are the ones the arm sheet's tests use as well. +final _field = find.byKey(const Key('handler-instruction-field')); +final _sendKey = find.byKey(const Key('handler-instruction-send')); + +/// Every `handler:instruct` the drawer sent. File-scope because the edit-hold +/// and grant-echo groups assert on the same frames the instructing group does. +List> _instructs(ProjectSession session) => _transportOf( + session, +).sent.where((m) => m['type'] == 'handler:instruct').toList(); + +/// The one `handler:instruct` the drawer sent. +Map _sentInstruct(ProjectSession session) => + _instructs(session).single; + +/// Types [text] and taps the send key — the one route a sentence takes to +/// Handler now that the composer is the whole footer. +Future _sendInstruction(WidgetTester tester, String text) async { + await tester.enterText(_field, text); + // enterText schedules the composer's rebuild without pumping one, and the + // send key is inert until that frame lands — tapping it first is a no-op + // that reads exactly like a send the widget refused. + await tester.pump(); + await tester.tap(_sendKey); + await tester.pump(); +} + +String _fieldText(WidgetTester tester) => + tester.widget(_field).controller.text; + Future _pick(WidgetTester tester, String label) async { await tester.tap(find.text(label)); await tester.pumpAndSettle(); @@ -581,8 +614,8 @@ void main() { ), findsOneWidget, ); - // No second route to the one action: the presets and the field below are - // it, and a button here would give that action a second name. + // No second route to the one action: the composer below is it, and a + // button here would give that action a second name. expect(find.byTooltip('Send to Handler'), findsOneWidget); }); @@ -683,90 +716,27 @@ void main() { }); }); - // The instruction field and the presets live here rather than pinned above - // the composer, so this is where the load-bearing assertion now sits: the - // message TYPE a preset chip produces. A chip that grew its own verb would - // route around every rule that applies to instructions. + // The instruction box lives here rather than pinned above the session + // composer, so this is where the load-bearing assertions sit: what a typed + // sentence puts on the wire, and what becomes of the user's words when a send + // is refused. group('instructing', () { - List> instructs(ProjectSession session) => - _transportOf( - session, - ).sent.where((m) => m['type'] == 'handler:instruct').toList(); - - /// The one `handler:instruct` the drawer sent. - Map sentInstruct(ProjectSession session) => - instructs(session).single; - - testWidgets('a preset chip sends handler:instruct with its own sentence', ( - tester, - ) async { - final session = await _armedSession([_tests]); - await _pumpDrawer(tester, session); - - await tester.tap(find.text('Clean Build')); - await tester.pump(); - - final sent = sentInstruct(session); - // Not a chip-specific verb: the chip is indistinguishable on the wire - // from the user typing the same words. - expect(sent['terminalId'], 't1'); - expect(sent['text'], 'Clean Build'); - }); - - testWidgets('every preset chip is offered', (tester) async { - final session = await _armedSession([_tests]); - await _pumpDrawer(tester, session); - - for (final preset in handlerPresetInstructions) { - expect(find.text(preset), findsOneWidget); - } - }); - - testWidgets('and every one of them stays on a narrow phone', ( - tester, - ) async { - // `find.text` above passes on a preset parked off the right edge — a - // horizontal strip builds all its children whether or not any is - // reachable. Geometry is the only thing that can tell the two apart, and - // this width is where the fourth chip used to fall off. - tester.view.physicalSize = const Size(320, 900); - tester.view.devicePixelRatio = 1.0; - addTearDown(tester.view.reset); - - final session = await _armedSession([_tests]); - await _pumpDrawer(tester, session); - - for (final preset in handlerPresetInstructions) { - expect( - tester.getRect(find.text(preset)).right, - lessThanOrEqualTo(320.0), - reason: preset, - ); - } - - // Reachable, not merely laid out: the last one still sends. - await tester.tap(find.text(handlerPresetInstructions.last)); - await tester.pump(); - expect(sentInstruct(session)['text'], handlerPresetInstructions.last); - }); - testWidgets('typed text sends handler:instruct and clears the field', ( tester, ) async { final session = await _armedSession([_tests]); await _pumpDrawer(tester, session); - await tester.enterText(find.byType(AbTextField), 'also update the docs'); - await tester.tap(find.byTooltip('Send to Handler')); - await tester.pump(); + await _sendInstruction(tester, 'also update the docs'); - expect(sentInstruct(session)['text'], 'also update the docs'); + final sent = _sentInstruct(session); + // Nothing composer-specific on the wire: this is the same frame a phone + // typing the same words produces. + expect(sent['terminalId'], 't1'); + expect(sent['text'], 'also update the docs'); // The field is emptied; the sentence itself is not gone — it moves to // the list, which is the other half of this same submit. - expect( - tester.widget(find.byType(AbTextField)).controller!.text, - isEmpty, - ); + expect(_fieldText(tester), isEmpty); }); testWidgets('a sent instruction sits in the list until a status lands', ( @@ -775,9 +745,7 @@ void main() { final session = await _armedSession([_tests]); await _pumpDrawer(tester, session); - await tester.enterText(find.byType(AbTextField), 'also update the docs'); - await tester.tap(find.byTooltip('Send to Handler')); - await tester.pump(); + await _sendInstruction(tester, 'also update the docs'); // In the user's own words, at the tail — the slot appendItems will fill // with whatever the extractor makes of them. @@ -810,12 +778,12 @@ void main() { final session = await _armedSession(const []); await _pumpDrawer(tester, session); - await tester.tap(find.text('Run Tests')); - await tester.pump(); + await _sendInstruction(tester, 'Run Tests'); expect(find.textContaining('lands here'), findsNothing); - // Twice: the chip that sent it, and the row it is now waiting in. - expect(find.text('Run Tests'), findsNWidgets(2)); + // Once: the field cleared itself on the send, so the only copy on screen + // is the row the sentence is now waiting in. + expect(find.text('Run Tests'), findsOneWidget); }); testWidgets('a repeated send is refused until the snapshot lands', ( @@ -824,31 +792,25 @@ void main() { final session = await _armedSession([_tests]); await _pumpDrawer(tester, session); - // The chip, never the bare text: once the send lands, the sentence is on - // screen twice — on the chip and in the row waiting for its items. - final chip = find.widgetWithText(AbChip, 'Run Tests'); - await tester.tap(chip); - await tester.pump(); - await tester.tap(chip); - await tester.pump(); + await _sendInstruction(tester, 'Run Tests'); + await _sendInstruction(tester, 'Run Tests'); // The bridge appends and absorbs no duplicate, so a second identical // send is a second copy of the work in the backlog. - expect(instructs(session), hasLength(1)); + expect(_instructs(session), hasLength(1)); - // The second tap moves something on screen. Without it the chip is + // The second send moves something on screen. Without it the key is // indistinguishable from a broken button — the list is unchanged, and the // row waiting at the tail may be scrolled well out of sight. expect(find.text('Already sending "Run Tests".'), findsOneWidget); _emitStatus(session, [_tests, _extracted]); await tester.pump(); - await tester.tap(chip); - await tester.pump(); + await _sendInstruction(tester, 'Run Tests'); // The debounce lasts exactly as long as the ambiguity: once the bridge // has spoken, asking for the same thing again is a real second ask. - expect(instructs(session), hasLength(2)); + expect(_instructs(session), hasLength(2)); expect(find.text('Already sending "Run Tests".'), findsNothing); }); @@ -858,19 +820,13 @@ void main() { final session = await _armedSession([_tests]); await _pumpDrawer(tester, session); - await tester.tap(find.widgetWithText(AbChip, 'Run Tests')); - await tester.pump(); - await tester.enterText(find.byType(AbTextField), 'Run Tests'); - await tester.tap(find.byTooltip('Send to Handler')); - await tester.pump(); + await _sendInstruction(tester, 'Run Tests'); + await _sendInstruction(tester, 'Run Tests'); - expect(instructs(session), hasLength(1)); + expect(_instructs(session), hasLength(1)); // The field keeps what was typed: a clear on a send that did not happen // takes the user's words away and leaves an unchanged list behind. - expect( - tester.widget(find.byType(AbTextField)).controller!.text, - 'Run Tests', - ); + expect(_fieldText(tester), 'Run Tests'); expect(find.text('Already sending "Run Tests".'), findsOneWidget); }); @@ -880,12 +836,15 @@ void main() { final session = await _armedSession([_tests]); await _pumpDrawer(tester, session); - await tester.enterText(find.byType(AbTextField), 'also update the docs'); - await tester.tap(find.byTooltip('Send to Handler')); - await tester.tap(find.byTooltip('Send to Handler')); + await tester.enterText(_field, 'also update the docs'); + await tester.pump(); + // Twice with no pump between, which is the shape of a double tap: the + // first send clears the controller under a key that has not rebuilt yet. + await tester.tap(_sendKey); + await tester.tap(_sendKey); await tester.pump(); - expect(instructs(session), hasLength(1)); + expect(_instructs(session), hasLength(1)); }); testWidgets('a whitespace-only submit sends nothing', (tester) async { @@ -893,24 +852,27 @@ void main() { await _pumpDrawer(tester, session); final before = _transportOf(session).sent.length; - await tester.enterText(find.byType(AbTextField), ' '); - await tester.tap(find.byTooltip('Send to Handler')); + await tester.enterText(_field, ' '); + await tester.pump(); + + // Refused before the tap: whitespace leaves the key dead, so there is + // nothing to press rather than a press that quietly does nothing. + expect(tester.widget(_sendKey).onTap, isNull); + + await tester.tap(_sendKey); await tester.pump(); expect(_transportOf(session).sent.length, before); }); - testWidgets('a parked session keeps the chips and input live', ( - tester, - ) async { + testWidgets('a parked session keeps the composer live', (tester) async { // Stacking while parked is the point — the bridge queues it. final session = await _armedSession([_tests], state: 'parked'); await _pumpDrawer(tester, session); - await tester.tap(find.text('Run Tests')); - await tester.pump(); + await _sendInstruction(tester, 'Run Tests'); - expect(sentInstruct(session)['text'], 'Run Tests'); + expect(_sentInstruct(session)['text'], 'Run Tests'); }); testWidgets('an unarmed terminal is offered no field to instruct through', ( @@ -932,7 +894,7 @@ void main() { // Nothing is supervising that terminal, so an instruction would be sent // into a session that cannot run it. - expect(find.byType(AbTextField), findsNothing); + expect(find.byType(HandlerInstructionComposer), findsNothing); expect(find.text(handlerDisclaimerText), findsNothing); }); @@ -997,8 +959,7 @@ void main() { expect(find.text(handlerDisclaimerText), findsNothing); // Everything the sheet is FOR is untouched — the retirement is of one // standing notice, not of the footer it stood in. - expect(find.byType(AbTextField), findsOneWidget); - expect(find.text(handlerPresetInstructions.first), findsOneWidget); + expect(find.byType(HandlerInstructionComposer), findsOneWidget); }); }); @@ -1023,8 +984,7 @@ void main() { final session = await _armedSession([_tests, _commit, _pr]); await _pumpDrawer(tester, session); - await tester.tap(find.text('Run Tests')); - await tester.pump(); + await _sendInstruction(tester, 'Run Tests'); // On a phone this drawer is a modal sheet, which paints over the snack // bar its own ScaffoldMessenger renders, and a tooltip is long-press @@ -1044,8 +1004,7 @@ void main() { final session = await _armedSession([_tests, _commit, _pr]); await _pumpDrawer(tester, session); - await tester.tap(find.text('Run Tests')); - await tester.pump(); + await _sendInstruction(tester, 'Run Tests'); await _openMenuFor(tester, 2); // Still listed — the action applies, it is the moment that doesn't, and @@ -1068,8 +1027,7 @@ void main() { final session = await _armedSession([_tests, _commit, _pr]); await _pumpDrawer(tester, session); - await tester.tap(find.text('Run Tests')); - await tester.pump(); + await _sendInstruction(tester, 'Run Tests'); _emitStatus(session, [_tests, _commit, _pr, _extracted]); await tester.pump(); @@ -1088,8 +1046,7 @@ void main() { final session = await _armedSession([_tests, _commit, _pr]); await _pumpDrawer(tester, session); - await tester.tap(find.text('Run Tests')); - await tester.pump(); + await _sendInstruction(tester, 'Run Tests'); // Disabled outright rather than tinted disabled over a live control: the // reason is standing above the list, so this button has nothing left to @@ -1112,10 +1069,8 @@ void main() { final session = await _armedSession([_tests, _commit]); await _pumpDrawer(tester, session); - await tester.tap(find.text('Run Tests')); - await tester.pump(); - await tester.tap(find.text('Commit')); - await tester.pump(); + await _sendInstruction(tester, 'Run Tests'); + await _sendInstruction(tester, 'Commit'); const twoOutstanding = 'Still sending 2 instructions — editing is paused until they land.'; @@ -1131,21 +1086,13 @@ void main() { final session = await _armedSession([_tests]); await _pumpDrawer(tester, session); - await tester.tap(find.text('Run Tests')); - await tester.pump(); - await tester.enterText(find.byType(AbTextField), 'also update the docs'); - await tester.tap(find.byTooltip('Send to Handler')); - await tester.pump(); + await _sendInstruction(tester, 'Run Tests'); + await _sendInstruction(tester, 'also update the docs'); // Two appends cannot erase each other, and the extraction chain is // per-terminal and serial — so stacking work is exactly what this // surface is for, lock or no lock. - expect( - _transportOf( - session, - ).sent.where((m) => m['type'] == 'handler:instruct'), - hasLength(2), - ); + expect(_instructs(session), hasLength(2)); }); }); @@ -1703,8 +1650,7 @@ void main() { final session = await _armedSession(const [_tests]); await _pumpDrawer(tester, session); - await tester.tap(find.text('Clean Build')); - await tester.pump(); + await _sendInstruction(tester, 'Clean Build'); _emitGrant(session); await tester.pump(); @@ -1713,13 +1659,14 @@ void main() { findsOneWidget, ); expect(find.text('rm -rf · logs.example.com'), findsOneWidget); - // On the field's own left edge. The column around this centres anything - // that sizes to its child, and both lines are narrower than the sheet. + // On the composer's own left edge. The column around this centres + // anything that sizes to its child, and both lines are narrower than the + // sheet. expect( tester .getTopLeft(find.text('Also allowed for the rest of this session:')) .dx, - tester.getTopLeft(find.byType(AbTextField)).dx, + tester.getTopLeft(find.byType(HandlerInstructionComposer)).dx, ); }); @@ -1731,8 +1678,7 @@ void main() { final session = await _armedSession(const [_tests]); await _pumpDrawer(tester, session); - await tester.tap(find.text('Clean Build')); - await tester.pump(); + await _sendInstruction(tester, 'Clean Build'); _emitGrant(session, reason: 'rm -rf', detail: null); await tester.pump(); @@ -1745,8 +1691,7 @@ void main() { final session = await _armedSession(const [_tests]); await _pumpDrawer(tester, session); - await tester.tap(find.text('Clean Build')); - await tester.pump(); + await _sendInstruction(tester, 'Clean Build'); _emitGrant( session, reason: '20 hosts', @@ -1766,8 +1711,7 @@ void main() { final session = await _armedSession(const [_tests]); await _pumpDrawer(tester, session); - await tester.tap(find.text('Clean Build')); - await tester.pump(); + await _sendInstruction(tester, 'Clean Build'); expect(find.textContaining('Also allowed'), findsNothing); }); @@ -1797,8 +1741,7 @@ void main() { _emitGrant(session); await tester.pump(); - await tester.tap(find.text('Clean Build')); - await tester.pump(); + await _sendInstruction(tester, 'Clean Build'); expect(find.textContaining('Also allowed'), findsNothing); }); @@ -1811,8 +1754,7 @@ void main() { _emitGrant(session, recordId: 'old', detail: 'git clean -fd'); await tester.pump(); - await tester.tap(find.text('Clean Build')); - await tester.pump(); + await _sendInstruction(tester, 'Clean Build'); _emitGrant(session, recordId: 'new', detail: 'rm -rf'); await tester.pump(); @@ -1828,8 +1770,7 @@ void main() { final session = await _armedSession(const [_tests]); await _pumpDrawer(tester, session); - await tester.tap(find.text('Clean Build')); - await tester.pump(); + await _sendInstruction(tester, 'Clean Build'); _emitStatus(session, const [_tests, _extracted]); await tester.pump(); _emitGrant(session, recordId: 'phone', detail: 'git push --force'); @@ -1850,8 +1791,7 @@ void main() { ); await _pumpDrawer(tester, session, firstRun: firstRun); - await tester.tap(find.text('Clean Build')); - await tester.pump(); + await _sendInstruction(tester, 'Clean Build'); _emitGrant(session); await tester.pump(); diff --git a/app/test/widgets/handler/handler_header_pill_test.dart b/app/test/widgets/handler/handler_header_pill_test.dart index 8747a91d..363c4fe1 100644 --- a/app/test/widgets/handler/handler_header_pill_test.dart +++ b/app/test/widgets/handler/handler_header_pill_test.dart @@ -1,11 +1,15 @@ // The agent-header Handler pill for a parked session. A park is the one run // state with no call to action, so it must read as status and stay inert. import 'package:antgrid/models/handler_state.dart'; +import 'package:antgrid/models/session_entry.dart'; +import 'package:antgrid/models/workspace_view.dart'; import 'package:antgrid/providers/agent_transport.dart'; import 'package:antgrid/providers/first_run.dart'; import 'package:antgrid/providers/providers.dart'; import 'package:antgrid/providers/sessions.dart'; import 'package:antgrid/providers/value_controller.dart'; +import 'package:antgrid/providers/visible_surface.dart'; +import 'package:antgrid/services/sessions_service.dart'; import 'package:antgrid/storage/first_run_store.dart'; import 'package:antgrid/widgets/agent_panel.dart'; import 'package:flutter/material.dart'; @@ -32,6 +36,16 @@ HandlerSessionState _session( parkedUntil: parkedUntil, ); +SessionEntry _entry(String id, {bool deleting = false}) => SessionEntry( + id: id, + name: id, + createdAt: 0, + lastUsedAt: 0, + archived: false, + running: false, + deleting: deleting, +); + /// Focuses `t1` and renders the real production header control over /// [sessions]. No project is focused, so the handler service stays null — /// the pill derivation is all this exercises. @@ -116,4 +130,236 @@ void main() { }); expect(find.text('NEEDS YOU 2'), findsOneWidget); }); + + group('the pill that counts another session', () { + HandlerEscalation esc( + String terminalId, + int i, { + String urgency = 'normal', + int at = 1, + }) => HandlerEscalation( + escalationId: '$terminalId-$i', + terminalId: terminalId, + question: 'q', + reasoning: 'r', + draftReply: 'd', + urgency: urgency, + at: at, + ); + + /// Same control, but over a container the test keeps, so the tap's effect + /// on focus and on the pending destination is readable. + Future pumpWithContainer( + WidgetTester tester, + Map sessions, { + required String focused, + required void Function() onReveal, + List? escalations, + List? entries, + }) async { + useInMemoryPrefs(); + final store = await FirstRunStore.open(); + final container = ProviderContainer( + overrides: [ + firstRunStoreProvider.overrideWithValue(store), + // Given `entries`, focus is written through the REAL ActiveSessionId + // so its deleting-session guard is live; otherwise a pre-seeded plain + // controller stands in and the guard is out of scope. + if (entries == null) + activeSessionIdProvider.overrideWith(() => ValueController(focused)) + else + freshSessionsStateProvider.overrideWithValue( + SessionsState(projectId: 'p1', sessions: entries), + ), + selectedRegistrationIdProvider.overrideWith((_) => null), + revealHandlerTabProvider.overrideWith( + () => ValueController(onReveal), + ), + handlerStateProvider.overrideWith( + (ref) => Stream.value( + HandlerState.initial().copyWith( + sessions: sessions, + escalations: + escalations ?? + [ + for (final s in sessions.values) + for (var i = 0; i < s.pendingEscalations; i++) + esc(s.terminalId, i), + ], + ), + ), + ), + ], + ); + addTearDown(container.dispose); + if (entries != null) { + container.read(activeSessionIdProvider.notifier).set(focused); + } + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const MaterialApp( + home: Scaffold(body: HandlerHeaderControl()), + ), + ), + ); + await tester.pump(); + return container; + } + + testWidgets('focuses that session, and hands the tab over as pending', ( + tester, + ) async { + // The tab renders the focused session only, so revealing it without + // moving focus lands the user on an empty tab — the one navigation this + // pill exists to make. It goes through the pending handover rather than + // the reveal callback because the focus write it just made arms the + // shell's per-session UI restore, which undoes a tab selected before it. + var revealed = false; + final container = await pumpWithContainer( + tester, + { + 't1': _session('t1', runState: HandlerRunState.watching), + 't2': _session( + 't2', + runState: HandlerRunState.needsYou, + pendingEscalations: 1, + ), + }, + focused: 't1', + onReveal: () => revealed = true, + ); + + expect(find.text('NEEDS YOU 1'), findsOneWidget); + await tester.tap(find.text('NEEDS YOU 1')); + await tester.pump(); + + expect(container.read(activeSessionIdProvider), 't2'); + expect( + container.read(pendingWorkspaceViewProvider)?.value, + WorkspaceView.handler, + ); + expect(revealed, isFalse); + }); + + testWidgets('leaves focus alone when the focused session is the one ' + 'waiting', (tester) async { + var revealed = false; + final container = await pumpWithContainer( + tester, + { + 't1': _session( + 't1', + runState: HandlerRunState.needsYou, + pendingEscalations: 1, + ), + }, + focused: 't1', + onReveal: () => revealed = true, + ); + + await tester.tap(find.text('NEEDS YOU 1')); + await tester.pump(); + + expect(container.read(activeSessionIdProvider), 't1'); + expect(revealed, isTrue); + }); + + testWidgets('an urgent question elsewhere does not steal a session its ' + 'own pill was counting', (tester) async { + // `escalations` is banded by urgency across the whole project, so its + // head is not the row the pill was labelled from whenever the focused + // session has a question of its own — and a status pill must never + // switch the user's whole workspace to a session they did not pick. + var revealed = false; + final container = await pumpWithContainer( + tester, + { + 't1': _session( + 't1', + runState: HandlerRunState.needsYou, + pendingEscalations: 1, + ), + 't2': _session( + 't2', + runState: HandlerRunState.needsYou, + pendingEscalations: 1, + ), + }, + focused: 't1', + escalations: [esc('t2', 0, urgency: 'high'), esc('t1', 0, at: 2)], + onReveal: () => revealed = true, + ); + + expect(find.text('NEEDS YOU 1'), findsOneWidget); + await tester.tap(find.text('NEEDS YOU 1')); + await tester.pump(); + + expect(container.read(activeSessionIdProvider), 't1'); + expect(revealed, isTrue); + }); + + testWidgets('lands on a session the pill counted, not the oldest row on ' + 'the project', (tester) async { + // The mirror case: the focused session is not the one being counted, so + // the target has to come from the OTHER sessions' rows however the + // project-wide list happens to be ordered. + final container = await pumpWithContainer( + tester, + { + 't1': _session( + 't1', + runState: HandlerRunState.parked, + pendingEscalations: 1, + ), + 't2': _session( + 't2', + runState: HandlerRunState.needsYou, + pendingEscalations: 1, + ), + }, + focused: 't1', + escalations: [esc('t1', 0), esc('t2', 0, at: 2)], + onReveal: () {}, + ); + + expect(find.text('NEEDS YOU 1'), findsOneWidget); + await tester.tap(find.text('NEEDS YOU 1')); + await tester.pump(); + + expect(container.read(activeSessionIdProvider), 't2'); + }); + + testWidgets('a target the bridge is already deleting keeps the focus it ' + 'has and the tab that answers for it', (tester) async { + // `ActiveSessionId.set` refuses a deleting session, and such a session + // keeps its replayed escalations for the seconds before its row goes — + // so the pill can name a target the write will not take. Handing the tab + // over as pending regardless would stamp the session still in focus with + // a destination chosen for the other one. + var revealed = false; + final container = await pumpWithContainer( + tester, + { + 't1': _session('t1', runState: HandlerRunState.watching), + 't2': _session( + 't2', + runState: HandlerRunState.needsYou, + pendingEscalations: 1, + ), + }, + focused: 't1', + entries: [_entry('t1'), _entry('t2', deleting: true)], + onReveal: () => revealed = true, + ); + + expect(find.text('NEEDS YOU 1'), findsOneWidget); + await tester.tap(find.text('NEEDS YOU 1')); + await tester.pump(); + + expect(container.read(activeSessionIdProvider), 't1'); + expect(container.read(pendingWorkspaceViewProvider), isNull); + expect(revealed, isTrue); + }); + }); } diff --git a/app/test/widgets/handler/handler_instruction_composer_test.dart b/app/test/widgets/handler/handler_instruction_composer_test.dart new file mode 100644 index 00000000..b084f980 --- /dev/null +++ b/app/test/widgets/handler/handler_instruction_composer_test.dart @@ -0,0 +1,218 @@ +// The one box Handler instructions are typed into, shared by the arm sheet and +// the backlog drawer. The rules pinned here are the ones the two hosts must not +// be able to answer differently: what a return key does, when the send key is +// live, and who owns the text. +import 'package:antgrid/design/theme_presets.dart'; +import 'package:antgrid/design/widgets/ab_composer_send_button.dart'; +import 'package:antgrid/design/widgets/ab_prompt_field.dart'; +import 'package:antgrid/widgets/handler/handler_instruction_composer.dart'; +import 'package:antgrid/widgets/handler/handler_judge_chip.dart'; +import 'package:antgrid/widgets/handler/handler_session_settings.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/prefs_test_mock.dart'; + +final _field = find.byKey(const Key('handler-instruction-field')); +final _sendKey = find.byKey(const Key('handler-instruction-send')); + +/// The controller is HOST-owned, so the tests own it too — which is the point: +/// nothing the composer does may clear or dispose it. +Future _pump( + WidgetTester tester, { + VoidCallback? onSend, + String text = '', +}) async { + useInMemoryPrefs(); + final controller = TextEditingController(text: text); + addTearDown(controller.dispose); + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: ThemeData.dark().copyWith( + extensions: >[kDefaultPalette], + ), + home: Scaffold( + body: HandlerInstructionComposer( + terminalId: 't1', + controller: controller, + hintText: 'Send an instruction…', + judge: (judgeTool: null, judgeModel: null), + onJudgeChanged: (_) {}, + judgeScopeNote: handlerJudgeScopeNextPass, + send: onSend == null + ? null + : HandlerComposerSend( + tooltip: 'Send to Handler', + semanticLabel: 'Send to Handler', + onSend: onSend, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + return controller; +} + +/// Types into the field, which also gives it the focus every key test needs — +/// the Enter policy lives on the composer's own [FocusNode]. +Future _type(WidgetTester tester, String text) async { + await tester.enterText(_field, text); + await tester.pump(); +} + +void main() { + group('the return key', () { + testWidgets('hardware Enter commits through the host verb', (tester) async { + var sends = 0; + final controller = await _pump(tester, onSend: () => sends++); + await _type(tester, 'also update the docs'); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + + expect(sends, 1); + // Clearing is a statement that the send happened, which only the host + // knows — so the composer leaves the words exactly where they were. + expect(controller.text, 'also update the docs'); + }); + + testWidgets('Shift+Enter writes a newline and commits nothing', ( + tester, + ) async { + var sends = 0; + final controller = await _pump(tester, onSend: () => sends++); + await _type(tester, 'first line'); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + await tester.pump(); + + expect(sends, 0); + expect(controller.text, 'first line\n'); + }); + + testWidgets('Ctrl+Enter commits even mid-paragraph', (tester) async { + var sends = 0; + await _pump(tester, onSend: () => sends++); + await _type(tester, 'also update the docs'); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + + expect(sends, 1); + }); + + testWidgets('on a host with no send verb, Enter is a newline', ( + tester, + ) async { + // The arm sheet's shape: its one commit is [Arm Handler], so a return key + // here must not look like it promises anything. + final controller = await _pump(tester); + await _type(tester, 'also update the docs'); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + + expect(controller.text, 'also update the docs\n'); + expect(find.byType(ComposerSendButton), findsNothing); + }); + + testWidgets('Enter on an empty field neither sends nor is swallowed', ( + tester, + ) async { + var sends = 0; + final controller = await _pump(tester, onSend: () => sends++); + await _type(tester, ' '); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + + expect(sends, 0); + // A send this surface cannot make falls back to the newline rather than + // to nothing: a field that swallowed the key would look frozen. + expect(controller.text, ' \n'); + }); + + testWidgets('the field carries no send action for a soft keyboard', ( + tester, + ) async { + // The whole point of dropping `TextInputAction.send`: on a phone Enter + // must insert a newline and the send key must be the only commit. With + // maxLines null and no explicit action, the platform gives us newline. + await _pump(tester, onSend: () {}); + final field = tester.widget( + find.descendant(of: _field, matching: find.byType(TextField)), + ); + expect(field.textInputAction, isNull); + expect(field.maxLines, isNull); + }); + }); + + group('the send key', () { + testWidgets('is dead while the field holds only whitespace', ( + tester, + ) async { + await _pump(tester, onSend: () {}); + await _type(tester, ' '); + + expect(tester.widget(_sendKey).onTap, isNull); + + await _type(tester, ' x'); + + expect(tester.widget(_sendKey).onTap, isNotNull); + }); + + testWidgets('opens dead on an empty host controller', (tester) async { + await _pump(tester, onSend: () {}); + expect(tester.widget(_sendKey).onTap, isNull); + }); + + testWidgets('opens live on a controller the host seeded', (tester) async { + await _pump(tester, onSend: () {}, text: 'carried over'); + expect(tester.widget(_sendKey).onTap, isNotNull); + }); + + testWidgets('a tap commits without touching the words', (tester) async { + var sends = 0; + final controller = await _pump(tester, onSend: () => sends++); + await _type(tester, 'also update the docs'); + + await tester.tap(_sendKey); + await tester.pump(); + + expect(sends, 1); + expect(controller.text, 'also update the docs'); + }); + }); + + group('the box', () { + testWidgets('opens at three lines and stops growing at the cap', ( + tester, + ) async { + final controller = await _pump(tester, onSend: () {}); + expect(tester.widget(_field).minLines, 3); + + controller.text = [for (var i = 0; i < 40; i++) 'line $i'].join('\n'); + await tester.pumpAndSettle(); + + // A long instruction scrolls inside the box rather than pushing the + // backlog list — or the arm sheet's own commit — off screen. + expect(tester.getSize(_field).height, lessThanOrEqualTo(176.0)); + }); + + testWidgets('carries the judge that will read what is typed', ( + tester, + ) async { + await _pump(tester, onSend: () {}); + expect(find.byType(HandlerJudgeChip), findsOneWidget); + }); + }); +} diff --git a/app/test/widgets/handler/handler_judge_chip_test.dart b/app/test/widgets/handler/handler_judge_chip_test.dart new file mode 100644 index 00000000..4e43ce80 --- /dev/null +++ b/app/test/widgets/handler/handler_judge_chip_test.dart @@ -0,0 +1,436 @@ +// The compound judge+model chip and the two-pane panel behind it. What matters +// here is what a screenshot cannot show: which half of the label survives under +// width pressure, that a judge pick takes the model with it, and that the model +// pane is reachable — and escapable — without losing the panel. +import 'dart:io'; + +import 'package:antgrid/design/theme_presets.dart'; +import 'package:antgrid/design/widgets/ab_text_field.dart'; +import 'package:antgrid/models/agent_descriptor.dart'; +import 'package:antgrid/models/agent_event.dart'; +import 'package:antgrid/models/capability_catalog.dart'; +import 'package:antgrid/providers/agent_catalog.dart'; +import 'package:antgrid/providers/capability_catalog.dart'; +import 'package:antgrid/services/capability_catalog_cache.dart'; +import 'package:antgrid/widgets/handler/handler_judge_chip.dart'; +import 'package:antgrid/widgets/handler/handler_session_settings.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/prefs_test_mock.dart'; + +/// The bridge is authoritative for what an agent is called and what it can do, +/// so the picker has nothing to list until an advert has landed. +class _SeededCatalog extends AgentCatalogNotifier { + _SeededCatalog(this.seed); + + final Map seed; + + @override + Map build() => seed; +} + +AgentDescriptor _descriptor(String tool, {bool judgeCapable = true}) => + AgentDescriptor( + tool: tool, + label: tool[0].toUpperCase() + tool.substring(1), + chatCapable: true, + judgeCapable: judgeCapable, + handlerTerminal: true, + handlerChat: true, + ); + +/// `claude` is the tool with a catalog; `codex` deliberately has none, which is +/// the free-text branch a machine that has only run it in a terminal gets. +const _claudeModels = CapabilityCatalog( + models: [ + AgentCapabilityModel(id: 'opus-4', name: 'Opus'), + AgentCapabilityModel(id: 'sonnet-4', name: 'Sonnet'), + AgentCapabilityModel(id: 'haiku-4', name: 'Haiku'), + ], +); + +/// The disk cache is rooted in a temp dir rather than left at its default: +/// `remember` writes fire-and-forget, and an unresolvable app-support directory +/// would surface as an unhandled async error rather than a swallowed one. +ProviderContainer _container({ + Map? catalog, + bool seedModels = true, + Directory? diskRoot, +}) { + useInMemoryPrefs(); + final root = diskRoot ?? Directory.systemTemp.createTempSync('ab_judge_chip'); + addTearDown(() { + if (root.existsSync()) root.deleteSync(recursive: true); + }); + final container = ProviderContainer( + overrides: [ + capabilityCatalogCacheProvider.overrideWithValue( + CapabilityCatalogCache.testInstance(root: root.path), + ), + agentCatalogProvider.overrideWith( + () => _SeededCatalog( + catalog ?? + {'claude': _descriptor('claude'), 'codex': _descriptor('codex')}, + ), + ), + ], + ); + addTearDown(container.dispose); + if (seedModels) { + container + .read(capabilityCatalogProvider.notifier) + .remember(capabilityCacheKey('local', 'claude'), _claudeModels); + } + return container; +} + +/// Mounts the chip in a slot of [width] — a ComposerChip is handed a slot +/// rather than asking for one, so every shed decision is made against this +/// number. Bottom-aligned because the composer's control row sits low on its +/// surface and the panel opens upward from it. +Widget _chipApp( + ProviderContainer container, { + required HandlerJudgePick judge, + required double width, + ValueChanged? onChanged, +}) => UncontrolledProviderScope( + container: container, + child: MaterialApp( + theme: ThemeData.dark().copyWith( + extensions: >[kDefaultPalette], + ), + home: Scaffold( + body: Align( + alignment: Alignment.bottomLeft, + child: SizedBox( + width: width, + child: Row( + children: [ + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: HandlerJudgeChip( + terminalId: 't1', + judge: judge, + onChanged: onChanged ?? (_) {}, + scopeNote: handlerJudgeScopeOnArm, + ), + ), + ), + ], + ), + ), + ), + ), + ), +); + +Future _pumpChip( + WidgetTester tester, { + required HandlerJudgePick judge, + double width = 320, + bool seedModels = true, + Map? catalog, + ValueChanged? onChanged, + Directory? diskRoot, +}) async { + final container = _container( + catalog: catalog, + seedModels: seedModels, + diskRoot: diskRoot, + ); + await tester.pumpWidget( + _chipApp(container, judge: judge, width: width, onChanged: onChanged), + ); + await tester.pumpAndSettle(); + return container; +} + +Future _openPanel(WidgetTester tester) async { + await tester.tap(find.byType(HandlerJudgeChip)); + await tester.pumpAndSettle(); +} + +Future _drillToModels(WidgetTester tester) async { + await _openPanel(tester); + await tester.tap(find.text('Model')); + await tester.pumpAndSettle(); +} + +void main() { + group('the chip label', () { + testWidgets('names the judge, then the model that qualifies it', ( + tester, + ) async { + await _pumpChip( + tester, + judge: (judgeTool: 'claude', judgeModel: 'opus-4'), + ); + + // The catalog's display name, not the id: the id is what goes on the + // wire, and the chip is the one place a model is read rather than sent. + expect(find.text('Claude'), findsOneWidget); + expect(find.text('Opus'), findsOneWidget); + }); + + testWidgets('falls back to the raw id for a model nobody has listed', ( + tester, + ) async { + // A model typed into the free-text branch is never in a catalog, and + // showing it back is the only confirmation the user gets that it stuck. + await _pumpChip( + tester, + judge: (judgeTool: 'codex', judgeModel: 'o4-mini'), + ); + + expect(find.text('Codex'), findsOneWidget); + expect(find.text('o4-mini'), findsOneWidget); + }); + + testWidgets('with nothing to qualify it, the judge stands alone', ( + tester, + ) async { + await _pumpChip(tester, judge: (judgeTool: 'claude', judgeModel: null)); + + expect(find.text('Claude'), findsOneWidget); + expect(find.text('Opus'), findsNothing); + }); + + testWidgets('the model half sheds and the judge name does not', ( + tester, + ) async { + // Metric-independent on purpose: the exact pixel each half drops at + // depends on the test font, but the ORDER has to hold at every width. + final container = _container(); + var sawJudgeAlone = false; + for (var w = 320.0; w >= 60; w -= 8) { + await tester.pumpWidget( + _chipApp( + container, + judge: (judgeTool: 'claude', judgeModel: 'opus-4'), + width: w, + ), + ); + await tester.pumpAndSettle(); + final judge = find.text('Claude').evaluate().isNotEmpty; + final model = find.text('Opus').evaluate().isNotEmpty; + // The whole rule as an implication: the qualifier must never outlive + // the thing it qualifies. + expect(model && !judge, isFalse, reason: 'model survived alone at $w'); + if (judge && !model) sawJudgeAlone = true; + } + // And the middle rung is real, not a straight fall from both to neither. + expect(sawJudgeAlone, isTrue); + }); + }); + + group('the judge pane', () { + testWidgets('offers Default plus every judge-capable tool', (tester) async { + await _pumpChip( + tester, + judge: (judgeTool: null, judgeModel: null), + catalog: { + 'claude': _descriptor('claude'), + 'codex': _descriptor('codex'), + // Cannot run headless, so it can never be the judge. + 'aider': _descriptor('aider', judgeCapable: false), + }, + ); + await _openPanel(tester); + + expect(find.text('JUDGED BY'), findsOneWidget); + // Twice: the Default judge row, and the model drill row's trailing value, + // which also reads Default while nothing is picked. + expect(find.text('Default'), findsNWidgets(2)); + expect(find.text('Claude'), findsOneWidget); + expect(find.text('Codex'), findsOneWidget); + expect(find.text('Aider'), findsNothing); + // Scope is stated where the pick is made — the same control means "from + // the moment it arms" on one host and "next pass" on the other. + expect(find.text(handlerJudgeScopeOnArm), findsOneWidget); + }); + + testWidgets('picking a judge clears the model and keeps the panel open', ( + tester, + ) async { + final picks = []; + await _pumpChip( + tester, + judge: (judgeTool: 'claude', judgeModel: 'opus-4'), + onChanged: picks.add, + ); + await _openPanel(tester); + await tester.tap(find.text('Codex')); + await tester.pumpAndSettle(); + + expect(picks.single.judgeTool, 'codex'); + // A model id is a name only its own CLI answers to: carried across, it + // becomes a flag the new judge rejects on every pass. + expect(picks.single.judgeModel, isNull); + // The model is the next thing worth revisiting, so the panel stays up. + expect(find.text('JUDGED BY'), findsOneWidget); + }); + }); + + group('the model pane', () { + testWidgets('is always a drill, even for a three-model agent', ( + tester, + ) async { + await _pumpChip(tester, judge: (judgeTool: 'claude', judgeModel: null)); + await _openPanel(tester); + + // Not listed inline under the judges: a panel that changes shape per + // agent is one the user relearns per agent. + expect(find.text('Sonnet'), findsNothing); + + await tester.tap(find.text('Model')); + await tester.pumpAndSettle(); + + expect(find.text('MODEL'), findsOneWidget); + expect(find.text('Opus'), findsOneWidget); + expect(find.text('Sonnet'), findsOneWidget); + expect(find.text('Haiku'), findsOneWidget); + }); + + testWidgets('Default survives a query that matches no model', ( + tester, + ) async { + await _pumpChip(tester, judge: (judgeTool: 'claude', judgeModel: null)); + await _drillToModels(tester); + + await tester.enterText(find.byType(AbTextField), 'zzzz'); + await tester.pumpAndSettle(); + + expect(find.text('Opus'), findsNothing); + expect(find.text('No matching models'), findsOneWidget); + // Pinned above the filter and outside the searched list: putting the + // model back must never require clearing a query first. + expect(find.text('Default'), findsOneWidget); + }); + + testWidgets('picking a model commits it and closes the panel', ( + tester, + ) async { + final picks = []; + await _pumpChip( + tester, + judge: (judgeTool: 'claude', judgeModel: null), + onChanged: picks.add, + ); + await _drillToModels(tester); + await tester.tap(find.text('Sonnet')); + await tester.pumpAndSettle(); + + expect(picks.single.judgeTool, 'claude'); + expect(picks.single.judgeModel, 'sonnet-4'); + expect(find.text('MODEL'), findsNothing); + }); + + testWidgets('the back row returns to the judges without dismissing', ( + tester, + ) async { + await _pumpChip(tester, judge: (judgeTool: 'claude', judgeModel: null)); + await _drillToModels(tester); + + // Labelled with the judge it returns to, not the word "back": the pane + // above is a judge list, and a model only means anything under one. + await tester.tap(find.text('Claude').last); + await tester.pumpAndSettle(); + + expect(find.text('JUDGED BY'), findsOneWidget); + }); + + testWidgets('Escape goes back a pane rather than dismissing the panel', ( + tester, + ) async { + // Escape is bound at the route and pops the whole panel; the pane's own + // Actions override is what makes the innermost handler win, so a + // mis-drill costs a pane rather than the panel and the typed query. + await _pumpChip(tester, judge: (judgeTool: 'claude', judgeModel: null)); + await _drillToModels(tester); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + expect(find.text('JUDGED BY'), findsOneWidget); + expect(find.text('MODEL'), findsNothing); + }); + + testWidgets('a tool nobody has heard list its models gets a free-text id', ( + tester, + ) async { + // The models list is written by a CHAT session of that tool, so a machine + // that has only ever run this agent in a terminal has nothing to offer. + final picks = []; + await _pumpChip( + tester, + judge: (judgeTool: 'codex', judgeModel: null), + onChanged: picks.add, + ); + await _drillToModels(tester); + + expect( + find.text( + "This machine hasn't heard Codex list its models — type an id.", + ), + findsOneWidget, + ); + + await tester.enterText(find.byType(AbTextField), 'o4-mini'); + // Committed on submit, not per keystroke: a half-typed id is one the + // judge would try to run. + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + + expect(picks.single.judgeModel, 'o4-mini'); + }); + + testWidgets('a catalog still on disk is loaded before the drill', ( + tester, + ) async { + // Hydration is an async disk read that answers empty on the build that + // starts it, so a catalog first touched at the drill would open this pane + // on the free-text branch — telling the user nobody has listed these + // models — and swap it for a list a frame later. The chip reads the key + // from its own build to settle the answer before the pane can show it. + final root = Directory.systemTemp.createTempSync('ab_judge_chip_disk'); + await tester.runAsync( + () => CapabilityCatalogCache.testInstance( + root: root.path, + ).write(capabilityCacheKey('local', 'claude'), _claudeModels), + ); + + final container = await _pumpChip( + tester, + judge: (judgeTool: 'claude', judgeModel: null), + seedModels: false, + diskRoot: root, + ); + // Real file IO progresses only outside the fake-async zone, and its + // continuation lands only on a pump — so alternate the two. Polling for + // the key rather than sleeping a fixed span is what makes this a pin: a + // chip that never started the read leaves it absent however long we wait. + final key = capabilityCacheKey('local', 'claude'); + for (var i = 0; i < 20; i++) { + if (container.read(capabilityCatalogProvider).containsKey(key)) break; + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 10)), + ); + await tester.pump(); + } + await tester.pumpAndSettle(); + await _drillToModels(tester); + + expect(find.text('Opus'), findsOneWidget); + expect( + find.text( + "This machine hasn't heard Claude list its models — type an id.", + ), + findsNothing, + ); + }); + }); +} diff --git a/app/test/widgets/handler/handler_pa_bar_test.dart b/app/test/widgets/handler/handler_pa_bar_test.dart index f7dfdd54..21ca0ae0 100644 --- a/app/test/widgets/handler/handler_pa_bar_test.dart +++ b/app/test/widgets/handler/handler_pa_bar_test.dart @@ -1,7 +1,9 @@ // The pinned PA bar. It is one status row and nothing else: the instruction -// field and the presets moved into the backlog drawer, because a second field -// with its own send button, pinned under the composer, read as a rival place to -// type with nothing on either saying who receives it. +// composer moved into the backlog drawer, because a second field with its own +// send button, pinned under the session composer, read as a rival place to type +// with nothing on either saying who receives it. +import 'package:antgrid/design/ab_colors.dart'; +import 'package:antgrid/design/widgets/ab_chip.dart'; import 'package:antgrid/design/widgets/ab_text_field.dart'; import 'package:antgrid/models/handler_state.dart'; import 'package:antgrid/providers/first_run.dart'; @@ -10,6 +12,7 @@ import 'package:antgrid/providers/sessions.dart'; import 'package:antgrid/providers/value_controller.dart'; import 'package:antgrid/storage/first_run_store.dart'; import 'package:antgrid/widgets/handler/handler_backlog_drawer.dart'; +import 'package:antgrid/widgets/handler/handler_instruction_composer.dart'; import 'package:antgrid/widgets/handler/handler_pa_bar.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; @@ -28,6 +31,8 @@ HandlerSessionState _armed({ int? pendingEscalations, String? parkKind, int? parkedUntil, + HandlerPersonality? personality, + HandlerObservability? observability, }) => HandlerSessionState( terminalId: 't1', runState: runState, @@ -38,8 +43,16 @@ HandlerSessionState _armed({ escalations: escalations, parkKind: parkKind, parkedUntil: parkedUntil, + personality: personality, + observability: observability, ); +/// The chip's colour is the whole assertion, and the harness below mounts no +/// palette extension — so read the one the bar itself resolved rather than +/// guessing which fallback is in force. +AbChip _chip(WidgetTester tester, String label) => + tester.widget(find.widgetWithText(AbChip, label)); + HandlerInstructionItem _item(String id, String text, String status) => HandlerInstructionItem(id: id, text: text, status: status, createdAt: 1); @@ -105,13 +118,68 @@ Future _pump( } void main() { + testWidgets('the bar names the posture the bridge reported', (tester) async { + // The bar is on screen for the whole time a session is armed and is the + // only place the posture is visible at all, so "no chip" would be a state + // the user has to be taught to read. + await _pump( + tester, + sessions: {'t1': _armed(personality: HandlerPersonality.watchdog)}, + ); + final p = tester.element(find.byType(HandlerPaBar)).antgrid; + expect(_chip(tester, 'WATCHDOG').color, p.textMuted); + }); + + testWidgets('an unreported posture is a dash, never the default by name', ( + tester, + ) async { + // This chip reads as a live fact about the session. A bridge too old to + // carry the field is not a bridge running watchdog, and naming the preset + // here would advertise a control over nothing. + await _pump(tester, sessions: {'t1': _armed()}); + expect(find.text('WATCHDOG'), findsNothing); + final p = tester.element(find.byType(HandlerPaBar)).antgrid; + expect(_chip(tester, '—').color, p.textMuted); + }); + + testWidgets('the posture chip is tinted where nothing is being judged', ( + tester, + ) async { + // Escalate-only means no decide pass runs at all, so a bar naming a + // posture in ordinary chrome would say the opposite of what is happening. + await _pump( + tester, + sessions: { + 't1': _armed( + personality: HandlerPersonality.closer, + observability: HandlerObservability.escalateOnly, + ), + }, + ); + final p = tester.element(find.byType(HandlerPaBar)).antgrid; + expect(_chip(tester, 'CLOSER').color, p.warning); + }); + + testWidgets('tapping the posture chip does not open the backlog', ( + tester, + ) async { + String? opened; + await _pump( + tester, + sessions: {'t1': _armed(personality: HandlerPersonality.watchdog)}, + opener: (terminalId) => opened = terminalId, + ); + await tester.tap(find.text('WATCHDOG')); + await tester.pump(); + expect(opened, isNull); + }); + testWidgets('the bar offers no place to type of its own', (tester) async { - // The whole point of the collapse: the composer above it is the one field. + // The whole point of the collapse: the session composer above it is the + // one field, and Handler's own box lives a tap away in the drawer. await _pump(tester, sessions: {'t1': _armed()}); expect(find.byType(AbTextField), findsNothing); - for (final preset in handlerPresetInstructions) { - expect(find.text(preset), findsNothing); - } + expect(find.byType(HandlerInstructionComposer), findsNothing); expect(find.text(handlerDisclaimerText), findsNothing); }); @@ -159,14 +227,14 @@ void main() { // The bar and the drawer are separate files wired only by this default, so // without this the row can be inert in the app while every other test here // passes against an injected opener. It matters more since the collapse: - // this row is now the ONLY way to reach the instruction field. + // this row is now the ONLY way to reach the instruction composer. await _pump(tester, sessions: {'t1': _armed()}); await tester.tap(find.text('Nothing queued')); await tester.pumpAndSettle(); expect(find.byType(HandlerBacklogDrawer), findsOneWidget); - expect(find.byType(AbTextField), findsOneWidget); + expect(find.byType(HandlerInstructionComposer), findsOneWidget); }); testWidgets('a live park deadline runs a clock that stops on dispose', ( diff --git a/app/test/widgets/handler/handler_screen_test.dart b/app/test/widgets/handler/handler_screen_test.dart index bca38c4b..73cad933 100644 --- a/app/test/widgets/handler/handler_screen_test.dart +++ b/app/test/widgets/handler/handler_screen_test.dart @@ -7,6 +7,8 @@ import 'package:antgrid/project/project_session.dart'; import 'package:antgrid/project/project_session_registry.dart'; import 'package:antgrid/providers/agent_transport.dart'; import 'package:antgrid/providers/providers.dart'; +import 'package:antgrid/providers/sessions.dart'; +import 'package:antgrid/providers/value_controller.dart'; import 'package:antgrid/services/handler_service.dart'; import 'package:antgrid/storage/cached_sessions_store.dart'; import 'package:antgrid/test_helpers/fake_agent_transport.dart'; @@ -146,6 +148,9 @@ Future pumpLiveHandlerScreen(WidgetTester tester) async { projectSessionProvider.overrideWith((ref, id) async => projectSession), handlerServiceProvider.overrideWithValue(service), handlerStateProvider.overrideWith((ref) => service.stateStream), + // The screen narrows to the focused session, so without this every row + // the transport delivers for 't1' is filtered off the screen. + activeSessionIdProvider.overrideWith(() => ValueController('t1')), ], ); addTearDown(container.dispose); @@ -167,6 +172,7 @@ Future pumpHandlerScreen(WidgetTester tester, HandlerState state) async { ProviderScope( overrides: [ handlerStateProvider.overrideWith((ref) => Stream.value(state)), + activeSessionIdProvider.overrideWith(() => ValueController('t1')), ], child: const MaterialApp(home: Scaffold(body: HandlerScreen())), ), @@ -233,6 +239,7 @@ void main() { ), ), ), + activeSessionIdProvider.overrideWith(() => ValueController('t1')), ], child: const MaterialApp(home: Scaffold(body: HandlerScreen())), ), @@ -1236,19 +1243,18 @@ void main() { tester, stateWith( sessions: { - for (final id in ['t1', 't2']) - id: HandlerSessionState( - terminalId: id, - // The longest run-state word. It and the Armed chip are the - // status row's two fixed ends, so this is the widest that row is - // ever asked to be. - runState: HandlerRunState.needsYou, - pendingEscalations: 1, - armedAt: 1, - goal: 'ship it', - backlog: const [], - escalations: const [], - ), + 't1': HandlerSessionState( + terminalId: 't1', + // The longest run-state word. It and the Armed chip are the status + // row's two fixed ends, so this is the widest that row is ever + // asked to be. + runState: HandlerRunState.needsYou, + pendingEscalations: 1, + armedAt: 1, + goal: 'ship it', + backlog: const [], + escalations: const [], + ), }, ), ); @@ -1459,6 +1465,7 @@ void main() { ProviderScope( overrides: [ handlerStateProvider.overrideWith((ref) => states.stream), + activeSessionIdProvider.overrideWith(() => ValueController('t1')), ], child: const MaterialApp(home: Scaffold(body: HandlerScreen())), ), @@ -1498,14 +1505,9 @@ void main() { testWidgets('says nothing about undo when nothing is undoable', ( tester, ) async { - // Including offers that belong to ANOTHER session: the count is scoped - // to the terminal the report names. await pumpHandlerScreen( tester, - const HandlerState.initial().copyWith( - wrapUps: [wrapUp(terminalId: 't2')], - snapshots: [snapshot('s1')], - ), + const HandlerState.initial().copyWith(wrapUps: [wrapUp()]), ); expect(find.textContaining('can still be undone'), findsNothing); debugDefaultTargetPlatformOverride = null; @@ -1519,7 +1521,7 @@ void main() { snapshots: [snapshot('s1')], ), ); - final sessions = tester.getTopLeft(find.text('SESSIONS')).dy; + final sessions = tester.getTopLeft(find.text('SESSION')).dy; final wrapUps = tester.getTopLeft(find.text('WRAP-UP')).dy; final undo = tester.getTopLeft(find.text('UNDO')).dy; expect(sessions, lessThan(wrapUps)); @@ -1555,4 +1557,119 @@ void main() { debugDefaultTargetPlatformOverride = null; }); }); + + group('the tab follows focus', () { + HandlerActivityRecord rec(String terminalId, String reason) => + HandlerActivityRecord( + recordId: 'r-$terminalId', + at: 1, + terminalId: terminalId, + decision: 'handle', + reason: reason, + ); + + testWidgets('shows one session at a time, and swaps on a focus change', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + final container = ProviderContainer( + overrides: [ + activeSessionIdProvider.overrideWith(() => ValueController('t1')), + handlerStateProvider.overrideWith( + (ref) => Stream.value( + stateWith( + sessions: { + 't1': sessionState('t1'), + 't2': sessionState('t2'), + }, + ).copyWith( + activity: [ + rec('t1', 'answered the lint prompt'), + rec('t2', 'answered the migration prompt'), + ], + ), + ), + ), + ], + ); + addTearDown(container.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const MaterialApp(home: Scaffold(body: HandlerScreen())), + ), + ); + await tester.pump(); + + expect(find.textContaining('answered the lint prompt'), findsOneWidget); + expect(find.textContaining('answered the migration prompt'), findsNothing); + + container.read(activeSessionIdProvider.notifier).set('t2'); + await tester.pump(); + + expect(find.textContaining('answered the lint prompt'), findsNothing); + expect( + find.textContaining('answered the migration prompt'), + findsOneWidget, + ); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('an escalation with no session behind it yet still renders', ( + tester, + ) async { + // The two arrive on separate streams, so a pushed escalation can land + // before the status frame that adds its session. Answering that with the + // arm copy hides a question the app has already raised a toast for. + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + await pumpHandlerScreen( + tester, + const HandlerState.initial().copyWith( + escalations: [ + HandlerEscalation( + escalationId: 'e1', + terminalId: 't1', + question: 'may I force push?', + reasoning: 'r', + draftReply: 'd', + urgency: 'normal', + at: 1, + ), + ], + ), + ); + expect(find.textContaining('Handler is off'), findsNothing); + expect(find.text('may I force push?'), findsOneWidget); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('with nothing focused it offers the arm copy, not every ' + 'session at once', (tester) async { + // Never the project's whole state: falling back to it mid-switch would + // put two sessions' rows on a tab that says it shows one. + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + await tester.pumpWidget( + ProviderScope( + overrides: [ + activeSessionIdProvider.overrideWith( + () => ValueController(null), + ), + handlerStateProvider.overrideWith( + (ref) => Stream.value( + stateWith(sessions: {'t1': sessionState('t1')}).copyWith( + activity: [rec('t1', 'answered the lint prompt')], + ), + ), + ), + ], + child: const MaterialApp(home: Scaffold(body: HandlerScreen())), + ), + ); + await tester.pump(); + + expect(find.textContaining('Handler is off'), findsOneWidget); + expect(find.textContaining('answered the lint prompt'), findsNothing); + debugDefaultTargetPlatformOverride = null; + }); + }); } diff --git a/app/test/widgets/handler/handler_session_settings_test.dart b/app/test/widgets/handler/handler_session_settings_test.dart new file mode 100644 index 00000000..53161509 --- /dev/null +++ b/app/test/widgets/handler/handler_session_settings_test.dart @@ -0,0 +1,342 @@ +// The two per-session Handler choices and the sheet they share. The rules that +// matter here are the ones a screenshot cannot show: what a change SENDS, and +// what the sheet claims while nothing is actually judging. +import 'package:antgrid/design/theme_presets.dart'; +import 'package:antgrid/design/widgets/ab_segmented.dart'; +import 'package:antgrid/models/agent_descriptor.dart'; +import 'package:antgrid/models/handler_state.dart'; +import 'package:antgrid/providers/agent_catalog.dart'; +import 'package:antgrid/widgets/handler/handler_session_settings.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/prefs_test_mock.dart'; + +class _SeededCatalog extends AgentCatalogNotifier { + _SeededCatalog(this.seed); + + final Map seed; + + @override + Map build() => seed; +} + +AgentDescriptor _descriptor(String tool, {required bool judgeCapable}) => + AgentDescriptor( + tool: tool, + label: tool[0].toUpperCase() + tool.substring(1), + chatCapable: true, + judgeCapable: judgeCapable, + handlerTerminal: true, + handlerChat: true, + ); + +HandlerSessionSettingsValue _value({ + String? judgeTool, + String? judgeModel, + HandlerPersonality personality = HandlerPersonality.watchdog, +}) => ( + judgeTool: judgeTool, + judgeModel: judgeModel, + personality: personality, +); + +Future _pump( + WidgetTester tester, { + required HandlerSessionSettingsValue value, + Map catalog = const {'claude': true}, + ValueChanged? onChanged, + bool appliesNextPass = false, +}) async { + useInMemoryPrefs(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + agentCatalogProvider.overrideWith( + () => _SeededCatalog({ + for (final e in catalog.entries) + e.key: _descriptor(e.key, judgeCapable: e.value), + }), + ), + ], + child: MaterialApp( + theme: ThemeData.dark().copyWith( + extensions: >[kDefaultPalette], + ), + home: Scaffold( + body: HandlerSessionSettings( + terminalId: 't1', + value: value, + onChanged: onChanged ?? (_) {}, + appliesNextPass: appliesNextPass, + ), + ), + ), + ), + ); + await tester.pump(); +} + +/// Mounts ONE half of the settings block. The arm sheet takes the posture half +/// alone, because its composer's chip is already the judge picker there. +Future _pumpHalf( + WidgetTester tester, + Widget half, { + Map catalog = const {'claude': true}, +}) async { + useInMemoryPrefs(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + agentCatalogProvider.overrideWith( + () => _SeededCatalog({ + for (final e in catalog.entries) + e.key: _descriptor(e.key, judgeCapable: e.value), + }), + ), + ], + child: MaterialApp( + theme: ThemeData.dark().copyWith( + extensions: >[kDefaultPalette], + ), + home: Scaffold(body: half), + ), + ), + ); + await tester.pump(); +} + +// Nullable type argument: `selected` has to be able to match no cell at all, +// which is how the control says the far end has never reported a posture. +AbSegmented _segmented(WidgetTester tester) => + tester.widget>( + find.byType(AbSegmented), + ); + +void main() { + group('handlerSessionSettingsEdit', () { + test('an untouched sheet sends nothing at all', () { + final v = _value(judgeTool: 'codex', judgeModel: 'o3'); + final edit = handlerSessionSettingsEdit(v, v); + expect(edit.judgeTool, isNull); + expect(edit.judgeModel, isNull); + expect(edit.personality, isNull); + }); + + test('only the moved field is sent', () { + final edit = handlerSessionSettingsEdit( + _value(judgeTool: 'codex', judgeModel: 'o3'), + _value( + judgeTool: 'codex', + judgeModel: 'o3', + personality: HandlerPersonality.autopilot, + ), + ); + expect(edit.personality, HandlerPersonality.autopilot); + // The judge is untouched, so the wire must not carry it — a cold cache + // would otherwise clear a pick the bridge holds and this app has never + // been told about. + expect(edit.judgeTool, isNull); + expect(edit.judgeModel, isNull); + }); + + test('clearing a judge field goes out as the empty string, not null', () { + // Null means "leave it alone" on the wire, so the clear needs its own + // spelling or it is indistinguishable from no change. + final edit = handlerSessionSettingsEdit( + _value(judgeTool: 'codex', judgeModel: 'o3'), + _value(), + ); + expect(edit.judgeTool, ''); + expect(edit.judgeModel, ''); + }); + + test('a judge picked for the first time is sent by name', () { + final edit = handlerSessionSettingsEdit( + _value(), + _value(judgeTool: 'claude'), + ); + expect(edit.judgeTool, 'claude'); + // Cleared, not omitted: this app's `from` reads null whenever its cache + // is cold, and the bridge may well be holding the PREVIOUS CLI's model. + // Omitting the key would leave that id in force under the new judge. + expect(edit.judgeModel, ''); + }); + + test('a tool change always carries the model, cold cache included', () { + final edit = handlerSessionSettingsEdit( + _value(judgeTool: 'codex'), + _value(judgeTool: 'claude'), + ); + expect(edit.judgeTool, 'claude'); + expect(edit.judgeModel, ''); + }); + + test('a model-only edit leaves the tool alone', () { + final edit = handlerSessionSettingsEdit( + _value(judgeTool: 'codex', judgeModel: 'o3'), + _value(judgeTool: 'codex', judgeModel: 'o4-mini'), + ); + expect(edit.judgeTool, isNull); + expect(edit.judgeModel, 'o4-mini'); + }); + + }); + + group('handlerSessionSettingsFor', () { + test('a session with nothing stored reports no posture, never a guess', () { + // A bridge that has never named one is not a bridge running watchdog: + // seeding the default here would put a preset on screen as a live fact + // and then send nothing when the user "changed" it to what was shown. + final seed = handlerSessionSettingsFor(null, 't1'); + expect(seed.personality, isNull); + expect(seed.judgeTool, isNull); + expect(seed.judgeModel, isNull); + }); + }); + + group('handlerJudgeParkedNotice', () { + test('names the judge that cannot run and the fix, not just the fault', () { + final notice = handlerJudgeParkedNotice('Codex'); + expect(notice, contains('Codex')); + expect(notice, contains('Pick one that can')); + }); + + test('falls back to a nameless judge rather than a blank', () { + expect(handlerJudgeParkedNotice(null), startsWith('This judge')); + }); + }); + + group('the sheet', () { + testWidgets('leads with the posture and follows with the judge', ( + tester, + ) async { + await _pump(tester, value: _value(judgeTool: 'claude')); + final posture = tester.getTopLeft(find.text('HOW MUCH IT HANDLES')).dy; + final judge = tester.getTopLeft(find.text('JUDGED BY')).dy; + final model = tester.getTopLeft(find.text('MODEL')).dy; + expect(posture, lessThan(judge)); + expect(judge, lessThan(model)); + }); + + testWidgets('a healthy judge leaves the posture live and unexplained', ( + tester, + ) async { + await _pump(tester, value: _value(judgeTool: 'claude')); + expect(_segmented(tester).inactive, isFalse); + expect( + find.text(handlerPersonalityBlurb(HandlerPersonality.watchdog)), + findsOneWidget, + ); + expect(find.text(handlerPostureParkedBlurb), findsNothing); + }); + + testWidgets('a judge that cannot run headless parks the posture', ( + tester, + ) async { + await _pump( + tester, + value: _value( + judgeTool: 'codex', + personality: HandlerPersonality.autopilot, + ), + catalog: const {'claude': true, 'codex': false}, + ); + // Still the user's choice to make — it starts working the moment the + // judge is fixed — but it must not be painted as running. + expect(_segmented(tester).inactive, isTrue); + expect(find.text(handlerPostureParkedBlurb), findsOneWidget); + expect(find.textContaining(handlerJudgeParkedNotice('Codex')), findsOne); + }); + + testWidgets('the parked line outranks the next-pass line', (tester) async { + // Both are true post-arm, and "takes effect next pass" implies it takes + // effect at all, which is the one thing a parked posture does not do. + await _pump( + tester, + value: _value(judgeTool: 'codex'), + catalog: const {'codex': false}, + appliesNextPass: true, + ); + expect(find.text(handlerPostureParkedBlurb), findsOneWidget); + expect(find.textContaining('Takes effect on the next pass.'), findsNothing); + }); + + testWidgets('post-arm, a live posture says when it lands', (tester) async { + await _pump( + tester, + value: _value(judgeTool: 'claude'), + appliesNextPass: true, + ); + expect( + find.textContaining('Takes effect on the next pass.'), + findsOneWidget, + ); + }); + + testWidgets('the posture half carries no judge picker of its own', ( + tester, + ) async { + // On the arm sheet the composer's chip IS the judge picker, so mounting + // these rows too would be two controls for one value. + await _pumpHalf( + tester, + HandlerPostureControl( + terminalId: 't1', + value: _value(judgeTool: 'codex'), + onChanged: (_) {}, + ), + catalog: const {'codex': false}, + ); + + expect(find.text('HOW MUCH IT HANDLES'), findsOneWidget); + // The parked blurb REPLACES the personality one — a posture that is + // stored and inert must not also describe what it would be doing. + expect(find.text(handlerPostureParkedBlurb), findsOneWidget); + // The parked notice stays with the posture it parks: its copy never says + // "below", so on the arm sheet it points up at the chip and still reads + // true. + expect(find.textContaining(handlerJudgeParkedNotice('Codex')), findsOne); + expect(find.text('JUDGED BY'), findsNothing); + expect(find.text('MODEL'), findsNothing); + }); + + testWidgets('the judge half carries both picker rows and no posture', ( + tester, + ) async { + await _pumpHalf( + tester, + HandlerJudgeControl( + terminalId: 't1', + value: _value(judgeTool: 'claude'), + onChanged: (_) {}, + ), + ); + + expect(find.text('JUDGED BY'), findsOneWidget); + expect(find.text('MODEL'), findsOneWidget); + expect(find.text('HOW MUCH IT HANDLES'), findsNothing); + expect(find.byType(AbSegmented), findsNothing); + }); + + testWidgets('picking a judge clears the model with it', (tester) async { + HandlerSessionSettingsValue? sent; + await _pump( + tester, + value: _value(judgeTool: 'claude', judgeModel: 'sonnet-x'), + catalog: const {'claude': true, 'codex': true}, + onChanged: (v) => sent = v, + ); + await tester.tap(find.text('Claude')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Codex').last); + await tester.pumpAndSettle(); + + expect(sent?.judgeTool, 'codex'); + // A model id is a name only its own CLI answers to: carried across, it + // becomes a flag the new judge rejects on every pass. + expect(sent?.judgeModel, isNull); + }); + }); +} diff --git a/app/test/widgets/handler_arm_onboarding_test.dart b/app/test/widgets/handler_arm_onboarding_test.dart index 7a368edf..ab071466 100644 --- a/app/test/widgets/handler_arm_onboarding_test.dart +++ b/app/test/widgets/handler_arm_onboarding_test.dart @@ -2,7 +2,10 @@ import 'dart:async'; import 'package:antgrid/design/theme_presets.dart'; import 'package:antgrid/design/widgets/ab_button.dart'; +import 'package:antgrid/models/agent_descriptor.dart'; import 'package:antgrid/models/handler_state.dart'; +import 'package:antgrid/navigation/root_navigator.dart'; +import 'package:antgrid/providers/agent_catalog.dart'; import 'package:antgrid/project/project_session.dart'; import 'package:antgrid/project/project_session_registry.dart'; import 'package:antgrid/providers/agent_transport.dart'; @@ -19,13 +22,35 @@ import 'package:antgrid/widgets/agent_panel.dart'; import 'package:antgrid/widgets/handler/handler_arm_explainer.dart'; import 'package:antgrid/widgets/handler/handler_away_hint.dart'; import 'package:antgrid/widgets/handler/handler_item_status.dart'; +import 'package:antgrid/widgets/handler/handler_judge_chip.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/misc.dart'; import 'package:flutter_test/flutter_test.dart'; import '../helpers/prefs_test_mock.dart'; +/// The bridge is authoritative for what an agent is called and what it can do, +/// so a judge picker has nothing to list until an advert has landed. +class _SeededCatalog extends AgentCatalogNotifier { + _SeededCatalog(this.seed); + + final Map seed; + + @override + Map build() => seed; +} + +AgentDescriptor _descriptor(String tool) => AgentDescriptor( + tool: tool, + label: tool[0].toUpperCase() + tool.substring(1), + chatCapable: true, + judgeCapable: true, + handlerTerminal: true, + handlerChat: true, +); + Widget _wrap(Widget child, {required List overrides}) { return ProviderScope( overrides: overrides, @@ -72,8 +97,8 @@ void main() { test('a seeded goal is named, and only when one exists', () { const seeded = - 'It starts from what you asked for when you opened this session, ' - 'and queues that as your backlog.'; + 'Handler starts from what you asked for when you opened this ' + 'session, and queues that as your backlog.'; expect( handlerArmExplainerBody(agentObservable: true), isNot(contains(seeded)), @@ -141,6 +166,204 @@ void main() { ); expect(body, endsWith(unwatchableNotice('Claude Code'))); }); + + group('past the first arm', () { + test('a covered agent with nothing to add says nothing at all', () { + // The sheet opens on every arm, so the standing explanation would + // otherwise be re-read by a user who has armed a hundred sessions. + expect( + handlerArmExplainerBody(agentObservable: true, explain: false), + isNull, + ); + }); + + test('the standing explanation is the only thing dropped', () { + // Coverage is per-agent and the goal is per-session: neither is retired + // by having read the explanation once. + expect( + handlerArmExplainerBody( + agentObservable: false, + agentLabel: 'Claude Code', + explain: false, + ), + unwatchableNotice('Claude Code'), + ); + expect( + handlerArmExplainerBody( + agentObservable: true, + judgeCapable: false, + explain: false, + ), + escalateOnlyNotice, + ); + expect( + handlerArmExplainerBody(agentObservable: null, explain: false), + "This agent hasn't reported what Handler can see here, so it may " + 'stay silent.', + ); + }); + + test('a seeded goal names Handler, having lost its antecedent', () { + expect( + handlerArmExplainerBody( + agentObservable: true, + hasOpeningPrompt: true, + explain: false, + ), + 'Handler starts from what you asked for when you opened this ' + 'session, and queues that as your backlog.', + ); + }); + + test('an unwatchable agent still withholds the goal', () { + expect( + handlerArmExplainerBody( + agentObservable: false, + agentLabel: 'Claude Code', + hasOpeningPrompt: true, + explain: false, + ), + unwatchableNotice('Claude Code'), + ); + }); + }); + }); + + group('handlerShieldTooltip', () { + // The explainer's copy matrix has its own group above. This is the surface + // that answers every time, and the two must agree about precedence. + test('an armed session offers only the way out', () { + expect( + handlerShieldTooltip(armed: true, observable: false, judgeCapable: false), + 'Disarm Handler', + ); + }); + + test('an escalate-only agent is named before the arm, not after', () { + expect( + handlerShieldTooltip( + armed: false, + observable: true, + judgeCapable: false, + ), + escalateOnlyNotice, + ); + }); + + test('unwatchable outranks escalate-only', () { + // Both true of the same agent says one thing: it reports nothing. What + // its judge could have done never comes up. + expect( + handlerShieldTooltip( + armed: false, + observable: false, + judgeCapable: false, + agentLabel: 'Claude Code', + ), + unwatchableNotice('Claude Code'), + ); + }); + + test('a fully covered agent gets the plain label', () { + expect( + handlerShieldTooltip( + armed: false, + observable: true, + judgeCapable: true, + ), + 'Arm Handler', + ); + }); + + test('an undescribed agent claims neither fault', () { + expect( + handlerShieldTooltip( + armed: false, + observable: null, + judgeCapable: null, + ), + 'Arm Handler', + ); + }); + + test('a refused machine outranks every coverage answer', () { + // Coverage describes what an arm WOULD get, and there is no arm to get + // it — so a fully covered agent on a refused machine still says why. + expect( + handlerShieldTooltip( + armed: false, + observable: true, + judgeCapable: true, + entitlement: const HandlerEntitlement( + reason: HandlerEntitlementReason.notEntitled, + tier: 'free', + ), + ), + contains('Free plan'), + ); + expect( + handlerShieldTooltip( + armed: false, + observable: false, + judgeCapable: false, + agentLabel: 'Claude Code', + entitlement: const HandlerEntitlement( + reason: HandlerEntitlementReason.unreadable, + ), + ), + handlerEntitlementNotice( + const HandlerEntitlement(reason: HandlerEntitlementReason.unreadable), + ), + ); + }); + + test("a session armed before the refusal is still the user's to disarm", () { + expect( + handlerShieldTooltip( + armed: true, + observable: true, + judgeCapable: true, + entitlement: const HandlerEntitlement( + reason: HandlerEntitlementReason.notEntitled, + tier: 'free', + ), + ), + 'Disarm Handler', + ); + }); + }); + + group('handlerEntitlementNotice', () { + test('names the plan the machine is on when the bridge could read one', () { + // "You need Pro" alone leaves a paying user unable to tell whether they + // already have it. + expect( + handlerEntitlementNotice( + const HandlerEntitlement( + reason: HandlerEntitlementReason.notEntitled, + tier: 'free', + ), + ), + contains('Free plan'), + ); + }); + + test('an unreadable claim is sent to sign-in, never to checkout', () { + final notice = handlerEntitlementNotice( + const HandlerEntitlement(reason: HandlerEntitlementReason.unreadable), + ); + expect(notice, contains('Sign out and back in')); + expect(notice, isNot(contains('Pro'))); + }); + + test('a reason this app cannot name still says arming will not work', () { + // Silence is the failure being fixed, so an unknown reason falls back to + // unavailability rather than to nothing. + expect( + handlerEntitlementNotice(const HandlerEntitlement()), + contains("isn't available"), + ); + }); }); group('handlerShieldTooltip', () { @@ -244,14 +467,15 @@ void main() { expect(store.read().handlerAwayHintDismissed, isTrue); }); - group('armWithFirstRunExplainer carries the opening prompt', () { + group('armWithSheet carries the opening prompt', () { /// A REAL [ProjectSession] over a fake transport, focused: the goal is only /// proven seeded if the arm the flow sends carries it on the wire, and the /// flow resolves its service off the focused project rather than off /// anything the caller hands it. Future<(FakeAgentTransport, ProviderContainer, BuildContext)> pumpArm( WidgetTester tester, { - required bool armedOnce, + bool armedOnce = false, + List extraOverrides = const [], }) async { useInMemoryPrefs(); final store = await FirstRunStore.open(); @@ -273,6 +497,7 @@ void main() { firstRunStoreProvider.overrideWithValue(store), selectedRegistrationIdProvider.overrideWithValue('p'), projectSessionProvider('p').overrideWith((ref) => projectSession), + ...extraOverrides, ], ); addTearDown(container.dispose); @@ -281,6 +506,10 @@ void main() { UncontrolledProviderScope( container: container, child: MaterialApp( + // The flow's own failure reports go to the ROOT navigator's + // overlay, since every widget that could have shown one is gone by + // then — so the key has to be the one the provider hands out. + navigatorKey: container.read(rootNavigatorKeyProvider), theme: ThemeData.dark().copyWith( extensions: >[kDefaultPalette], ), @@ -298,9 +527,36 @@ void main() { ); } + /// A toast dismisses itself on a timer, and a timer outliving the tree + /// fails the test — so every assertion on one has to let it finish. + Future settleToast(WidgetTester tester) async { + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + } + Map armFrame(FakeAgentTransport transport) => transport.sent.firstWhere((m) => m['type'] == 'handler:configure'); + /// The whole flow as a user performs it: the sheet is not skippable, so + /// every arm here goes through it and commits on its own button. + Future armThroughSheet( + WidgetTester tester, + ProviderContainer container, + BuildContext context, + ) async { + unawaited( + armWithSheet( + context: context, + container: container, + terminalId: 't1', + agentObservable: true, + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(AbButton, 'Arm Handler')); + await tester.pumpAndSettle(); + } + /// The bridge answering that the terminal is armed, which is what the flow /// waits on before retiring anything. Without it the confirmation window /// stays open and its timer outlives the test. @@ -325,20 +581,12 @@ void main() { } testWidgets('a remembered prompt arms as the session goal', (tester) async { - final (transport, container, context) = await pumpArm( - tester, - armedOnce: true, - ); + final (transport, container, context) = await pumpArm(tester); container .read(sessionOpeningPromptsProvider.notifier) .remember('t1', 'fix the flaky login test'); - await armWithFirstRunExplainer( - context: context, - container: container, - terminalId: 't1', - agentObservable: true, - ); + await armThroughSheet(tester, container, context); final sent = armFrame(transport); expect(sent['armed'], true); @@ -351,70 +599,271 @@ void main() { testWidgets('a session nothing remembers still arms with no payload', ( tester, ) async { - final (transport, container, context) = await pumpArm( - tester, - armedOnce: true, - ); + final (transport, container, context) = await pumpArm(tester); - await armWithFirstRunExplainer( - context: context, - container: container, - terminalId: 't1', - agentObservable: true, - ); + await armThroughSheet(tester, container, context); final sent = armFrame(transport); expect(sent['armed'], true); expect(sent.containsKey('goal'), isFalse); expect(sent.containsKey('backlog'), isFalse); + // The posture the sheet SHOWS is a seed, not something the bridge + // reported: a cold cache over a session the bridge holds a posture for + // is the ordinary case after a restart, so an untouched control must + // send nothing rather than reset that pick to the default. + expect(sent.containsKey('personality'), isFalse); await confirmArmed(tester, transport); }); - testWidgets('the prompt is dropped once the bridge confirms the arm, so a ' - 're-arm queues nothing twice', (tester) async { + testWidgets('the sheet opens on every arm, not just the first', ( + tester, + ) async { final (transport, container, context) = await pumpArm( tester, armedOnce: true, ); + + unawaited( + armWithSheet( + context: context, + container: container, + terminalId: 't1', + agentObservable: true, + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Arm Handler'), findsWidgets); + // …but without re-teaching what Handler is. The composer and the posture + // control are the whole sheet from here on. + expect( + find.textContaining('Handler watches this session while'), + findsNothing, + ); + // Nothing is armed until the sheet's own commit — the tap that opened it + // is not the arm. + expect( + transport.sent.where((m) => m['type'] == 'handler:configure'), + isEmpty, + ); + + await tester.tap(find.widgetWithText(AbButton, 'Arm Handler')); + await tester.pumpAndSettle(); + expect(armFrame(transport)['armed'], true); + await confirmArmed(tester, transport); + }); + + testWidgets('a posture picked on the sheet rides the arm', (tester) async { + final (transport, container, context) = await pumpArm(tester); + + unawaited( + armWithSheet( + context: context, + container: container, + terminalId: 't1', + agentObservable: true, + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('AUTOPILOT')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(AbButton, 'Arm Handler')); + await tester.pumpAndSettle(); + + expect(armFrame(transport)['personality'], 'autopilot'); + await confirmArmed(tester, transport); + }); + + testWidgets('a posture moved away from and back still rides the arm', ( + tester, + ) async { + // The seed is a display value: the bridge may hold a posture this app has + // never been told about, so landing back on what the sheet opened showing + // is a choice about it, not the absence of one. + final (transport, container, context) = await pumpArm(tester); + + unawaited( + armWithSheet( + context: context, + container: container, + terminalId: 't1', + agentObservable: true, + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('CLOSER')); + await tester.pumpAndSettle(); + await tester.tap(find.text('WATCHDOG')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(AbButton, 'Arm Handler')); + await tester.pumpAndSettle(); + + expect(armFrame(transport)['personality'], 'watchdog'); + await confirmArmed(tester, transport); + }); + + /// The bridge saying this machine will not run Handler at all. Emitted with + /// no armed sessions, which is the state a refusal always leaves behind. + Future refuse( + WidgetTester tester, + FakeAgentTransport transport, + Map entitlement, + ) async { + transport.emit('handler:status', { + 'projectId': 'p', + 'sessions': [], + 'entitlement': entitlement, + }); + await tester.pumpAndSettle(); + } + + testWidgets('a paywalled machine explains itself instead of arming', ( + tester, + ) async { + final (transport, container, context) = await pumpArm(tester); + await refuse(tester, transport, {'reason': 'not_entitled', 'tier': 'free'}); + + unawaited( + armWithSheet( + context: context, + container: container, + terminalId: 't1', + agentObservable: true, + ), + ); + await tester.pumpAndSettle(); + + // The arm sheet is a form that could not have committed, so it never + // opens: the refusal takes its place and offers the one fix it has. + expect(find.text('Handler needs Pro'), findsOneWidget); + expect(find.textContaining('Free plan'), findsOneWidget); + expect(find.widgetWithText(AbButton, 'See plans'), findsOneWidget); + expect(find.widgetWithText(AbButton, 'Arm Handler'), findsNothing); + + await tester.tap(find.widgetWithText(AbButton, 'Not now')); + await tester.pumpAndSettle(); + expect( + transport.sent.where((m) => m['type'] == 'handler:configure'), + isEmpty, + ); + }); + + testWidgets('an unreadable claim is never sold an upgrade', (tester) async { + final (transport, container, context) = await pumpArm(tester); + await refuse(tester, transport, {'reason': 'unreadable'}); + + unawaited( + armWithSheet( + context: context, + container: container, + terminalId: 't1', + agentObservable: true, + ), + ); + await tester.pumpAndSettle(); + + // A purchase buys nothing here, and a button offering one would teach the + // user the wrong thing about what went wrong. + expect(find.text('Handler is unavailable'), findsOneWidget); + expect(find.widgetWithText(AbButton, 'See plans'), findsNothing); + expect(find.widgetWithText(AbButton, 'Close'), findsOneWidget); + + await tester.tap(find.widgetWithText(AbButton, 'Close')); + await tester.pumpAndSettle(); + expect( + transport.sent.where((m) => m['type'] == 'handler:configure'), + isEmpty, + ); + }); + + testWidgets('a refusal the bridge stops sending stops gating the arm', ( + tester, + ) async { + // The gate is derived per frame on both ends: an app told once that + // Handler is paywalled has no other way to learn that it no longer is. + final (transport, container, context) = await pumpArm(tester); + await refuse(tester, transport, {'reason': 'not_entitled', 'tier': 'free'}); + transport.emit('handler:status', { + 'projectId': 'p', + 'sessions': [], + }); + await tester.pumpAndSettle(); + + await armThroughSheet(tester, container, context); + + expect(armFrame(transport)['armed'], true); + await confirmArmed(tester, transport); + }); + + testWidgets('a refusal arriving on the answer to an arm is spoken', ( + tester, + ) async { + // The stale-cache path: the app believed it was entitled, sent the arm, + // and the bridge answered no. Without this the send is indistinguishable + // from a tap that never registered until the confirmation window runs out + // — and, with no instruction riding on it, not even then. + final (transport, container, context) = await pumpArm(tester); + + await armThroughSheet(tester, container, context); + expect(armFrame(transport)['armed'], true); + + await refuse(tester, transport, {'reason': 'not_entitled', 'tier': 'free'}); + + expect(find.text('Handler not armed'), findsOneWidget); + expect(find.textContaining('Free plan'), findsOneWidget); + await settleToast(tester); + }); + + testWidgets('backing out of the sheet arms nothing', (tester) async { + final (transport, container, context) = await pumpArm(tester); + + unawaited( + armWithSheet( + context: context, + container: container, + terminalId: 't1', + agentObservable: true, + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(AbButton, 'Not now')); + await tester.pumpAndSettle(); + + expect( + transport.sent.where((m) => m['type'] == 'handler:configure'), + isEmpty, + ); + }); + + testWidgets('the prompt is dropped once the bridge confirms the arm, so a ' + 're-arm queues nothing twice', (tester) async { + final (transport, container, context) = await pumpArm(tester); container .read(sessionOpeningPromptsProvider.notifier) .remember('t1', 'revert the last migration'); - await armWithFirstRunExplainer( - context: context, - container: container, - terminalId: 't1', - agentObservable: true, - ); + await armThroughSheet(tester, container, context); await confirmArmed(tester, transport); expect(container.read(sessionOpeningPromptsProvider)['t1'], isNull); // A plain disarm leaves the bridge nothing to rehydrate, so a goal sent // again here is extracted into an empty backlog and done a second time. transport.clearSent(); - await armWithFirstRunExplainer( - context: context, - container: container, - terminalId: 't1', - agentObservable: true, - ); + await armThroughSheet(tester, container, context); expect(armFrame(transport).containsKey('goal'), isFalse); await confirmArmed(tester, transport); }); - testWidgets('the first arm tells the user the goal is being seeded', ( + testWidgets('an arm over a remembered prompt says the goal is seeded', ( tester, ) async { - final (transport, container, context) = await pumpArm( - tester, - armedOnce: false, - ); + final (transport, container, context) = await pumpArm(tester); container .read(sessionOpeningPromptsProvider.notifier) .remember('t1', 'fix the flaky login test'); unawaited( - armWithFirstRunExplainer( + armWithSheet( context: context, container: container, terminalId: 't1', @@ -425,7 +874,7 @@ void main() { expect( find.textContaining( - 'It starts from what you asked for when you opened this session', + 'Handler starts from what you asked for when you opened this session', ), findsOneWidget, ); @@ -435,16 +884,13 @@ void main() { await confirmArmed(tester, transport); }); - testWidgets('with nothing remembered the first arm promises no backlog', ( + testWidgets('with nothing remembered the sheet promises no backlog', ( tester, ) async { - final (transport, container, context) = await pumpArm( - tester, - armedOnce: false, - ); + final (transport, container, context) = await pumpArm(tester); unawaited( - armWithFirstRunExplainer( + armWithSheet( context: context, container: container, terminalId: 't1', @@ -454,14 +900,157 @@ void main() { await tester.pumpAndSettle(); expect( - find.textContaining('It starts from what you asked for'), + find.textContaining('Handler starts from what you asked for'), findsNothing, ); + // A first arm still gets the standing explanation — the other half of + // what handlerArmedOnce gates on this sheet. + expect( + find.textContaining('Handler watches this session while'), + findsOneWidget, + ); await tester.tap(find.widgetWithText(AbButton, 'Arm Handler')); await tester.pumpAndSettle(); expect(armFrame(transport).containsKey('goal'), isFalse); await confirmArmed(tester, transport); }); + + // The sharp edge of the whole feature: `handler:instruct` is DROPPED by the + // bridge when no armed session exists, and the drop is a log line no phone + // reads. So the sentence typed on this sheet cannot ride the arm, and + // cannot be smuggled in as the goal either — a goal grants nothing, and + // `instruct` is the one feed point for instruction-scoped authorization. + group('the arm sheet composer', () { + final field = find.byKey(const Key('handler-instruction-field')); + + List> instructs(FakeAgentTransport transport) => + transport.sent + .where((m) => m['type'] == 'handler:instruct') + .toList(); + + /// Opens the arm sheet and leaves it on screen. + Future openSheet( + WidgetTester tester, + ProviderContainer container, + BuildContext context, + ) async { + unawaited( + armWithSheet( + context: context, + container: container, + terminalId: 't1', + agentObservable: true, + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('typed text is not on the wire before the arm is confirmed', ( + tester, + ) async { + final (transport, container, context) = await pumpArm(tester); + await openSheet(tester, container, context); + + await tester.enterText(field, 'also update the changelog'); + await tester.tap(find.widgetWithText(AbButton, 'Arm Handler')); + await tester.pumpAndSettle(); + + // The arm went; the instruction did not go with it. + expect(armFrame(transport)['armed'], true); + expect(instructs(transport), isEmpty); + + await confirmArmed(tester, transport); + }); + + testWidgets('and lands exactly once when the bridge confirms', ( + tester, + ) async { + final (transport, container, context) = await pumpArm(tester); + await openSheet(tester, container, context); + + await tester.enterText(field, 'also update the changelog'); + await tester.tap(find.widgetWithText(AbButton, 'Arm Handler')); + await tester.pumpAndSettle(); + await confirmArmed(tester, transport); + + expect(instructs(transport), hasLength(1)); + expect(instructs(transport).single['terminalId'], 't1'); + expect(instructs(transport).single['text'], 'also update the changelog'); + }); + + testWidgets('an untouched composer sends no instruction at all', ( + tester, + ) async { + final (transport, container, context) = await pumpArm(tester); + await openSheet(tester, container, context); + + // Arming with nothing typed is the ordinary case, and an empty + // `handler:instruct` would spend an extraction pass on nothing. + await tester.tap(find.widgetWithText(AbButton, 'Arm Handler')); + await tester.pumpAndSettle(); + await confirmArmed(tester, transport); + + expect(instructs(transport), isEmpty); + }); + + testWidgets('an arm the bridge never confirms sends nothing', ( + tester, + ) async { + // A send that vanished with nothing coming back to explain it: the + // window closing is the end of it, not a late retry. The user is told, + // because the sentence they typed exists nowhere else once it shuts. + final (transport, container, context) = await pumpArm(tester); + await openSheet(tester, container, context); + + await tester.enterText(field, 'also update the changelog'); + await tester.tap(find.widgetWithText(AbButton, 'Arm Handler')); + await tester.pumpAndSettle(); + await tester.pump(kHandlerArmConfirmWindow + const Duration(seconds: 1)); + await tester.pumpAndSettle(); + + expect(instructs(transport), isEmpty); + expect(find.text('Nothing was queued'), findsOneWidget); + await settleToast(tester); + }); + + testWidgets('a judge picked here rides the arm, not a frame of its own', ( + tester, + ) async { + final (transport, container, context) = await pumpArm( + tester, + extraOverrides: [ + agentCatalogProvider.overrideWith( + () => _SeededCatalog({ + 'claude': _descriptor('claude'), + 'codex': _descriptor('codex'), + }), + ), + ], + ); + await openSheet(tester, container, context); + + await tester.tap(find.byType(HandlerJudgeChip)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Codex')); + await tester.pumpAndSettle(); + // The panel stays open on a judge pick — the model is the next thing + // the user may want — so it has to be dismissed before the sheet's own + // commit is reachable. + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(AbButton, 'Arm Handler')); + await tester.pumpAndSettle(); + + final configures = transport.sent + .where((m) => m['type'] == 'handler:configure') + .toList(); + expect(configures, hasLength(1)); + expect(configures.single['judgeTool'], 'codex'); + + await confirmArmed(tester, transport); + }); + }); }); group('HandlerHeaderControl shield form', () { diff --git a/app/test/widgets/new_session_composer_test.dart b/app/test/widgets/new_session_composer_test.dart index 414ac586..98cd1649 100644 --- a/app/test/widgets/new_session_composer_test.dart +++ b/app/test/widgets/new_session_composer_test.dart @@ -5,9 +5,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/misc.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:antgrid/design/widgets/ab_prompt_field.dart'; import 'package:antgrid/design/widgets/ab_cross_fade.dart'; import 'package:antgrid/design/widgets/ab_switch.dart'; import 'package:antgrid/design/ab_theme.dart'; +import 'package:antgrid/launcher/host_control_client.dart' + show HostControlException; import 'package:antgrid/models/agent_descriptor.dart'; import 'package:antgrid/models/git_branch.dart'; import 'package:antgrid/providers/agent_catalog.dart'; @@ -177,7 +180,7 @@ Widget _host({ }) { final composer = NewSessionComposer( onOpenFolder: onOpenFolder ?? () {}, - submit: submit ?? (_, {allowActiveSessions = false}) async {}, + submit: submit ?? (_, {allowActiveSessions = false, stashIfDirty = false}) async {}, ); return ProviderScope( overrides: overrides, @@ -201,7 +204,7 @@ void main() { await tester.pumpWidget( _host( overrides: _baseOverrides(target: _project), - submit: (ref, {allowActiveSessions = false}) async { + submit: (ref, {allowActiveSessions = false, stashIfDirty = false}) async { submitCount++; }, ), @@ -234,7 +237,7 @@ void main() { await tester.pumpWidget( _host( overrides: _baseOverrides(target: _project), - submit: (ref, {allowActiveSessions = false}) async { + submit: (ref, {allowActiveSessions = false, stashIfDirty = false}) async { submitCount++; }, ), @@ -275,7 +278,7 @@ void main() { ); await tester.pumpAndSettle(); - final promptField = tester.widget( + final promptField = tester.widget( find.byKey(const Key('new-session-prompt-field')), ); expect(promptField.enabled, isFalse); @@ -661,7 +664,7 @@ void main() { builder: (context, ref, _) => ref.watch(_composerVisible) ? NewSessionComposer( onOpenFolder: () {}, - submit: (_, {allowActiveSessions = false}) async {}, + submit: (_, {allowActiveSessions = false, stashIfDirty = false}) async {}, ) : const SizedBox.shrink(), ), @@ -934,7 +937,7 @@ void main() { ), ), ], - submit: (ref, {allowActiveSessions = false}) async { + submit: (ref, {allowActiveSessions = false, stashIfDirty = false}) async { submitCalls.add(allowActiveSessions); if (!allowActiveSessions) { throw ActiveSessionsBranchSwitchException( @@ -985,7 +988,7 @@ void main() { ), ), ], - submit: (ref, {allowActiveSessions = false}) async { + submit: (ref, {allowActiveSessions = false, stashIfDirty = false}) async { submitCalls.add(allowActiveSessions); if (!allowActiveSessions) { throw ActiveSessionsBranchSwitchException( @@ -1018,6 +1021,115 @@ void main() { }); }); + group('git checkout refusals', () { + testWidgets( + 'DIRTY_WORKTREE offers to stash and retries on confirm', + (tester) async { + var submitCalls = []; + await tester.pumpWidget( + _host( + overrides: [ + ..._baseOverrides(target: _project), + newSessionBranchSelectionProvider.overrideWith( + () => ValueController( + const NewSessionBranchSelection( + targetId: 'p-my-repo', + branch: 'dev', + ), + ), + ), + ], + submit: (ref, {allowActiveSessions = false, stashIfDirty = false}) async { + submitCalls.add(stashIfDirty); + if (!stashIfDirty) { + throw DirtyWorktreeBranchSwitchException( + targetId: _project.id, + branch: 'dev', + ); + } + }, + ), + ); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const Key('new-session-prompt-field')), + 'start session', + ); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(submitCalls, [false]); + expect(find.text('Stash uncommitted changes?'), findsOneWidget); + + await tester.tap(find.text('Stash & switch')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(submitCalls, [false, true]); + }, + ); + + // Only the TYPED DirtyWorktreeBranchSwitchException gets the stash offer + // above — a bare HostControlException carrying the same code (e.g. from a + // caller that skipped the conversion `startNewSession` does) has no safe + // retry to offer here, so it must land as clear, specific text (naming + // the files, as the bridge's own message does) rather than the raw + // exception dump the generic catch-all prints. + testWidgets( + 'DIRTY_WORKTREE shows the bridge message, not a raw exception dump', + (tester) async { + await tester.pumpWidget( + _host( + overrides: [ + ..._baseOverrides(target: _project), + newSessionBranchSelectionProvider.overrideWith( + () => ValueController( + const NewSessionBranchSelection( + targetId: 'p-my-repo', + branch: 'dev', + ), + ), + ), + ], + submit: (ref, {allowActiveSessions = false, stashIfDirty = false}) async { + throw HostControlException( + 'DIRTY_WORKTREE', + 'Switching to "dev" would overwrite uncommitted changes in: ' + 'a.txt. Commit, stash, or discard them first.', + ); + }, + ), + ); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const Key('new-session-prompt-field')), + 'start session', + ); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect( + find.text( + 'Switching to "dev" would overwrite uncommitted changes in: ' + 'a.txt. Commit, stash, or discard them first.', + ), + findsOneWidget, + ); + expect(find.textContaining('HostControlException'), findsNothing); + expect(find.textContaining('Failed to start session'), findsNothing); + + await tester.pump(const Duration(seconds: 8)); + await tester.pump(const Duration(milliseconds: 300)); + }, + ); + }); + group('create-time isolation refusals', () { /// Submits, then settles far enough for the refusal's snack bar to render. Future submitPrompt(WidgetTester tester) async { @@ -1038,7 +1150,7 @@ void main() { Widget refusingHost(SessionOperationException refusal) => _host( overrides: _baseOverrides(target: _project), - submit: (ref, {allowActiveSessions = false}) async => throw refusal, + submit: (ref, {allowActiveSessions = false, stashIfDirty = false}) async => throw refusal, ); testWidgets('a mapped code replaces the bridge wording', (tester) async { @@ -1226,7 +1338,7 @@ void main() { await tester.pumpWidget( _host( overrides: _baseOverrides(target: _project), - submit: (ref, {allowActiveSessions = false}) async { + submit: (ref, {allowActiveSessions = false, stashIfDirty = false}) async { submitCount++; }, ), @@ -1243,14 +1355,27 @@ void main() { begin(container); await settle(tester); - final field = tester.widget( + final field = tester.widget( find.byKey(const Key('new-session-prompt-field')), ); // Frozen, not disabled: the prompt already on the wire is the thing the // user is waiting on, so it stays legible and undimmed. expect(field.readOnly, isTrue); expect(field.enabled, isTrue); - expect(field.showCursor, isFalse); + // Read off the rendered field, because AbPromptField DERIVES this from + // readOnly rather than taking it — a caret blinking in a frozen prompt + // invites the edit the lock exists to refuse. + expect( + tester + .widget( + find.descendant( + of: find.byKey(const Key('new-session-prompt-field')), + matching: find.byType(TextField), + ), + ) + .showCursor, + isFalse, + ); await tester.sendKeyEvent(LogicalKeyboardKey.enter); await settle(tester); @@ -1381,8 +1506,10 @@ void main() { await tester.tap(find.byKey(const Key('new-session-prompt-field'))); await tester.pumpAndSettle(); final node = tester - .widget(find.byKey(const Key('new-session-prompt-field'))) - .focusNode!; + .widget( + find.byKey(const Key('new-session-prompt-field')), + ) + .focusNode; expect(node.hasFocus, isTrue); return node; } @@ -1485,7 +1612,7 @@ void main() { builder: (context, ref, _) => ref.watch(_composerVisible) ? NewSessionComposer( onOpenFolder: () {}, - submit: (_, {allowActiveSessions = false}) async {}, + submit: (_, {allowActiveSessions = false, stashIfDirty = false}) async {}, ) : const SizedBox.shrink(), ), @@ -1518,7 +1645,7 @@ void main() { expect(statusText(tester), 'Waking mac-studio...'); expect( tester - .widget( + .widget( find.byKey(const Key('new-session-prompt-field')), ) .readOnly, diff --git a/app/test/widgets/projects_drawer_first_run_test.dart b/app/test/widgets/projects_drawer_first_run_test.dart index 9cf1dbe1..19fc0641 100644 --- a/app/test/widgets/projects_drawer_first_run_test.dart +++ b/app/test/widgets/projects_drawer_first_run_test.dart @@ -137,14 +137,20 @@ void main() { expect(tester.takeException(), isNull); - // Still docked: below the project rows, above the account footer — which is - // pinned and therefore whole, not scrolled away with the checklist. + // Above the account footer, which is pinned and therefore whole rather than + // scrolled away with the checklist. final section = tester.getTopLeft(find.byType(FirstRunSetupSection)).dy; + expect(section, lessThan(tester.getTopLeft(find.byType(AccountFooter)).dy)); + + // And below a WHOLE first row, not a sliver of one. This is the size where + // the checklist would otherwise take the entire body, and a drawer that + // lists no machine and no project is the thing minBodyExtent exists to + // prevent — a bound in scaled pixels, because the row it has to contain + // grows with the scaler and a raw token does not. expect( - section, - greaterThan(tester.getTopLeft(find.byType(DrawerEntryRow).first).dy), + tester.getBottomLeft(find.byType(LocalMachineBand)).dy, + lessThanOrEqualTo(section), ); - expect(section, lessThan(tester.getTopLeft(find.byType(AccountFooter)).dy)); expect(tester.getSize(find.byType(AccountFooter)).height, 45); // The point of the dock: its viewport is shorter than the checklist inside diff --git a/app/test/widgets/session_handler_badge_test.dart b/app/test/widgets/session_handler_badge_test.dart new file mode 100644 index 00000000..fe9fec2e --- /dev/null +++ b/app/test/widgets/session_handler_badge_test.dart @@ -0,0 +1,95 @@ +// The one surface a session that is NOT in focus has. The Handler tab, its tab +// badge and the agent header's pill all answer for the focused session, so a +// sibling's unanswered question is only ever visible here. +import 'package:antgrid/models/handler_state.dart'; +import 'package:antgrid/providers/agent_transport.dart'; +import 'package:antgrid/providers/providers.dart'; +import 'package:antgrid/widgets/session_handler_badge.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +HandlerSessionState _session(String terminalId, {required int pending}) => + HandlerSessionState( + terminalId: terminalId, + runState: pending > 0 + ? HandlerRunState.needsYou + : HandlerRunState.watching, + pendingEscalations: pending, + armedAt: 1, + goal: 'ship it', + backlog: const [], + escalations: const [], + ); + +Future _pump( + WidgetTester tester, { + required String entryId, + required String sessionId, + String? focusedProject = 'p', + Map sessions = const {}, +}) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + selectedRegistrationIdProvider.overrideWithValue(focusedProject), + handlerStateProvider.overrideWith( + (ref) => + Stream.value(HandlerState.initial().copyWith(sessions: sessions)), + ), + ], + child: MaterialApp( + home: Scaffold( + body: SessionHandlerBadge(entryId: entryId, sessionId: sessionId), + ), + ), + ), + ); + await tester.pump(); +} + +void main() { + testWidgets('counts the answers Handler is waiting on', (tester) async { + await _pump( + tester, + entryId: 'p', + sessionId: 't2', + sessions: {'t1': _session('t1', pending: 1), 't2': _session('t2', pending: 2)}, + ); + expect(find.text('2'), findsOneWidget); + }); + + testWidgets('says nothing for a session with nothing pending', ( + tester, + ) async { + await _pump( + tester, + entryId: 'p', + sessionId: 't1', + sessions: {'t1': _session('t1', pending: 0)}, + ); + expect(find.byType(SessionHandlerBadge), findsOneWidget); + expect(find.text('0'), findsNothing); + }); + + testWidgets('says nothing for a session that is not armed at all', ( + tester, + ) async { + await _pump(tester, entryId: 'p', sessionId: 'unarmed'); + expect(find.textContaining(RegExp(r'^\d+$')), findsNothing); + }); + + testWidgets('refuses a row belonging to another project', (tester) async { + // handlerStateProvider follows project focus, so answering this row would + // attach the FOCUSED project's count to another project's session name — + // worse than no count. Cross-project escalations need a surface of their + // own. + await _pump( + tester, + entryId: 'other', + sessionId: 't2', + sessions: {'t2': _session('t2', pending: 2)}, + ); + expect(find.text('2'), findsNothing); + }); +} diff --git a/app/test/widgets/session_row_rename_blur_test.dart b/app/test/widgets/session_row_rename_blur_test.dart new file mode 100644 index 00000000..7048e865 --- /dev/null +++ b/app/test/widgets/session_row_rename_blur_test.dart @@ -0,0 +1,136 @@ +import 'package:antgrid/models/session_entry.dart'; +import 'package:antgrid/project/project_session.dart'; +import 'package:antgrid/project/project_session_registry.dart'; +import 'package:antgrid/providers/agent_transport.dart'; +import 'package:antgrid/storage/cached_sessions_store.dart'; +import 'package:antgrid/test_helpers/fake_agent_transport.dart'; +import 'package:antgrid/widgets/session_row.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../helpers/prefs_test_mock.dart'; + +const _projectId = 'proj-rename-blur'; + +SessionEntry _session(String id) => SessionEntry( + id: id, + name: 'Diagnose terminal scrollback bug', + createdAt: 0, + lastUsedAt: 0, + archived: false, + running: true, +); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(useInMemoryPrefs); + + // Regression: blurring an inline rename used to take down the app with + // `ConcurrentModificationError: Concurrent modification during iteration: + // _Set len:N` (seen in production as a FATAL, culprit + // `_CompactIterator.moveNext` under `FocusManager.applyFocusChangesIfNeeded`). + // + // The field's `onFocusChange` commits the rename, and `detached` runs that + // through `Future.sync`, so `_exitEdit` executed INSIDE the focus + // notification. Disposing its FocusNode there detaches it, and + // `FocusManager._markDetached` removes it from `_dirtyNodes` — the Set the + // notification loop is iterating. + testWidgets('blurring an inline rename does not crash the focus manager', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.windows; + final elsewhere = FocusNode(debugLabel: 'elsewhere'); + addTearDown(elsewhere.dispose); + try { + final transport = FakeAgentTransport(); + final cache = await CachedSessionsStore.open(); + addTearDown(cache.close); + final projectSession = ProjectSession( + projectId: _projectId, + transport: transport, + mode: ProjectSessionMode.local, + cachedSessionsStore: cache, + onClose: () async => await transport.dispose(), + ); + // Registered after the cache so it tears down FIRST (addTearDown is + // LIFO). Both own timers and stream subscriptions that would otherwise + // outlive the widget tree and fail some later test with a pending-timer + // assertion pointing nowhere near this file. + addTearDown(projectSession.close); + final container = ProviderContainer( + overrides: [ + selectedRegistrationIdProvider.overrideWithValue(_projectId), + projectSessionProvider.overrideWith( + (ref, id) async => projectSession, + ), + ], + ); + addTearDown(container.dispose); + await container.read(projectSessionProvider(_projectId).future); + // Inline rename is offered only for a WARM project, so the row cannot + // enter edit mode until the registry knows about this one. + container + .read(projectSessionRegistryProvider.notifier) + .touch(_projectId, isLocal: true); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + home: Scaffold( + body: Column( + children: [ + SizedBox( + width: 260, + child: SessionRow( + entryId: _projectId, + session: _session('sess-rename'), + ), + ), + // Somewhere for focus to GO. The bug needs a real focus + // change, not just an unfocus. + Focus( + focusNode: elsewhere, + child: const SizedBox(height: 20), + ), + ], + ), + ), + ), + ), + ); + await tester.pump(); + await tester.pump(); + + // Double-tap opens the inline editor (desktop-only affordance). + final row = find.byType(SessionRow); + await tester.tap(row); + await tester.pump(kDoubleTapMinTime); + await tester.tap(row); + await tester.pumpAndSettle(); + + final field = find.byType(TextField); + if (field.evaluate().isEmpty) { + // Rename is gated on the project being warm; if this row cannot enter + // edit mode the test is not exercising anything and must say so rather + // than pass silently. + fail('inline rename did not open — SessionRow never entered edit mode'); + } + + // The blur. Before the fix this threw out of the microtask that runs + // `applyFocusChangesIfNeeded`. + elsewhere.requestFocus(); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.byType(TextField), findsNothing); + + await tester.pumpWidget(const SizedBox()); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); +} diff --git a/app/test/widgets/terminal_view_wrapper_keys_test.dart b/app/test/widgets/terminal_view_wrapper_keys_test.dart index 10825eb9..c4b1ebe4 100644 --- a/app/test/widgets/terminal_view_wrapper_keys_test.dart +++ b/app/test/widgets/terminal_view_wrapper_keys_test.dart @@ -3,6 +3,8 @@ // NOTHING for Alt+, and an over-eager paste interception swallows the // chord an agent CLI binds for itself (Claude Code's paste-image is ctrl+v // everywhere except Windows/WSL, where it is alt+v). +import 'dart:ui' as ui; + import 'package:antgrid/design/theme_presets.dart'; import 'package:antgrid/models/terminal_models.dart'; import 'package:antgrid/project/project_session.dart'; @@ -458,4 +460,105 @@ void main() { expect(written, isNot(contains(_esc))); }, ); + + _platformTestWidgets( + 'Windows: an injected Ctrl+V still pastes when the embedder ' + 'de-synchronizes Ctrl mid-chord', + TargetPlatform.windows, + (tester) async { + // Windows clipboard history (Win+V) pastes by injecting Ctrl+V with no + // scancode, which sets VK_CONTROL but not VK_LCONTROL. Flutter's Windows + // embedder re-syncs the SIDED modifiers on every key event, decides the + // Ctrl it just delivered is not down, and synthesizes an up for it BEFORE + // the V — then a down again after it. Measured on Flutter 3.44 / + // Windows 11; without the wrapper's own view of the chord this pasted + // nothing and typed a bare `v` into the agent. + final written = await pumpTerminal( + tester, + 't-injected-paste', + clipboardText: 'pasted', + ); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + _synthesizedControl(ui.KeyEventType.up); + await tester.sendKeyDownEvent( + LogicalKeyboardKey.keyV, + character: 'v', + ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.keyV); + _synthesizedControl(ui.KeyEventType.down); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + + expect(String.fromCharCodes(written), 'pasted'); + }, + ); + + _platformTestWidgets( + 'Windows: a numpad key with NumLock off reaches the PTY', + TargetPlatform.windows, + (tester) async { + // No character metadata is exactly what NumLock-off looks like, and + // Ghostty's shim resolves a numpad key to neither a key enum nor + // printable text — so before this the whole numpad wrote nothing at all. + final written = await pumpTerminal(tester, 't-numpad-navigation'); + await tester.sendKeyDownEvent(LogicalKeyboardKey.numpad4); + await tester.sendKeyUpEvent(LogicalKeyboardKey.numpad4); + await tester.pumpAndSettle(); + + // Left arrow, DECCKM off. + expect(written, [_esc, 0x5B, 0x44]); + }, + ); + + _platformTestWidgets( + 'Windows: a Ctrl the wrapper never saw pressed still counts as held', + TargetPlatform.windows, + (tester) async { + // The mirror is three-valued for this case: Ctrl-clicking into the + // terminal while already holding Ctrl leaves it with no real event for + // that key, so it must defer to `HardwareKeyboard` rather than call the + // chord released and eat the paste. + final written = await pumpTerminal( + tester, + 't-ctrl-before-focus', + clipboardText: 'deferred', + ); + + // A synthesized down is how the framework reports a modifier it caught up + // on rather than saw pressed, so it updates `HardwareKeyboard` while the + // mirror deliberately learns nothing from it. + _synthesizedControl(ui.KeyEventType.down); + await tester.pumpAndSettle(); + await tester.sendKeyDownEvent(LogicalKeyboardKey.keyV, character: 'v'); + await tester.sendKeyUpEvent(LogicalKeyboardKey.keyV); + _synthesizedControl(ui.KeyEventType.up); + await tester.pumpAndSettle(); + + expect(String.fromCharCodes(written), 'deferred'); + }, + ); +} + +/// Dispatches the synthesized Ctrl event Flutter's Windows embedder emits when +/// it re-synchronizes modifier state. `KeyEventSimulator` only produces real +/// events, and the whole point of the case under test is that these are not. +/// +/// `keyEventManager` is the only door a synthesized event can come through — +/// its replacement (`HardwareKeyboard.addHandler`) receives events rather than +/// injecting them, and updating `HardwareKeyboard` alone would never reach the +/// focus-manager handler under test. +// ignore: deprecated_member_use +void _synthesizedControl(ui.KeyEventType type) { + // ignore: deprecated_member_use + ServicesBinding.instance.keyEventManager.handleKeyData( + ui.KeyData( + timeStamp: Duration.zero, + type: type, + physical: PhysicalKeyboardKey.controlLeft.usbHidUsage, + logical: LogicalKeyboardKey.controlLeft.keyId, + character: null, + synthesized: true, + ), + ); } diff --git a/app/test/widgets/transcript/markdown_body_test.dart b/app/test/widgets/transcript/markdown_body_test.dart index 6b1dcae4..7269c10b 100644 --- a/app/test/widgets/transcript/markdown_body_test.dart +++ b/app/test/widgets/transcript/markdown_body_test.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:visibility_detector/visibility_detector.dart'; import 'package:antgrid/widgets/transcript/markdown_body.dart'; @@ -15,11 +16,13 @@ void main() { tester, ) async { await tester.pumpWidget( - const MaterialApp( - home: Scaffold( - body: SingleChildScrollView( - child: TranscriptMarkdown( - data: 'hi **bold**\n\n```dart\nfinal x = 1;\n```', + ProviderScope( + child: MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: TranscriptMarkdown( + data: 'hi **bold**\n\n```dart\nfinal x = 1;\n```', + ), ), ), ), diff --git a/bridge/CLAUDE.md b/bridge/CLAUDE.md index 181b9d63..da74da3b 100644 --- a/bridge/CLAUDE.md +++ b/bridge/CLAUDE.md @@ -145,6 +145,16 @@ already has them. - bare PTY keystroke → `userReply`: clears the block only. Typing in an idle session is not work. - PTY keystroke that SUBMITTED (`isSubmitKeystroke` in `keystrokes.ts`: a trailing CR, but not `\x1b\r` — alt+enter inserts a newline and may never be sent) → `userReply({submitted:true})`: also opens a turn, but only for a session in `keystrokeTurnSessions` — an agent with turn-END hooks and no turn-start (codex/cursor/copilot; see `needsKeystrokeTurnStart` in `agents/registry.ts`, which reads it off each agent's own `hooks.turnBoundaryEvents`). Never for Claude (it has a real signal) nor for the hookless agents (opencode/antigravity/kilo/kimi/mistral-vibe — nothing would close the inferred turn). + Not every `terminal:input` frame is a keystroke. A viewer's VT engine answers + the modes the guest turned on over the SAME channel, so a session with DEC + 1004 focus reporting or mouse tracking on gets `CSI I`/`CSI O` on every window + focus change and a mouse report per click — `isTerminalReport` (`keystrokes.ts`) + is what keeps those out of every "the user acted" consumer in agent-core's + `terminal:input` case while still writing them to the PTY. The guard is the + `break` those consumers all sit below, so one added there inherits it. + Without it, clicking back into the window to ANSWER a blocked agent was itself + what cleared its "needs you" dot. + The submit gate has **two** halves and both are required. A PTY delivers one keystroke per frame, so the submitting CR normally arrives alone and `isSubmitKeystroke` alone cannot tell a prompt from enter on an empty line or on a TUI menu — which start no turn, so the stop hook the inference depends on never fires. `hasTypedContent` (also `keystrokes.ts`) marks the session in `typedSessions`, and only a submit with that marker opens a turn; opening consumes it. Which agent a session runs is `s.tool ?? defaultTool`, where `defaultTool` is folded from `agent:hello` — a `SessionEntry` carries `tool` only when it OVERRODE the project's `agent.tool`, so reading the entry alone silently opted every default-spec session out of the inference. Two ordering rules fall out of the fold being keyed by session id: an attributed turn-start that beats its session's first `session:updated` is HELD in `pendingTurns` for exactly one session list, and a notification whose `terminalId` is not a running session (config-`terminals:` slots stamp one too) falls back to the project-wide key rather than being filed where nothing can read it. That fallback FANS OUT — `statusFor` reads it for every running session — and a turn-start clears it on the word of one session; both are accepted (losing the signal is worse than over-reporting it), and both are the reason a config-`terminals:` error dots every session on the project. @@ -159,6 +169,7 @@ already has them. - `stream-mux.ts` — multiplexes project cores over the machine socket as sealed `{s, m}` envelopes (`s` absent/`"0"` = machine control plane; the ENVELOPE JSON is what gets fragmented, so `s` survives reassembly). `attachStream(bus, opts)` → `StreamHandle{streamId, detach, sendTunnel}`; admission = `stream-open` → `stream-opened` vs `error{ref: streamId}` (a rejection leaves the socket and other streams live); `opts.mayDeliver` is the OUTBOUND authorization hook, re-read on every bus frame and every `sendTunnel` (tunnel bypasses the bus) — absent means always-deliver, so a caller that answers to a switch must fail closed in its own provider; on each `welcome` the mux re-opens every attached stream (the relay dropped its `openStreams` on the disconnect). Current relays admit every stream a healthy machine opens — no per-account quota survives (`SESSION_LIMIT_EXCEEDED` is retired, kept only for relays predating the worker-limit change; `ErrorCode` in `packages/antgrid-wire/src/relay-protocol.ts` reserves the name for exactly that reason, and the relay side is the Streams bullet in `relay/CLAUDE.md`). The one rejection a current relay can still send is `STREAM_LIMIT_EXCEEDED`, the relay's structural per-connection ceiling: orders of magnitude above real use, so treat it as our bug (a leak of undetached streams), never as backpressure to retry. An inbound frame for an unknown streamId is dropped AND answered with a control-plane `stream-invalid {streamId}` (rate-limited per dead id): a host restart re-attaches every project under fresh ids, and without that notice the phone replays onto the dead id forever with nothing to trigger a renegotiation. - `host-server.ts` + `paired-phones.ts` — machine-level device trust. `HostServer.startRemoteControlPlane()` owns the single machine RelayClient (bare `deviceUuid` — the only registration shape; compound `deviceUuid.projectId` is gone); project cores attach as streams via `remoteDepsFor(projectId)` (`ProjectCoreRemoteDeps = {attachStream, currentPeerPubkey, sendPushDeliver}` — `wireRelaySlot` is deleted). Stream admission publishes `stream-ready {projectId, streamId}`, and `buildProjectsAdvertisement` (`agent:projects`) carries per-project `streamId` so a reconnecting phone binds without a fresh `project:start`; stopped projects start on demand (`handleControlPlaneVerb` → `project:start`). `startCore` re-advertises unconditionally: an open no phone asked for (restart re-open, desktop-side open) lands AFTER the handshake advert, and nothing else announces it. A rejected verb returns `control:result {ok:false,error}` to the phone (never silently dropped). Authorization for a remote device is `loadRemoteAccessPolicy(abDir)` (`agents/mobile-access-policy.json` — the filename and the `mobile-access:*` verbs keep the old spelling on purpose: both cross a version boundary the rename cannot reach) — ONE machine-wide boolean, the only gate, read live at every check via `remoteAccessEnabled()` so `mobile-access:set` takes effect without restarting a core. It gates the stream in BOTH directions and both halves are load-bearing: inbound at `currentPhoneAllowed()` (agent-core's bus handler + `handleTunnelMessage`), outbound at the stream's `mayDeliver` (`attachRelayStream`). Inbound alone is not enough — a project the phone cold-started opens as a `mode:"remote"` core with no `PromotionHandle`, so `demoteAllPromoted()` (which turning the switch off also runs, for every PROMOTED slot) never touches it and it would keep streaming terminal/tree/git at the phone. Gating at the send, not at detach, is deliberate: the core and its stream stay alive, so flipping the switch back on resumes the same `streamId` with no re-attach and no destroyed work. Which projectId that phone may name is bounded solely by `isSafeProjectId` + the `seenProjects` catalog — every remote verb (`project:start`, both sessions RPCs) must do that lookup, there is no second gate behind it. `loadPairedPhones(abDir)` (`agents/paired-phones.json`) is NOT authorization: it is the identity/push-token/`lastSeenAt` row, kept for push targeting and freshness (hence `watch()` + `touchLastSeen` survive). - `auth/` — in-memory OAuth (no on-disk store). `credentials.ts` parses one JSON line from stdin into a `BootstrapPayload` (`local | remote`, 10s idle timeout) written by the app on spawn. `oauth-client.ts` mints tokens via `POST /api/auth/oauth2/token` (`grant_type=client_credentials`, `resource=/api/auth`); `startTokenMaintenance` re-mints at 80% of TTL (30s retry). On `invalid_client` → emit `auth_revoked` to stderr, exit 4 — that verdict is keyed on the ERROR CODE, not the status: Better-Auth answers a revoked device with 401 but a deleted client row with **400** ("missing client"), and a sign-out rotates the device and drops its row, so both mean the cached pair is dead. Credentials reach the host only once, via the stdin bootstrap, so a host left running on a rotated-away pair can never recover on its own — the app respawns it when the account device changes (`local_host_warmup.dart`). **The boot-time control-plane mint is exempt from the exit** (`fatalRevokeArmed`, disarmed across `start()`'s `startRemoteControlPlane()`): host.json and the ready marker are already out by then, so exiting would have the app's supervisor respawn straight back into the same dead pair — a permanent crash loop that also takes down the loopback plane local work depends on. Boot logs and serves loopback-only; a verdict from token maintenance afterwards is still fatal. +- `crash-reporting.ts` — Sentry (`@sentry/bun`) for the HOST process only, into the same self-hosted errex project as the app. Three things gate it and all three must hold: the user's consent, which arrives on the stdin bootstrap as `telemetryEnabled` (the app reads the SAME setting that decides its own Sentry init, so one install cannot report from one half and not the other) and whose ABSENCE means off — a CLI or test host has nobody who consented; a `SENTRY_DSN` baked in at build time by `--define`, exactly like `LICENSE_API_URL`, so an un-`--define`d dev build is inert unless the env var is deliberately set — and it must carry a NUMERIC project id, because the JS SDKs reject any other and `Sentry.init` swallows the refusal (no throw, no status; later captures and even `flush` then succeed while sending nothing), which is why init verifies `getClient()?.getDsn()` and logs at error rather than trusting itself. errex issues SLUGS (`antgrid-app`), so the DSN that works for the app does NOT work here; sentry-dart takes the last path segment as an opaque String, which is why only this side is affected; and `scrubCrashEvent`, which strips paths, source lines, locals and the hostname before transmit — kept in lockstep with `app/lib/analytics/crash_reporting.dart`, since a path that survives one scrubber and not the other is one leak wearing two faces. Consent is fixed for the host's lifetime (the bootstrap is read once); that is the same restart-scoped gate the app applies to itself, not an oversight. Every integration that reads request bodies or source off disk is excluded (reasons are per-name in the file). **`OnUncaughtException`/`OnUnhandledRejection` are deliberately KEPT** and own the capture on both top-level paths, because they are what stamps a fatal `handled: false` (`auto.node.onuncaughtexception`) — a hand-rolled `captureException` reports the same crash as `generic`/`handled: true`, which is wrong in exactly the dimension this instrumentation answers. They are re-added with options pinned rather than inherited, and the contract has a second half that lives in `index.ts`: the SDK re-counts the OTHER `uncaughtException` listeners AT CRASH TIME, so it defers to our teardown only while one of ours is registered, and as the sole listener it exits on its own and skips the PTY sweep. `index.ts` therefore owns the EXIT and registers its handlers as early as `shutdown` can be closed over; do not widen that window from either side. **The `hook` subcommand is deliberately uninstrumented** — see the comment on its action for why an SDK there would be both unconsented and unable to catch the failure it looks like it would catch. ## Stopping an agent diff --git a/bridge/package.json b/bridge/package.json index ea91bd2b..2f7623c3 100644 --- a/bridge/package.json +++ b/bridge/package.json @@ -12,6 +12,7 @@ "@anthropic-ai/claude-agent-sdk": "0.3.201", "@inquirer/prompts": "^8.4.2", "@opencode-ai/sdk": "1.15.10", + "@sentry/bun": "^10.70.0", "@xterm/addon-serialize": "^0.14.0", "@xterm/headless": "^6.0.0", "antgrid-wire": "workspace:*", diff --git a/bridge/src/agent-core.ts b/bridge/src/agent-core.ts index f9b3ac7b..de104d8c 100644 --- a/bridge/src/agent-core.ts +++ b/bridge/src/agent-core.ts @@ -11,6 +11,7 @@ import { hasTypedContent, isInterruptKeystroke, isSubmitKeystroke, + isTerminalReport, submittedLine, } from "./keystrokes"; import { AGENT_GRACE_MS, killChildTree, processGroupSpawn } from "./terminal-session"; @@ -61,7 +62,9 @@ import { snapshotAsksFor } from "./rpc/state-snapshot"; import { StructuredAgentManager } from "./structured/structured-manager"; import { TOOL_UPDATE_SPECS, createToolUpdateChecker, execToolUpdate, execToolVersion, parseAgentVersion, runAgentUpdate, updateSpecFor } from "./update/specs"; import { getGitStatus, gitCommit, gitDiscard, gitStage, gitUnstage, type GitFileEntry } from "./git"; -import { listLocalBranches, checkoutLocalBranch } from "./git-branches"; +import { listLocalBranches, checkoutLocalBranch, checkBranchAgainstRemote, listStashes, stashPop, stashDrop } from "./git-branches"; +import { getGitLog, getCommitFiles, getCommitFileDiff } from "./git-log"; +import { gitPull, gitPush, readSyncState, fetchRemote, EMPTY_SYNC_STATE, type GitSyncState } from "./git-sync"; import { WORKTREE_SESSIONS_SUPPORTED } from "./worktree-capability"; /** Hand the event loop one full turn. `setImmediate` fires in libuv's check @@ -95,7 +98,16 @@ interface CheckoutRuntime { runningCommands: Map; cachedGitBranch: string | null; cachedGitFiles: GitFileEntry[]; + /** Ahead/behind against the upstream REF. Refreshed alongside the git status + * it rides with, which is only affordable because [readSyncState] reaches no + * remote — a probe here would be one network round trip per checkout every + * 10s on the backstop poll alone. */ + cachedGitSync: GitSyncState; gitBranchInterval: ReturnType | null; + /** Periodic background `git fetch` — see [fetchRemote]. Its own timer, on a + * much longer period than [gitBranchInterval]: that one is a cheap LOCAL + * read, this one reaches the network. */ + gitAutofetchInterval: ReturnType | null; gitRefreshTimer: ReturnType | null; /** Fire-and-forget `git status` reads still running against this checkout — * see [trackGitRefresh] for why teardown has to wait them out. */ @@ -570,7 +582,9 @@ export async function buildAgentCore(opts: BuildAgentCoreOptions): Promise + log.error("git:log handler failed: %s", err) + ); + break; + } + case "git:commit-files": { + handleGitCommitFiles(runtime, msg.projectId, msg.sha).catch((err) => + log.error("git:commit-files handler failed: %s", err) + ); + break; + } + case "git:commit-diff": { + handleGitCommitDiff(runtime, msg.projectId, msg.sha, msg.path).catch((err) => + log.error("git:commit-diff handler failed: %s", err) + ); + break; + } case "git:checkout": { handleGitCheckout(runtime, msg.projectId, msg.branch).catch((err) => log.error("git:checkout handler failed: %s", err) @@ -1011,6 +1056,53 @@ export async function buildAgentCore(opts: BuildAgentCoreOptions): Promise + log.error("git:stash-list handler failed: %s", err) + ); + break; + } + case "git:stash-pop": { + // Tracked for the same reason `git:sync` below is, and more urgently: + // a pop rewrites the whole working tree, so it holds the checkout as + // its child's cwd for longer than a push does. + trackGitRefresh( + runtime, + handleGitStashPop(runtime, msg.projectId, msg.ref).catch((err) => + log.error("git:stash-pop handler failed: %s", err) + ), + ); + break; + } + case "git:stash-drop": { + trackGitRefresh( + runtime, + handleGitStashDrop(runtime, msg.projectId, msg.ref).catch((err) => + log.error("git:stash-drop handler failed: %s", err) + ), + ); + break; + } + case "git:sync": { + // Tracked, not merely fired: a push/pull holds the checkout as its + // child's cwd for up to the transfer timeout, and `awaitGitRefreshes` + // is what teardown waits on before `git worktree remove`. Untracked, + // a session deleted mid-push takes a Windows sharing violation and is + // then undeletable forever. + trackGitRefresh( + runtime, + handleGitSync(runtime, msg.projectId, msg.op).catch((err) => + log.error("git:sync handler failed: %s", err) + ), + ); + break; + } + case "git:sync-status": { + handleGitSyncStatus(runtime, msg.projectId, msg.probeRemote === true).catch((err) => + log.error("git:sync-status handler failed: %s", err) + ); + break; + } case "command:run": { const cmdConfig = runtime.config.commands?.find((c) => c.name === msg.commandName); if (!cmdConfig) { @@ -1433,6 +1525,8 @@ export async function buildAgentCore(opts: BuildAgentCoreOptions): Promise { const ticket = ++runtime.gitStatusSeq; let files: GitFileEntry[]; + let sync: GitSyncState; try { - files = await getGitStatus(runtime.checkout.path); + // Read together and applied together under ONE ticket: they are two + // halves of the same snapshot, and a separate sequence for each lets a + // reader see this refresh's file list beside the previous one's counts. + [files, sync] = await Promise.all([ + getGitStatus(runtime.checkout.path), + readSyncState(runtime.checkout.path), + ]); } catch { // `Bun.spawn` throws SYNCHRONOUSLY when cwd is gone, which is the normal // state once the checkout has been removed under an in-flight refresh. @@ -1653,6 +1754,7 @@ export async function buildAgentCore(opts: BuildAgentCoreOptions): Promise>, + force = false, + ) { + sendFromRuntime(runtime, createMessage("git:sync-state", { + projectId: project.id, + branch: state.branch, + remote: state.remote, + remoteBranch: state.remoteBranch, + // A probe ASKED the remote, so its counts supersede the local ones for + // the frame that reports its verdict — sending `state: "behind"` beside + // a pre-fetch `behind: 0` describes two different moments as one. + // `checkBranchAgainstRemote` omits both whenever it could not count + // (`differs`, `unreachable`, `gone`), which is when the local pair is + // still the best answer there is. + ahead: probed?.ahead ?? state.ahead, + behind: probed?.behind ?? state.behind, + hasUpstream: state.hasUpstream, + hasRemote: state.hasRemote, + ...(probed ? { state: probed.state } : {}), + }), force); + } + + /** One background-fetch pass — see [fetchRemote]. Pushes `git:sync-state` + * only when the counts it produces actually moved. */ + function runGitAutofetchTick(runtime: CheckoutRuntime): void { + const prevSync = JSON.stringify(runtime.cachedGitSync); + trackGitRefresh( + runtime, + fetchRemote(runtime.checkout.path) + .then((fetched) => { + if (!fetched || runtime.disposed) return; + return refreshGitStatus(runtime).then(() => { + if (JSON.stringify(runtime.cachedGitSync) !== prevSync) sendGitSyncState(runtime); + }); + }) + .catch(() => {}), + ); + } + + /** Starts this checkout's background-fetch backstop — the periodic + * counterpart to [readSyncState]'s "as fresh as the last fetch" contract, + * matching every SCM client with a sync indicator (VS Code defaults to 3 + * minutes; kept the same here). Ticks once immediately: without that, a + * checkout that just connected would wait up to the full period before it + * could show a commit someone else pushed while this bridge was offline. */ + function startGitAutofetch(runtime: CheckoutRuntime): void { + runGitAutofetchTick(runtime); + runtime.gitAutofetchInterval = setInterval(() => runGitAutofetchTick(runtime), 180_000); + } + async function handleGitListBranches(runtime: CheckoutRuntime, projectId: string) { try { const catalog = await listLocalBranches(runtime.checkout.path); @@ -1766,6 +1921,67 @@ export async function buildAgentCore(opts: BuildAgentCoreOptions): Promise>; + try { + result = await withGitSyncLock(runtime.checkout.id, () => + op === "push" ? gitPush(runtime.checkout.path) : gitPull(runtime.checkout.path), + ); + } catch (err: any) { + sendFromRuntime(runtime, createMessage("git:sync-result", { + projectId, + op, + success: false, + branch: runtime.cachedGitSync.branch, + failureKind: "unknown" as const, + error: err?.message || String(err), + })); + return; + } + + sendFromRuntime(runtime, createMessage("git:sync-result", { + projectId, + op, + success: result.success, + branch: result.branch, + ...(result.remote ? { remote: result.remote } : {}), + ...(result.remoteBranch ? { remoteBranch: result.remoteBranch } : {}), + ...(result.summary ? { summary: result.summary } : {}), + ...(result.error ? { error: result.error } : {}), + ...(result.failureKind ? { failureKind: result.failureKind } : {}), + ...(result.command ? { command: result.command } : {}), + ...(result.stderr ? { stderr: result.stderr } : {}), + })); + + // Refreshed on BOTH outcomes, for the reason [handleGitDiscard] gives: a + // pull is several git invocations and a fetch that succeeded before the + // ff-only refusal has already moved `refs/remotes`, so even a failure + // changes the counts. A successful push moves nothing but `.git/`, which + // the watcher ignores — nothing else would ever correct the indicator. + await Promise.all([refreshGitBranch(runtime), refreshGitStatus(runtime)]); + sendGitStatus(runtime); + sendGitSyncState(runtime); + sendStatus(runtime); + } + + async function handleGitSyncStatus( + runtime: CheckoutRuntime, + projectId: string, + probeRemote: boolean, + ) { + await refreshGitStatus(runtime); + const state = runtime.cachedGitSync; + // Forced on every arm: this verb IS the app's hydrator, re-fired on every + // (re)establish, and `git:sync-state` is a replay type — so for an idle + // checkout the answer is byte-identical to the cached frame and the bus's + // dedup drops it before the asker sees it. Same reason [resyncState] + // forces its pair. + if (!probeRemote || !state.branch) { + sendGitSyncState(runtime, state, undefined, true); + return; + } + try { + // Bounded and non-prompting inside itself; a branch the remote cannot be + // asked about reports `unreachable`, which the app renders as nothing + // rather than as an error — the local counts beside it are still true. + const probed = await checkBranchAgainstRemote(runtime.checkout.path, state.branch); + sendGitSyncState(runtime, state, probed, true); + } catch { + sendGitSyncState(runtime, state, undefined, true); + } + } + async function handleGitDiscard( runtime: CheckoutRuntime, projectId: string, @@ -1861,6 +2174,55 @@ export async function buildAgentCore(opts: BuildAgentCoreOptions): Promise { const branch = runtime.cachedGitBranch; const files = JSON.stringify(runtime.cachedGitFiles); + const sync = JSON.stringify(runtime.cachedGitSync); trackGitRefresh( runtime, Promise.all([refreshGitBranch(runtime), refreshGitStatus(runtime)]) .then(() => { if (runtime.cachedGitBranch !== branch) sendStatus(runtime); if (JSON.stringify(runtime.cachedGitFiles) !== files) sendGitStatus(runtime); + // The backstop that catches a commit, fetch or push made OUTSIDE + // the app — those touch only `.git/`, which the watcher ignores. + if (JSON.stringify(runtime.cachedGitSync) !== sync) sendGitSyncState(runtime); }) .catch(() => {}), ); }, 10_000); + + startGitAutofetch(runtime); } /** Spawn the checkout's `services` block. Manual-start slots stay listed in @@ -2311,6 +2691,8 @@ export async function buildAgentCore(opts: BuildAgentCoreOptions): Promise { @@ -2887,6 +3270,9 @@ export async function buildAgentCore(opts: BuildAgentCoreOptions): Promise { sendStatus(); sendGitStatus(); + // Sent once here so the bus CACHES it for replay — see the matching + // comment on the checkout-runtime path above. + sendGitSyncState(mainRuntime); }) .catch(() => {}), ); @@ -2895,6 +3281,7 @@ export async function buildAgentCore(opts: BuildAgentCoreOptions): Promise { const prevBranch = mainRuntime.cachedGitBranch; const prevFiles = JSON.stringify(mainRuntime.cachedGitFiles); + const prevSync = JSON.stringify(mainRuntime.cachedGitSync); trackGitRefresh( mainRuntime, Promise.all([refreshGitBranch(mainRuntime), refreshGitStatus(mainRuntime)]) @@ -2903,11 +3290,18 @@ export async function buildAgentCore(opts: BuildAgentCoreOptions): Promise {}), ); }, 10_000); + startGitAutofetch(mainRuntime); + const fw = new FileWatcher( project, (msg: AbMessage, opts) => (opts?.force ? republishAb(msg) : sendAb(msg)), diff --git a/bridge/src/auth/credentials.ts b/bridge/src/auth/credentials.ts index 01abc936..9169c2ab 100644 --- a/bridge/src/auth/credentials.ts +++ b/bridge/src/auth/credentials.ts @@ -45,6 +45,14 @@ export const BootstrapPayloadSchema = z // replaced the app out from under. Opaque here — the host never interprets // it. Optional: a host started outside the app (CLI/tests) has no owner. ownerBuild: z.string().min(1).optional(), + // The user's crash/telemetry consent, read by the app from its own settings + // at the moment it spawned us — the SAME read that decides the app's own + // Sentry init, so the two halves of one install can never disagree. Consent + // is therefore fixed for the host's lifetime, exactly as it is for the app + // process (`initCrashReporting` wraps `runApp` and is never re-run); a + // toggle takes effect on the next spawn. Optional, and absence must resolve + // to OFF: a host started outside the app has nobody to have consented. + telemetryEnabled: z.boolean().optional(), }) .refine((p) => p.firstProject === undefined || p.firstProject.mode !== "remote" || p.machine !== undefined, { message: "firstProject.mode 'remote' requires a machine block", diff --git a/bridge/src/control-protocol.ts b/bridge/src/control-protocol.ts index 02652af7..80a3b422 100644 --- a/bridge/src/control-protocol.ts +++ b/bridge/src/control-protocol.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import type { AgentDescriptor } from "./protocol"; -import type { BranchRemoteStatus } from "./git-branches"; +import type { BranchRemoteStatus, StashEntry } from "./git-branches"; export const ControlRequestSchema = z.discriminatedUnion("type", [ z.object({ id: z.string().min(1), type: z.literal("project:list") }), @@ -41,6 +41,7 @@ export const ControlRequestSchema = z.discriminatedUnion("type", [ projectPath: z.string().min(1), branch: z.string().min(1), allowActiveSessions: z.boolean().optional(), + stashIfDirty: z.boolean().optional(), }), // Discloses a checkout's absolute path to the caller. Deliberately confined // to THIS plane: checkout paths are host-local (checkout-types.ts) and the @@ -124,6 +125,6 @@ export type ControlResponse = | { id: string; ok: true; type: "mobile-access:set"; enabled: boolean } | { id: string; ok: true; type: "git:branches"; isRepository: boolean; current: string | null; branches: string[]; worktreeSessionsSupported: boolean } | { id: string; ok: true; type: "git:remote-state"; status: BranchRemoteStatus } - | { id: string; ok: true; type: "git:checkout"; current: string } + | { id: string; ok: true; type: "git:checkout"; current: string; stashed?: StashEntry } | { id: string; ok: true; type: "checkout:path"; path: string } | { id: string; ok: false; error: { code: string; message: string } }; diff --git a/bridge/src/crash-reporting.ts b/bridge/src/crash-reporting.ts new file mode 100644 index 00000000..71d713b3 --- /dev/null +++ b/bridge/src/crash-reporting.ts @@ -0,0 +1,324 @@ +import * as Sentry from "@sentry/bun"; +import type { Breadcrumb, ErrorEvent, StackFrame } from "@sentry/bun"; +import { logger } from "./logger"; + +const log = logger.child({ component: "crash-reporting" }); + +/** Integrations dropped from the SDK defaults. Every one either reads user + * content off disk / off the wire, or bills a shutdown budgeted in ms: + * + * - `Console` turns each `console.*` line into a breadcrumb holding the line + * verbatim — for the bridge that is checkout paths, branch names, session ids. + * - `ContextLines` reads the source lines AROUND the crash off disk into + * `context_line`/`pre_context`/`post_context`. In a dev (uncompiled) host + * that is our own source; the field is a content leak either way. + * - `RequestData`, `Http`, `NodeFetch` and `BunServer` instrument the loopback + * api-server and every outbound fetch. Hook payloads (prompts, tool input) + * arrive as request BODIES on that server, so this is the worst exposure in + * the list; the URLs alone carry project ids. + * - `ProcessSession` posts a release-health session on exit, adding a network + * round-trip to a shutdown path that races a Store destage on Windows. + * - `Modules` walks up from `process.cwd()` for a `package.json` and ships its + * dependency map as `event.modules`. The host does not choose its own cwd — + * it inherits the spawning app's — so what that finds is not knowable from + * here, and it is disk I/O on the crash path for a field nothing reads. + * + * The two top-level handler integrations are NOT here. They are re-added below + * with their options pinned — see `TOP_LEVEL_HANDLER_INTEGRATIONS`. + * + * Names are matched against the SDK's own integration `name`s, so a rename + * upstream silently stops filtering. Exported so `crash-scrubber.test.ts` pins + * THIS set against the live defaults, rather than a copy of it that a new entry + * here would not reach. */ +export const EXCLUDED_INTEGRATIONS = new Set([ + "Console", + "ContextLines", + "RequestData", + "Http", + "NodeFetch", + "BunServer", + "ProcessSession", + "Modules", +]); + +/** + * `OnUncaughtException` and `OnUnhandledRejection`, re-added with their options + * stated rather than inherited. They replace the identically-named defaults + * (the SDK keys integrations by name, so exactly ONE listener of each is + * installed — not a duplicate pair). + * + * They own the CAPTURE on both top-level paths, and `index.ts` deliberately + * does not `captureBridgeError` there. That is the whole reason to keep them: + * they stamp `mechanism { type: "auto.node.onuncaughtexception", handled: false }`, + * which is what makes an event a CRASH rather than a handled error in the UI + * and in release health. A hand-rolled `captureException` reports the same + * fatal as `generic`/`handled: true` — measurably wrong, and wrong in exactly + * the dimension this instrumentation exists to answer. + * + * `exitEvenIfOtherHandlersAreRegistered: false` is the load-bearing option and + * is pinned here rather than inherited from the SDK's default. True would make + * the SDK `process.exit(1)` on its own, skipping `index.ts`'s teardown — the + * teardown that sweeps every PTY. Windows survives that (the kernel closes our + * job handles), but POSIX has no such backstop and every agent tree would be + * orphaned. Do not drop the option because the default currently agrees with it. + * + * The option is only half the contract, and the other half lives in `index.ts`: + * measured on 10.70, the SDK re-counts the OTHER `uncaughtException` listeners + * at CRASH TIME. With one of ours present it defers and we keep the exit; as + * the SOLE listener it takes the fatal path and exits regardless of this option. + * So the guarantee is "index.ts registers its handlers before a crash can + * matter", not "we configured the integration correctly". + */ +const TOP_LEVEL_HANDLER_INTEGRATIONS = [ + Sentry.onUncaughtExceptionIntegration({ exitEvenIfOtherHandlersAreRegistered: false }), + // `strict` re-raises the rejection as an uncaught exception; `warn` leaves the + // process to us, which is the only mode compatible with owning our own exit. + Sentry.onUnhandledRejectionIntegration({ mode: "warn" }), +]; + +/** Kept in lockstep with `_pathLike` in `app/lib/analytics/crash_reporting.dart` + * — the app and the bridge report into the same errex project, and a path that + * survives one scrubber but not the other is one leak wearing two faces. */ +const PATH_LIKE = /([a-zA-Z]:)?[\\/][^\s"]+/g; +const REDACTED_PATH = ""; + +/** How long a shutdown may wait on the transport. Bounded hard because this is + * appended to the END of teardown, after the drain that kills every PTY: + * nothing follows it but the exit, so the ceiling is the whole remaining + * budget and not a slice of a larger one. The app force-kills the host tree 3s + * into its own graceful ask, and on Windows that stretch races a Store + * destage. */ +const FLUSH_TIMEOUT_MS = 2_000; + +function redact(input: string): string { + return input.replace(PATH_LIKE, REDACTED_PATH); +} + +/** Nullable-preserving: a null/undefined field stays as it was. Guards on + * falsiness rather than `=== undefined` because these fields are typed + * optional but arrive off the wire — a `null` reaching `String.replace` throws, + * and `beforeSend` swallowing that throw drops the whole event. Mirrors + * `_redactNullable` in the app's scrubber, which gets this from `?.`. */ +function redactNullable(input: T): T { + return (input ? redact(input) : input) as T; +} + +/** Recursively redact strings inside arbitrary breadcrumb/extra data — nested + * objects and arrays, not just the top level. KEYS are redacted too: a map can + * be keyed by a path (`{"/home/me/x.ts": "opened"}`), which would otherwise + * travel verbatim. Non-string scalars pass through untouched. */ +function redactDeep(value: unknown): unknown { + if (typeof value === "string") return redact(value); + if (Array.isArray(value)) return value.map(redactDeep); + if (value !== null && typeof value === "object") { + // `fromEntries` rather than assignment into a literal: assigning a key + // named `__proto__` runs Object.prototype's setter instead of creating the + // property, so that entry — and only that one — would vanish silently. + return Object.fromEntries( + Object.entries(value as Record).map(([k, v]) => [redact(k), redactDeep(v)]), + ); + } + return value; +} + +/** Redacts the on-disk path and DELETES every source/local field. `context_line` + * and its neighbours are literal lines of whatever file the crash landed in and + * `vars` carries local values — both raw content, never needed for an anonymous + * report, so they are dropped rather than redacted. The `ContextLines` + * integration that populates them is already excluded above; this is the + * belt-and-braces half, and it also covers frames an SDK synthesized itself. */ +function scrubFrame(frame: StackFrame): void { + frame.filename = redactNullable(frame.filename); + frame.abs_path = redactNullable(frame.abs_path); + frame.module = redactNullable(frame.module); + delete frame.context_line; + delete frame.pre_context; + delete frame.post_context; + delete frame.vars; +} + +function scrubBreadcrumb(crumb: Breadcrumb): void { + crumb.message = redactNullable(crumb.message); + if (crumb.data) crumb.data = redactDeep(crumb.data) as Record; +} + +/** + * Strips user content (paths, project/file names, source snippets, the machine + * name) from an event before transmit. Defense-in-depth even though errex is + * self-hosted: the zero-knowledge promise is that we never hold readable user + * content, and the bridge is the process that actually touches the working tree. + * + * Mutates and returns [event] — the idiomatic `beforeSend` shape. + * + * Coverage is every field the SDK was MEASURED to populate for this process + * (`crash-scrubber.test.ts` records that probe): message/logentry, exception + * values and their frames, thread stacks, breadcrumbs, extra, transaction, and + * `server_name`, which arrives as the bare hostname — `logger.ts` drops pino's + * `hostname` binding for the same reason. `user`, `request` and `modules` are + * not populated at all with `sendDefaultPii: false` and the server and + * `Modules` integrations excluded, and are cleared anyway so re-enabling one + * cannot quietly start shipping them. `debug_meta` images keep their ids and + * addresses but lose `code_file`. + * `contexts` is deliberately NOT scrubbed: it is os/runtime/device-HARDWARE + * metadata with no name or path in it, and it is most of why a cross-platform + * bridge reports at all. Re-run the probe and revisit this list on an SDK major. + */ +export function scrubCrashEvent(event: ErrorEvent): ErrorEvent { + event.message = redactNullable(event.message); + if (event.logentry?.message) { + event.logentry.message = redact(event.logentry.message); + } + + for (const crumb of event.breadcrumbs ?? []) scrubBreadcrumb(crumb); + + for (const exception of event.exception?.values ?? []) { + exception.value = redactNullable(exception.value); + for (const frame of exception.stacktrace?.frames ?? []) scrubFrame(frame); + } + + // Threads carry the same frames as exceptions and are attached independently. + for (const thread of event.threads?.values ?? []) { + for (const frame of thread.stacktrace?.frames ?? []) scrubFrame(frame); + } + + event.transaction = redactNullable(event.transaction); + if (event.extra) event.extra = redactDeep(event.extra) as Record; + if (event.server_name !== undefined) event.server_name = ""; + // `code_file` is an absolute on-disk path to the binary/sourcemap; the rest of + // a debug image is addresses and ids. + for (const image of event.debug_meta?.images ?? []) { + if ("code_file" in image) image.code_file = redactNullable(image.code_file); + } + delete event.user; + delete event.request; + delete event.modules; + + return event; +} + +/** Set once by `initCrashReporting`. Everything below is a no-op while false, so + * a host with no consent (or no DSN) never touches the SDK after init. */ +let active = false; + +export interface CrashReportingOptions { + /** The user's telemetry consent, as the spawning app read it. Absent from a + * bootstrap payload sent by the CLI or a test, which is why the caller + * resolves that absence to `false` rather than this defaulting it. */ + enabled: boolean; + /** A build-time constant in a shipped bridge (`--define`, from CI's + * `SENTRY_DSN_BRIDGE`), ambient env otherwise — which is what keeps a dev + * host silent unless deliberately configured. Same shape as + * `LICENSE_API_URL`, but NOT the app's DSN: see `hasNumericProjectId`. */ + dsn: string; + /** The spawning app's `ownerBuild`, used verbatim — never parsed, per the + * contract in `credentials.ts`. It is the only per-build identifier the host + * has: `VERSION` is a static literal nobody bumps, so it is identical across + * every release, and CI builds this binary and the app from one commit. */ + release?: string; +} + +/** The JS SDKs accept a DSN only when its project id is NUMERIC, and errex + * issues SLUGS (`antgrid-app`) — so the DSN that works for the app is refused + * here, and the bridge needs its own. + * + * Checked BEFORE `Sentry.init` rather than after, because init installs the two + * top-level process handlers before it ever looks at the DSN, and nothing takes + * them off again: `Sentry.close()` disables the client but leaves the + * listeners. A client that can never transmit would therefore keep owning both + * fatal paths — the `warn`-mode rejection handler prints the raw reason, paths + * and all, to a stderr that is teed into `~/.antgrid/host.log`, and takes the + * rejection away from the runtime's own reporting. Install nothing instead. + * + * The SDK's own `validateDsn` is not the backstop it looks like: it opens with + * `if (!DEBUG_BUILD) return true`, and `DEBUG_BUILD` is only + * `typeof __SENTRY_DEBUG__ === "undefined" || __SENTRY_DEBUG__`. Defining that + * false at build time — a routine bundle-size flag — would make the SDK accept + * a slug id and post envelopes to a URL built from it. */ +function hasNumericProjectId(dsn: string): boolean { + const projectId = dsn.split(/[?#]/)[0]?.split("/").pop() ?? ""; + return /^\d+$/.test(projectId); +} + +/** Returns whether reporting actually came up — callers log it, nothing branches. */ +export function initCrashReporting(opts: CrashReportingOptions): boolean { + if (active) return true; + if (!opts.enabled || !opts.dsn) return false; + if (!hasNumericProjectId(opts.dsn)) { + log.error("crash reporting DISABLED: SENTRY_DSN carries a non-numeric project id, which the JS SDK refuses"); + return false; + } + + Sentry.init({ + dsn: opts.dsn, + ...(opts.release ? { release: opts.release } : {}), + sendDefaultPii: false, + // Pinned, not left to default, because `getClientOptions` fills each of + // these from the AMBIENT ENVIRONMENT when the option is undefined — and the + // host inherits its environment from whatever spawned it, which on a + // developer's machine is a shell nobody audited. `SENTRY_TRACES_SAMPLE_RATE` + // would start emitting transactions, which `beforeSend` does not see at all + // (that is `beforeSendTransaction`, a callback this file never sets); + // `SENTRY_SPOTLIGHT` would fan every envelope out to a second destination on + // loopback; `SENTRY_DEBUG` would narrate the SDK into a stderr that is teed + // into `~/.antgrid/host.log`. + tracesSampleRate: 0, + spotlight: false, + debug: false, + integrations: (defaults) => [ + ...defaults.filter((i) => !EXCLUDED_INTEGRATIONS.has(i.name)), + ...TOP_LEVEL_HANDLER_INTEGRATIONS, + ], + beforeSend: (event) => scrubCrashEvent(event), + }); + + // `Sentry.init` NEVER throws and NEVER returns a status: a DSN it refuses + // leaves a client with no transport, and every later `captureException` and + // `flush` then succeeds silently — `flush` resolves TRUE with nothing sent. + // So a refusal is never "reporting is off"; it is reporting that believes it + // is on. `hasNumericProjectId` above catches the one refusal we know of by + // name; this catches whatever the next one turns out to be. + if (!Sentry.getClient()?.getDsn()) { + log.error("crash reporting DISABLED: the SDK refused the DSN"); + return false; + } + + Sentry.setTag("component", "bridge"); + active = true; + return true; +} + +/** Record an error the bridge CAUGHT and decided to log — a failed shutdown, + * say. The two top-level handler paths do NOT come through here: the SDK's own + * integrations capture those, so they keep `handled: false` (see + * `TOP_LEVEL_HANDLER_INTEGRATIONS`), and `handled: true` here is the honest + * answer for an error we caught. [context] is the call site, not a message — it + * becomes a tag, so it must stay a fixed vocabulary and never carry user + * content. */ +export function captureBridgeError(err: unknown, context: string): void { + if (!active) return; + Sentry.captureException(err, { tags: { bridge_context: context } }); +} + +/** Drain the transport before exit, bounded so a dead network cannot hold up the + * exit. Called AFTER the sweep, so what a hung transport costs is a lost report + * and a later exit — never an unswept PTY. + * + * Unconditional while reporting is on, deliberately: most captures now happen + * inside the SDK's own top-level handlers, so nothing on this side can know + * whether the queue is empty — and it need not, since a flush with nothing to + * send measures ~15ms. Against a black-holed host it runs the full timeout: + * `_isClientDoneProcessing` counts 1ms TICKS rather than elapsed time, so the + * ceiling holds only as long as timers are not being coarsened. */ +export async function flushCrashReports(timeoutMs: number = FLUSH_TIMEOUT_MS): Promise { + if (!active) return; + try { + await Sentry.flush(timeoutMs); + } catch { + // A report we could not send must never change how the host exits. + } +} + +/** Test seam: `initCrashReporting` latches a module-level flag exactly once. */ +export function __resetCrashReportingForTest(): void { + active = false; +} diff --git a/bridge/src/entitlement.ts b/bridge/src/entitlement.ts index a7265b0c..367592f7 100644 --- a/bridge/src/entitlement.ts +++ b/bridge/src/entitlement.ts @@ -58,6 +58,15 @@ export interface TierClaim { export type TierClaimSource = () => TierClaim; +/** + * The two ways a capability is withheld — and the half of the verdict that is + * spoken to the user, so it crosses the wire (`handler:status.entitlement` in + * protocol.ts) and is mirrored by hand app-side. Widening it is a copy edit on + * three surfaces, which is the point: every reason here owes the reader a + * sentence saying what to do about it. + */ +export type EntitlementRefusal = "not_entitled" | "unreadable"; + /** * Why a capability was allowed or refused. `allowed` is the only thing a call * site branches on; `reason` exists so a log line can tell the two allowed @@ -65,13 +74,23 @@ export type TierClaimSource = () => TierClaim; * direction especially: "nobody wired this" (`unwired`) and "the server said * no" (`not_entitled`) land on OPPOSITE sides of `allowed`, so they cannot be * confused the way an absent value and a negative one otherwise would be. + * + * A union rather than one flat shape so `allowed: false` NARROWS `reason` to + * the refusals: what the bridge tells the app is derived from a verdict, and a + * flat type would let `unwired` — the allowed case — be reported as one. */ -export interface EntitlementVerdict { - readonly allowed: boolean; - readonly reason: "entitled" | "unwired" | "not_entitled" | "unreadable"; - /** The tier the claim carried, when it carried a recognised one. */ - readonly tier?: Tier; -} +export type EntitlementVerdict = + | { + readonly allowed: true; + readonly reason: "entitled" | "unwired"; + /** The tier the claim carried, when it carried a recognised one. */ + readonly tier?: Tier; + } + | { + readonly allowed: false; + readonly reason: EntitlementRefusal; + readonly tier?: Tier; + }; export type EntitlementReader = (capability: Capability) => EntitlementVerdict; diff --git a/bridge/src/file-watcher.ts b/bridge/src/file-watcher.ts index ecc2c0bd..822fbf87 100644 --- a/bridge/src/file-watcher.ts +++ b/bridge/src/file-watcher.ts @@ -1,5 +1,5 @@ import chokidar, { type FSWatcher } from "chokidar"; -import { relative, extname, basename, join, isAbsolute } from "node:path"; +import { relative, resolve, sep, extname, basename, join, isAbsolute } from "node:path"; import { statSync, watch as fsWatch, type FSWatcher as NodeFSWatcher } from "node:fs"; import { logger } from "./logger"; const log = logger.child({ component: "file-watcher" }); @@ -44,6 +44,10 @@ export class FileWatcher { removed: new Set(), }; private debounceTimer: ReturnType | null = null; + /** Set when the native recursive watcher reports a change with no path — + * see [startNativeRecursiveWatch] — so [flushBatch] falls back to a full + * resync instead of sending an incremental batch it knows is incomplete. */ + private needsFullResync = false; constructor( project: ProjectInfo, @@ -149,28 +153,7 @@ export class FileWatcher { this.nativeWatcher = fsWatch( this.projectRoot, { recursive: true, persistent: true }, - (_event, filename) => { - if (filename == null) return; - // Usually relative to projectRoot (String() also covers a Buffer if - // the platform yields one) — but Windows also delivers the ABSOLUTE - // watched root for events on the directory itself, so re-derive - // rather than trust it. - const raw = String(filename); - const rel = (isAbsolute(raw) ? relative(this.projectRoot, raw) : raw) - .replace(/\\/g, "/"); - // `ignore` THROWS on a path that isn't root-relative instead of - // answering, and this callback runs on a libuv event with no caller - // to catch it — an unhandled RangeError that takes the watcher down - // (the chokidar path guards the same way for the same reason). The - // root itself and anything above it are honestly "not ignored", but - // there is also nothing under them to report. - if (!rel || rel === "." || rel === ".." || rel.startsWith("../")) return; - // The recursive stream sees the whole tree (the OS can't prune at the - // subscription level); apply the same ignore rules chokidar's - // `ignored` would, so node_modules/build/etc. churn is dropped here. - if (this.ig.ignores(rel)) return; - this.onNativeChange(join(this.projectRoot, rel)); - }, + (_event, filename) => this.handleNativeEvent(filename), ); this.nativeWatcher.on("error", (err) => log.error("File watcher error: %s", err), @@ -188,6 +171,49 @@ export class FileWatcher { } } + /** + * One event off the native recursive watcher. + * + * A named method rather than the inline closure it used to be, so the + * buffer-overflow branch below is reachable from a test — driving it through + * a real overflow means provoking one from the OS, and the private field it + * sets can be assigned directly without the branch that sets it ever running. + */ + handleNativeEvent(filename: string | Buffer | null): void { + if (filename == null) { + // Windows' (and reportedly macOS's) recursive fs.watch reports exactly + // this — a change with no path — when its internal notification buffer + // overflows: a burst of filesystem activity (a new directory landing + // with many files in one go is enough, measured on Windows) drops the + // per-file events instead of queuing them, rather than raising an error. + // There is no path to diff here, so treat it as "something changed, + // scope unknown" and let flushBatch fall back to a full resync — + // otherwise some of the affected files never appear until the app's own + // pull-to-refresh forces a rebuild from disk. + this.needsFullResync = true; + this.scheduleBatch(); + return; + } + // Usually relative to projectRoot (String() also covers a Buffer if the + // platform yields one) — but Windows also delivers the ABSOLUTE watched + // root for events on the directory itself, so re-derive rather than trust + // it. + const raw = String(filename); + const rel = (isAbsolute(raw) ? relative(this.projectRoot, raw) : raw) + .replace(/\\/g, "/"); + // `ignore` THROWS on a path that isn't root-relative instead of answering, + // and this runs on a libuv event with no caller to catch it — an unhandled + // RangeError that takes the watcher down (the chokidar path guards the + // same way for the same reason). The root itself and anything above it are + // honestly "not ignored", but there is also nothing under them to report. + if (!rel || rel === "." || rel === ".." || rel.startsWith("../")) return; + // The recursive stream sees the whole tree (the OS can't prune at the + // subscription level); apply the same ignore rules chokidar's `ignored` + // would, so node_modules/build/etc. churn is dropped here. + if (this.ig.ignores(rel)) return; + this.onNativeChange(join(this.projectRoot, rel)); + } + // Route a raw recursive-watch hit through the existing pending-change maps. // The app upserts `added` and `modified` identically, so every still-present // path goes through the add path — no separate known-paths set needed. @@ -222,6 +248,48 @@ export class FileWatcher { ); } + /** Resolves a path a terminal program printed (an OSC 8 `file://` hyperlink + * target, absolute or already checkout-relative) against this checkout's + * root, and replies with the checkout-relative form the app's file tree + * understands. The app never learns the checkout's absolute root (see + * `docs/architecture.md` — the checkout path never crosses the session + * wire), so it cannot make this relative on its own; a null `relPath` + * covers both a path from outside this checkout and one that fails to + * resolve at all. Mirrors [readFile]'s own traversal guard. */ + handleResolvePathRequest(requestId: string, rawPath: string): void { + const absPath = resolve(this.projectRoot, rawPath); + const normalizedRoot = resolve(this.projectRoot); + // Case-folded on Windows, where the comparison is between two strings that + // came from different places: the root as the host spelled it, and a drive + // letter as a terminal program printed it. `path.resolve` preserves the + // case of both, so a `file:///c:/...` hyperlink against a `C:\...` root + // reads as outside the checkout and the Files tab silently ignores it. + // `relative()` one method away already folds, so only this test dissents. + const cmpPath = process.platform === "win32" ? absPath.toLowerCase() : absPath; + const cmpRoot = + process.platform === "win32" ? normalizedRoot.toLowerCase() : normalizedRoot; + const insideRoot = cmpPath === cmpRoot || cmpPath.startsWith(cmpRoot + sep); + let relPath: string | null = null; + let isDirectory = false; + if (insideRoot) { + relPath = + absPath === normalizedRoot ? "" : this.toRelPath(absPath); + try { + isDirectory = statSync(absPath).isDirectory(); + } catch { + // Doesn't exist (yet) — still a valid path to point the Files tab at. + } + } + this.sendMessage( + createMessage("file:resolve-path-result", { + projectId: this.projectId, + requestId, + relPath, + isDirectory, + }), + ); + } + /** Returns chokidar's close promise so a caller about to delete the watched * directory can wait the subscriptions out. Chokidar tears down one * `fs.watch()` per directory and resolves only when the last is closed; @@ -301,6 +369,9 @@ export class FileWatcher { private flushBatch(): void { this.debounceTimer = null; + const fullResync = this.needsFullResync; + this.needsFullResync = false; + const added = Array.from(this.pending.added.values()); const modified = Array.from(this.pending.modified.values()); const removed = Array.from(this.pending.removed); @@ -310,7 +381,7 @@ export class FileWatcher { this.pending.modified.clear(); this.pending.removed.clear(); - if (added.length === 0 && modified.length === 0 && removed.length === 0) return; + if (!fullResync && added.length === 0 && modified.length === 0 && removed.length === 0) return; // Ahead of the suppression gate below, and not gated by it: git status is // not a heavy-stream frame, and its cache is what a reconnecting app is @@ -321,6 +392,22 @@ export class FileWatcher { const seq = this.connState.bumpFileSeq(); if (this.connState.suppressed) { // Drop the update; the next tree-snapshot reply will reflect the current tree. + // A pending RESYNC is deferred rather than dropped: the flag was consumed + // above, and the delta stream it exists to correct is exactly what + // survives a suppression window — clearing it here would leave the app's + // base missing every add and remove from the overflow with nothing able + // to notice. + this.needsFullResync ||= fullResync; + return; + } + + if (fullResync) { + // The watcher lost track of what actually changed (see the null-filename + // branch above) — whatever named add/modify/remove this same tick also + // captured is incomplete at best, so send the real thing instead: the + // same full tree a manual pull-to-refresh would rebuild. + this.sendFullTree({ force: true }); + log.debug("tree resync for project %s — watcher reported an unnamed change", this.projectId); return; } diff --git a/bridge/src/git-branches.ts b/bridge/src/git-branches.ts index 1d8bbec1..57702890 100644 --- a/bridge/src/git-branches.ts +++ b/bridge/src/git-branches.ts @@ -7,7 +7,12 @@ export interface GitBranchCatalog { export class GitHelperError extends Error { constructor( - public readonly code: "NOT_GIT_REPOSITORY" | "UNKNOWN_BRANCH" | "CHECKOUT_FAILED", + public readonly code: + | "NOT_GIT_REPOSITORY" + | "UNKNOWN_BRANCH" + | "CHECKOUT_FAILED" + | "DIRTY_WORKTREE" + | "STASH_FAILED", message: string, ) { super(message); @@ -15,6 +20,70 @@ export class GitHelperError extends Error { } } +/** One `git stash` entry. `branch` is the branch HEAD pointed at when the + * stash was created, parsed off git's own reflog subject — stashes are a + * single list shared by the whole repository (every worktree included), so + * this is the only record of which branch a given entry belongs to. */ +export interface StashEntry { + /** e.g. `stash@{0}` — stable only until the NEXT push/pop/drop shifts the + * list, so callers must re-list rather than cache this across a mutation. */ + ref: string; + /** "" when the subject doesn't match either of git's own formats (a stash + * made with `--no-keep-index` on a detached HEAD, e.g.) — never guessed. */ + branch: string; + message: string; + /** Unix seconds. */ + createdAt: number; +} + +/** git's own reflog subject for a stash is either `WIP on : + * ` (the default, no `-m`) or `On : ` (ours, since + * every push here passes `-m`) — both are OUR format to parse, not git's to + * document further; there is no third form. */ +function parseStashSubject(subject: string): { branch: string; message: string } { + const match = /^(?:WIP on|On) ([^:]+): (.*)$/.exec(subject); + if (!match) return { branch: "", message: subject }; + return { branch: match[1]!, message: match[2]! }; +} + +/** Longest file list a dirty-worktree refusal spells out before summarizing — + * same shape as `unresolvedConflictError` in `git.ts`. */ +const NAMED_DIRTY_FILES_IN_ERROR = 3; + +/** Whether `stderr` is git's refusal to move HEAD over changes it would have + * to overwrite — a tracked edit or an untracked file in the way — as opposed + * to any other reason `git switch` can fail (a hook, a submodule, detached + * HEAD oddities). Both of git's own wordings ("...by checkout" for a plain + * switch, "...by merge" when the switch itself performs a merge) share this + * clause, so matching on it covers both without depending on which one fired. */ +function isDirtyWorktreeRefusal(stderr: string): boolean { + return stderr.includes("would be overwritten by"); +} + +/** The path list `git switch` prints directly under either "would be + * overwritten" header, one per line, each indented with a single tab — + * git's own format, not ours to construct. */ +function parseOverwrittenFiles(stderr: string): string[] { + return stderr + .split(/\r?\n/) + .filter((line) => line.startsWith("\t")) + .map((line) => line.slice(1)); +} + +/** User-facing refusal for `DIRTY_WORKTREE`, naming what is actually in the + * way — the raw git hint block ("Please commit your changes or stash them...") + * reads as a terminal message, not app copy, and says nothing about WHICH + * files. */ +function dirtyWorktreeError(branch: string, files: string[]): string { + if (files.length === 0) { + return `Switching to "${branch}" would overwrite uncommitted changes. Commit, stash, or discard them first.`; + } + const named = files.slice(0, NAMED_DIRTY_FILES_IN_ERROR).join(", "); + const rest = files.length - NAMED_DIRTY_FILES_IN_ERROR; + const list = rest > 0 ? `${named} and ${rest} more` : named; + return `Switching to "${branch}" would overwrite uncommitted changes in: ${list}. Commit, stash, or discard them first.`; +} + export async function listLocalBranches(projectPath: string): Promise { // Check if inside work tree const revParseProc = Bun.spawn(["git", "rev-parse", "--is-inside-work-tree"], { @@ -91,7 +160,16 @@ export async function listLocalBranches(projectPath: string): Promise { + opts?: { + /** On `DIRTY_WORKTREE`, stash the working tree (tracked + untracked, via + * `-u`) and retry the switch once, rather than refusing outright. The + * created stash is returned as `stashed` so the caller can surface a + * Restore/Discard affordance — nothing here pops it automatically, since + * the whole point is that the switch must not silently reapply changes + * that belong to the branch just left. */ + stashIfDirty?: boolean; + }, +): Promise<{ current: string; stashed?: StashEntry }> { const catalog = await listLocalBranches(projectPath); if (!catalog.isRepository) { throw new GitHelperError("NOT_GIT_REPOSITORY", "Not a Git repository"); @@ -122,23 +200,73 @@ export async function checkoutLocalBranch( return { current: branch }; } - const proc = Bun.spawn(["git", "switch", branch], { - cwd: projectPath, - stdout: "pipe", - stderr: "pipe", - }); - - const stderr = (await new Response(proc.stderr).text()).trim(); - const exitCode = await proc.exited; + const attemptSwitch = async (): Promise<{ dirty: string[] } | null> => { + // Through [runGit] for its `LC_ALL=C`: the two things read off this stderr + // — [isDirtyWorktreeRefusal] and [parseOverwrittenFiles] — are matches on + // git's own ENGLISH wording, so on a localized git a bare spawn reports + // every dirty-worktree refusal as CHECKOUT_FAILED and never offers the + // stash. + const { exitCode, stderr: rawStderr } = await runGit(projectPath, ["switch", branch]); + const stderr = rawStderr.trim(); + if (exitCode !== 0) { + if (known && isDirtyWorktreeRefusal(stderr)) { + return { dirty: parseOverwrittenFiles(stderr) }; + } + throw new GitHelperError( + known ? "CHECKOUT_FAILED" : "UNKNOWN_BRANCH", + stderr || `git switch ${branch} failed with exit code ${exitCode}`, + ); + } + return null; + }; - if (exitCode !== 0) { - throw new GitHelperError( - known ? "CHECKOUT_FAILED" : "UNKNOWN_BRANCH", - stderr || `git switch ${branch} failed with exit code ${exitCode}`, - ); + const dirty = await attemptSwitch(); + let stashed: StashEntry | undefined; + if (dirty) { + if (!opts?.stashIfDirty) { + throw new GitHelperError("DIRTY_WORKTREE", dirtyWorktreeError(branch, dirty.dirty)); + } + // `-u` covers untracked files too — the same set `dirtyWorktreeError` + // above would have named, since an untracked file in the way is exactly + // what `isDirtyWorktreeRefusal` also matches. + stashed = await stashPush(projectPath, `Before switching to ${branch}`); + // EVERY failure past this point owes the pop, not just a second dirty + // refusal: `attemptSwitch` THROWS for any other reason git can refuse (a + // hook, a submodule, an `index.lock`), and the verification below throws + // too — and on those paths the caller reports a checkout failure while the + // user's tracked AND untracked work sits in a stash the error never + // mentions. The tree is empty of it and nothing in the app lists it. + try { + const retried = await attemptSwitch(); + if (retried) { + // The stash didn't clear whatever git objected to — put it back rather + // than leaving the user's work stashed with the switch still refused, + // and report the ORIGINAL dirty files so the message still names + // something actionable. + await stashPopBestEffort(projectPath, stashed.ref); + throw new GitHelperError("DIRTY_WORKTREE", dirtyWorktreeError(branch, retried.dirty)); + } + await verifyCurrentBranch(projectPath, branch); + } catch (err) { + // The `retried` arm above already popped and is re-thrown untouched; + // everything else lands here with the stash still held. + if (!(err instanceof GitHelperError && err.code === "DIRTY_WORKTREE")) { + await stashPopBestEffort(projectPath, stashed.ref); + } + throw err; + } + return { current: branch, stashed }; } - // Re-verify current branch + await verifyCurrentBranch(projectPath, branch); + return { current: branch, stashed }; +} + +/** Confirms `git switch` actually moved HEAD. Separate so the stash-and-retry + * path above can run it INSIDE its rollback guard — a verification failure + * after a successful stash is one of the two ways the user's work was left + * stashed with only a "checkout failed" to explain it. */ +async function verifyCurrentBranch(projectPath: string, branch: string): Promise { const verifyProc = Bun.spawn(["git", "branch", "--show-current"], { cwd: projectPath, stdout: "pipe", @@ -150,8 +278,107 @@ export async function checkoutLocalBranch( if (verifyText !== branch) { throw new GitHelperError("CHECKOUT_FAILED", `Verification failed: expected branch '${branch}', got '${verifyText}'`); } +} - return { current: branch }; +/** Local (non-network) git in this module. Deliberately [runGitRemote] with no + * deadline rather than a bare spawn: its `LC_ALL=C` is what makes every prose + * matcher here — [parseStashSubject]'s `WIP on`/`On`, [isDirtyWorktreeRefusal]'s + * `would be overwritten by` — a fact rather than a guess about the user's + * locale, and `GIT_OPTIONAL_LOCKS=0` keeps a read from contending with the + * agent's own git for `index.lock`. */ +async function runGit( + cwd: string, + args: string[], +): Promise<{ exitCode: number; stdout: string; stderr: string }> { + return runGitRemote(cwd, args); +} + +/** Internal to [checkoutLocalBranch]'s stash-and-retry path only — every other + * caller stashes by passing `stashIfDirty`, so there is exactly one place a + * stash is created here and exactly one message format for + * [parseStashSubject] to read back. */ +async function stashPush(projectPath: string, message: string): Promise { + const { exitCode, stdout, stderr } = await runGit(projectPath, ["stash", "push", "-u", "-m", message]); + if (exitCode !== 0) { + throw new GitHelperError("STASH_FAILED", stderr.trim() || stdout.trim() || `git stash push exited ${exitCode}`); + } + const created = (await listStashes(projectPath))[0]; + if (!created) { + // "No local changes to save" exits 0 with nothing pushed — reachable only + // if the tree went clean between the DIRTY_WORKTREE refusal and here (a + // concurrent commit/discard), not something this function can diagnose. + throw new GitHelperError("STASH_FAILED", "git stash push reported success but created no stash"); + } + return created; +} + +/** Rollback path only, for when the retried switch fails anyway — swallows + * its own failure because the caller is already mid-throw over the ORIGINAL + * refusal, and a second, unrelated error here would bury it. Leaves the + * stash in place on failure, which is still recoverable from the Git panel. */ +async function stashPopBestEffort(projectPath: string, ref: string): Promise { + await runGit(projectPath, ["stash", "pop", ref]).catch(() => undefined); +} + +/** Every stash in the repository, most recent first — matches `git stash + * list`'s own order. Stashes are shared across every worktree of this + * repository (see [StashEntry]), so this is the same list regardless of + * which checkout `projectPath` names. */ +export async function listStashes(projectPath: string): Promise { + // \x1f (unit separator) rather than a printable delimiter: a stash message + // is free-form user/Antgrid text and could itself contain a tab or pipe. + const { exitCode, stdout } = await runGit(projectPath, [ + "stash", "list", "--format=%gd\x1f%gs\x1f%at", + ]); + if (exitCode !== 0) return []; + return stdout + .split(/\r?\n/) + .filter((line) => line.length > 0) + .map((line) => { + const [ref, subject, at] = line.split("\x1f"); + const { branch, message } = parseStashSubject(subject ?? ""); + return { ref: ref ?? "", branch, message, createdAt: Number(at) || 0 }; + }); +} + +/** The only shape a stash reference may take on its way to argv — exactly what + * [listStashes] reports (git's own `%gd`), which is the only place the app + * ever gets one. + * + * Same hazard [checkoutLocalBranch] refuses a leading `-` for, and reachable + * the same way: `parseMessageFast` validates the message TYPE alone on the + * encrypted/local hot path, so `git:stash-pop`/`-drop`'s Zod `ref` never runs + * and an arbitrary string arrives here POSITIONALLY. `git stash pop --index` + * pops `stash@{0}` — not the entry the user tapped — and restores the index + * with it; `git stash drop --help` opens git's help viewer, which under a + * non-interactive `Bun.spawn` never exits and hangs the handler forever. */ +const STASH_REF_RE = /^stash@\{\d{1,9}\}$/; + +function assertStashRef(ref: string): void { + if (!STASH_REF_RE.test(ref)) { + throw new GitHelperError("STASH_FAILED", `'${ref}' is not a stash reference`); + } +} + +/** Reapplies a stash and drops it on success — git's own `stash pop`, and the + * Restore affordance's whole meaning: "put it back", not "keep a copy too". + * A conflicting pop leaves the stash in the list, same as git itself, and is + * surfaced to the user as the ordinary working-tree conflict it now is + * rather than something this function tries to resolve or roll back. */ +export async function stashPop(projectPath: string, ref: string): Promise { + assertStashRef(ref); + const { exitCode, stdout, stderr } = await runGit(projectPath, ["stash", "pop", ref]); + if (exitCode !== 0) { + throw new GitHelperError("STASH_FAILED", stderr.trim() || stdout.trim() || `git stash pop ${ref} exited ${exitCode}`); + } +} + +export async function stashDrop(projectPath: string, ref: string): Promise { + assertStashRef(ref); + const { exitCode, stderr } = await runGit(projectPath, ["stash", "drop", ref]); + if (exitCode !== 0) { + throw new GitHelperError("STASH_FAILED", stderr.trim() || `git stash drop ${ref} exited ${exitCode}`); + } } /** @@ -198,7 +425,7 @@ const LS_REMOTE_TIMEOUT_MS = 6_000; * or a black-holed host. GIT_TERMINAL_PROMPT=0 turns the prompt into a failure * and the kill timer bounds the rest. Same shape as handler/snapshot.ts. */ -async function runGit( +export async function runGitRemote( cwd: string, args: string[], timeoutMs?: number, @@ -243,13 +470,13 @@ async function runGit( * reports which of the two it was, because a missing ref means "deleted" only * when config claimed one — a `.` remote (tracking a LOCAL branch) is a * fallback, not tracking. */ -async function resolvePushTarget( +export async function resolvePushTarget( projectPath: string, branch: string, ): Promise<{ remote: string; remoteBranch: string; tracked: boolean } | null> { const [remoteCfg, mergeCfg] = await Promise.all([ - runGit(projectPath, ["config", "--get", `branch.${branch}.remote`]), - runGit(projectPath, ["config", "--get", `branch.${branch}.merge`]), + runGitRemote(projectPath, ["config", "--get", `branch.${branch}.remote`]), + runGitRemote(projectPath, ["config", "--get", `branch.${branch}.merge`]), ]); const remote = remoteCfg.stdout.trim(); const merge = mergeCfg.stdout.trim(); @@ -261,7 +488,7 @@ async function resolvePushTarget( return { remote, remoteBranch, tracked: true }; } - const remotes = await runGit(projectPath, ["remote"]); + const remotes = await runGitRemote(projectPath, ["remote"]); const names = remotes.stdout.split(/\r?\n/).map((n) => n.trim()).filter(Boolean); if (names.length === 0) return null; return { remote: names.includes("origin") ? "origin" : names[0]!, remoteBranch: branch, tracked: false }; @@ -271,7 +498,7 @@ export async function checkBranchAgainstRemote( projectPath: string, branch: string, ): Promise { - const localRev = await runGit(projectPath, ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}^{commit}`]); + const localRev = await runGitRemote(projectPath, ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}^{commit}`]); const localSha = localRev.stdout.trim(); if (localRev.exitCode !== 0 || !localSha) { throw new GitHelperError("UNKNOWN_BRANCH", `Branch '${branch}' does not exist`); @@ -280,7 +507,7 @@ export async function checkBranchAgainstRemote( const target = await resolvePushTarget(projectPath, branch); if (!target) return { branch, state: "no-remote" }; - const ls = await runGit( + const ls = await runGitRemote( projectPath, ["ls-remote", "--heads", "--", target.remote, `refs/heads/${target.remoteBranch}`], LS_REMOTE_TIMEOUT_MS, @@ -306,10 +533,10 @@ export async function checkBranchAgainstRemote( // Counts need the remote commit as a local object. Right after a fetch it is // there; otherwise `differs` is the whole honest answer. - const have = await runGit(projectPath, ["cat-file", "-e", `${remoteSha}^{commit}`]); + const have = await runGitRemote(projectPath, ["cat-file", "-e", `${remoteSha}^{commit}`]); if (have.exitCode !== 0) return { ...base, state: "differs" }; - const counts = await runGit(projectPath, ["rev-list", "--left-right", "--count", `${remoteSha}...${localSha}`]); + const counts = await runGitRemote(projectPath, ["rev-list", "--left-right", "--count", `${remoteSha}...${localSha}`]); const [behindRaw, aheadRaw] = counts.stdout.trim().split(/\s+/); const behind = Number(behindRaw); const ahead = Number(aheadRaw); diff --git a/bridge/src/git-log.ts b/bridge/src/git-log.ts new file mode 100644 index 00000000..29e3799c --- /dev/null +++ b/bridge/src/git-log.ts @@ -0,0 +1,256 @@ +// bridge/src/git-log.ts +// Commit history: the paginated log the History tab scrolls through, and the +// per-commit file list + diff it drills into. Kept apart from `git.ts` +// (working-tree status/diff/commit) and `git-branches.ts` (branch catalog + +// checkout) — a third, read-only concern with its own small git-invocation +// helper, matching how those two modules are already split. + +async function runGit( + cwd: string, + args: string[], +): Promise<{ exitCode: number; stdout: string; stderr: string }> { + // core.quotepath=false: see git.ts's [runGit] — same non-ASCII-path reason. + const proc = Bun.spawn(["git", "-c", "core.quotepath=false", ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + const exitCode = await proc.exited; + return { exitCode, stdout, stderr }; +} + +export interface GitLogEntry { + sha: string; + shortSha: string; + subject: string; + authorName: string; + authorEmail: string; + /** ISO 8601, author date (not committer date) — what every other git UI + * sorts and labels by. */ + authorDate: string; +} + +/** Field/record separators for `git log --pretty=format:` — ASCII unit/record + * separators, control characters a commit subject cannot contain, so no + * escaping is needed the way path-quoting needs `-z` elsewhere in this file. */ +const LOG_FIELD_SEP = "\x1f"; +const LOG_RECORD_SEP = "\x1e"; + +/** + * One page of `git log`, newest first. `skip`/`limit` are a plain offset + * (git's own `--skip`/`-n`), not a commit-hash cursor: the History tab is + * read-only and each page is fetched once as the user scrolls, so the extra + * correctness a hash cursor buys against a mid-scroll rebase isn't worth the + * bridge tracking state for it. Requests `limit + 1` to answer [hasMore] + * without a second round trip. + * + * Both are clamped HERE and not left to the schema: `parseMessageFast` is what + * validates the encrypted/local hot path and it checks the message type alone, + * so `git:log`'s Zod bounds and defaults never run on a real inbound frame. An + * absent field would reach git as `--skip=undefined -nNaN`, and an unbounded + * one would buffer the whole repository's history into one string and one wire + * frame. + */ +export const MAX_LOG_PAGE = 500; + +export async function getGitLog( + cwd: string, + skip: number, + limit: number, +): Promise<{ commits: GitLogEntry[]; hasMore: boolean }> { + const safeSkip = Number.isFinite(skip) ? Math.max(0, Math.floor(skip)) : 0; + const safeLimit = Number.isFinite(limit) + ? Math.min(MAX_LOG_PAGE, Math.max(1, Math.floor(limit))) + : 50; + const format = ["%H", "%h", "%an", "%ae", "%aI", "%s"].join(LOG_FIELD_SEP); + const r = await runGit(cwd, [ + "log", + `--skip=${safeSkip}`, + `-n${safeLimit + 1}`, + `--pretty=format:${format}${LOG_RECORD_SEP}`, + ]); + // Non-zero here is almost always "no commits yet" (unborn HEAD) rather than + // a real failure — an empty page is the honest answer either way. + if (r.exitCode !== 0) return { commits: [], hasMore: false }; + + const records = r.stdout + .split(LOG_RECORD_SEP) + .map((rec) => (rec.startsWith("\n") ? rec.slice(1) : rec)) + .filter((rec) => rec.length > 0); + const hasMore = records.length > safeLimit; + const commits = records.slice(0, safeLimit).map((record) => { + const [sha, shortSha, authorName, authorEmail, authorDate, ...subjectParts] = + record.split(LOG_FIELD_SEP); + return { + sha: sha ?? "", + shortSha: shortSha ?? "", + authorName: authorName ?? "", + authorEmail: authorEmail ?? "", + authorDate: authorDate ?? "", + subject: subjectParts.join(LOG_FIELD_SEP), + }; + }); + return { commits, hasMore }; +} + +export type GitCommitFileStatus = "M" | "A" | "D" | "R"; + +export interface GitCommitFileEntry { + path: string; + status: GitCommitFileStatus; + /** Pre-rename path; set only when status is "R". */ + oldPath?: string; + additions: number; + deletions: number; +} + +/** Diff-tree flags shared by [getCommitFiles] and [getCommitFileDiff] — both + * must agree on which tree a commit is compared against, or the file list a + * user expands and the diff they then open could describe two different + * changes. `--root` diffs the very first commit against the empty tree + * instead of erroring for lack of a parent; `-m --first-parent` picks a + * merge's mainline (the branch that was actually checked out) rather than + * git's default of emitting nothing for a merge commit. */ +const COMMIT_DIFF_FLAGS = ["-M", "-r", "-m", "--first-parent", "--root", "--relative"]; + +/** A commit id as this module will hand it to git: hex, full or abbreviated. + * + * `parseMessageFast` is what the encrypted/local hot path validates inbound + * frames with, and it checks the message TYPE and nothing else — so the Zod + * `sha: z.string()` on `git:commit-files`/`git:commit-diff` never runs and a + * sha reaches here as an arbitrary string. `diff-tree` takes the whole common + * diff-option set, `--output=` included, and a sha is POSITIONAL: one + * starting with `-` is parsed as an option and writes a file outside the + * checkout. Same hazard `checkoutLocalBranch` refuses a leading `-` for, and + * the same shape `worktree-manager.ts` already gates a commit id on. */ +const COMMIT_SHA_RE = /^[0-9a-f]{4,64}$/i; + +/** Every git invocation here interpolates the sha positionally, so this is the + * one place it can be bounded. Callers report the empty answer they would get + * from an unknown commit — indistinguishable to the app, and correct: a sha + * git could not name is a commit that is not there. */ +function isCommitSha(sha: string): boolean { + return COMMIT_SHA_RE.test(sha); +} + +/** name-status -z: `\0\0` for a plain change, or + * `R\0\0\0` for a detected rename — same shape + * [parsePorcelain] documents in git.ts, minus the X/Y split (a commit has no + * index vs worktree). */ +function parseNameStatusZ(stdout: string): Map { + const out = new Map(); + const tokens = stdout.split("\0").filter((t) => t.length > 0); + for (let i = 0; i < tokens.length; i++) { + const code = tokens[i]![0]; + if (code === "R" || code === "C") { + const oldPath = tokens[++i]; + const newPath = tokens[++i]; + if (newPath !== undefined) out.set(newPath, { status: "R", oldPath }); + continue; + } + const path = tokens[++i]; + if (path === undefined) continue; + out.set(path, { status: code === "A" ? "A" : code === "D" ? "D" : "M" }); // folds "T" + } + return out; +} + +/** numstat -z, same record shape [getDiffStats] parses in git.ts (see its own + * doc for the empty-path/rename-pair case). */ +function parseNumstatZ(stdout: string): Map { + const out = new Map(); + const tokens = stdout.split("\0").filter((t) => t.length > 0); + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]!; + const firstTab = token.indexOf("\t"); + const secondTab = firstTab === -1 ? -1 : token.indexOf("\t", firstTab + 1); + if (firstTab === -1 || secondTab === -1) continue; + const addedStr = token.slice(0, firstTab); + const deletedStr = token.slice(firstTab + 1, secondTab); + const additions = addedStr === "-" ? 0 : parseInt(addedStr, 10); + const deletions = deletedStr === "-" ? 0 : parseInt(deletedStr, 10); + const path = token.slice(secondTab + 1); + if (path === "") { + const newPath = tokens[i + 2]; + i += 2; + if (newPath !== undefined) out.set(newPath, { additions, deletions }); + continue; + } + out.set(path, { additions, deletions }); + } + return out; +} + +/** Files one commit touched, combining `--name-status` (what changed) with + * `--numstat` (line counts) — one `diff-tree` invocation cannot report both + * at once, the same limitation [getDiffStats] works around for the working + * tree. */ +export async function getCommitFiles(cwd: string, sha: string): Promise { + if (!isCommitSha(sha)) return []; + const [nameStatus, numstat] = await Promise.all([ + runGit(cwd, ["diff-tree", "--no-commit-id", "--name-status", "-z", ...COMMIT_DIFF_FLAGS, sha]), + runGit(cwd, ["diff-tree", "--no-commit-id", "--numstat", "-z", ...COMMIT_DIFF_FLAGS, sha]), + ]); + if (nameStatus.exitCode !== 0) return []; + + const statuses = parseNameStatusZ(nameStatus.stdout); + const stats = numstat.exitCode === 0 ? parseNumstatZ(numstat.stdout) : new Map(); + + const entries: GitCommitFileEntry[] = []; + for (const [path, info] of statuses) { + const { additions, deletions } = stats.get(path) ?? { additions: 0, deletions: 0 }; + entries.push({ path, status: info.status, oldPath: info.oldPath, additions, deletions }); + } + return entries; +} + +/** One file's diff within a single commit, in the same [COMMIT_DIFF_FLAGS] + * scope [getCommitFiles] built its list from — the file list a user expands + * and the diff they then open must describe the same change. */ +export async function getCommitFileDiff( + cwd: string, + sha: string, + path: string, +): Promise<{ diff: string | null; additions: number; deletions: number }> { + if (!isCommitSha(sha)) return { diff: null, additions: 0, deletions: 0 }; + const r = await runGit(cwd, [ + "diff-tree", "-p", "--no-commit-id", ...COMMIT_DIFF_FLAGS, sha, "--", path, + ]); + if (r.exitCode !== 0) return { diff: null, additions: 0, deletions: 0 }; + + return { diff: r.stdout || null, ...countPatchLines(r.stdout) }; +} + +/** + * Added/removed line counts for one unified patch. + * + * Counts only INSIDE a hunk, because a leading `+`/`-` is not by itself enough + * to tell content from a header: a removed line whose own text is `-- foo` + * arrives as `--- foo`, indistinguishable from the `--- a/path` header by + * prefix alone, and an added `++counter;` (C++, Perl) arrives as `+++counter;`. + * Prefix tests therefore drop exactly the lines the languages that use those + * prefixes are full of. A hunk opens at `@@` and the next file's preamble + * closes it at `diff --git`, so everything a header can be lands outside. + * + * Shared rather than inlined because the same count is taken for the working + * tree in agent-core's `git:diff` handler, and the two must never answer + * differently for identical patch text — the Changes tab and the History tab + * show the number beside the very same diff. + */ +export function countPatchLines(patch: string): { additions: number; deletions: number } { + let additions = 0; + let deletions = 0; + let inHunk = false; + for (const line of patch.split("\n")) { + if (line.startsWith("diff --git ")) inHunk = false; + else if (line.startsWith("@@")) inHunk = true; + else if (!inHunk) continue; + else if (line.startsWith("+")) additions++; + else if (line.startsWith("-")) deletions++; + } + return { additions, deletions }; +} diff --git a/bridge/src/git-sync.ts b/bridge/src/git-sync.ts new file mode 100644 index 00000000..faabafed --- /dev/null +++ b/bridge/src/git-sync.ts @@ -0,0 +1,377 @@ +import { runGitRemote, resolvePushTarget } from "./git-branches"; + +/** + * Why a push or pull did not happen, in a vocabulary the app switches on for + * copy. Deliberately a closed set: the app must never re-parse git's prose, + * which is localized and reworded between versions — it forwards `stderr` + * verbatim to the agent and branches its own UI on this. + * + * `unknown` is the honest answer for anything unrecognized, INCLUDING a + * timeout. An app that meets a kind added by a newer bridge must read it as + * `unknown` rather than failing (see `GitSyncFailureKind.fromWire` in the Dart + * mirror), which is what lets this list grow without an app release. + */ +export type GitSyncFailureKind = + | "no-remote" + | "no-upstream" + | "ambiguous-remote" + | "not-fast-forward" + | "rejected" + | "diverged" + | "auth" + | "conflict" + | "dirty-tree" + | "detached" + | "unknown"; + +export interface GitSyncResult { + success: boolean; + op: "push" | "pull"; + /** Current branch, or null on a detached HEAD. */ + branch: string | null; + remote?: string; + remoteBranch?: string; + /** One line for a toast on success ("Pushed 3 commits to origin/main"). */ + summary?: string; + error?: string; + failureKind?: GitSyncFailureKind; + /** The git invocation as run, for the agent handoff. Never a shell string — + * argv joined for reading, since nothing re-executes it. */ + command?: string; + /** Git's own stderr, untouched. This is the half the agent actually needs. */ + stderr?: string; +} + +/** Local-only view of how this branch stands against its upstream REF. Cheap + * enough to recompute on every git-status refresh precisely because it asks + * no remote — see [readSyncState]. */ +export interface GitSyncState { + branch: string | null; + remote: string | null; + remoteBranch: string | null; + ahead: number; + behind: number; + hasUpstream: boolean; + hasRemote: boolean; +} + +export const EMPTY_SYNC_STATE: GitSyncState = { + branch: null, + remote: null, + remoteBranch: null, + ahead: 0, + behind: 0, + hasUpstream: false, + hasRemote: false, +}; + +// A transfer has no UI deadline the way `ls-remote` does — the user has pressed +// a button and expects to wait — so this is a wedge guard, not a responsiveness +// one: a hung transport must not hold the checkout runtime forever. Generous +// enough that a genuinely large first push over a slow link still completes. +const TRANSFER_TIMEOUT_MS = 120_000; + +/** + * Classify a failed push/pull from git's own output. + * + * Pure, and separately exported, because every one of these strings is a real + * one observed from a real git — a table test over them is the only way this + * stays correct across git versions, and it cannot be written against a + * function that also spawns processes. + * + * Order matters: a non-fast-forward rejection also contains the word + * "rejected", and an auth failure over https also mentions the remote. + */ +export function classifySyncFailure( + stderr: string, + exitCode: number, +): GitSyncFailureKind { + const s = stderr.toLowerCase(); + + if ( + s.includes("could not read username") + || s.includes("could not read password") + || s.includes("authentication failed") + || s.includes("permission denied (publickey") + || s.includes("terminal prompts disabled") + || s.includes("invalid username or token") + ) { + return "auth"; + } + // `pull --ff-only` on a branch that has its own commits. Git's wording has + // changed across versions ("Not possible to fast-forward" then + // "Need to specify how to reconcile divergent branches"), hence both. + if ( + s.includes("not possible to fast-forward") + || s.includes("divergent branches") + || s.includes("diverging branches") + ) { + return "diverged"; + } + if (s.includes("non-fast-forward") || s.includes("fetch first")) { + return "not-fast-forward"; + } + if (s.includes("would be overwritten by merge") || s.includes("local changes")) { + return "dirty-tree"; + } + if (s.includes("conflict")) return "conflict"; + // The third wording is `pull`'s, and it is the only one that path can + // produce: `gitPush` passes `-u` explicitly, so the first two are push's + // alone and this arm was unreachable from a pull on an untracked branch — + // which is exactly the branch the app's Publish affordance exists for. + if ( + s.includes("has no upstream branch") + || s.includes("no upstream configured") + || s.includes("no tracking information for the current branch") + ) { + return "no-upstream"; + } + // The URL sits between the two words ("repository 'https://…' not found"), + // so this cannot be a substring test. + if (s.includes("does not appear to be a git repository") || /repository .*not found/.test(s)) { + return "no-remote"; + } + if (s.includes("[rejected]") || s.includes("failed to push")) return "rejected"; + // 124 is the timeout sentinel [runGitRemote] returns; it is genuinely + // unclassifiable — a hung auth prompt and a black-holed host look identical. + if (exitCode === 124) return "unknown"; + return "unknown"; +} + +async function currentBranch(cwd: string): Promise { + const r = await runGitRemote(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]); + const name = r.stdout.trim(); + // `rev-parse --abbrev-ref` answers the literal "HEAD" when detached, which is + // not a branch name and must not be handed to `push`/`pull` as one. + if (r.exitCode !== 0 || !name || name === "HEAD") return null; + return name; +} + +/** + * Ahead/behind against the upstream REF (`refs/remotes/...`), not against the + * remote itself — deliberately the opposite trade-off from + * [checkBranchAgainstRemote], and for a different job. + * + * This one rides every `git status` refresh, so it must cost nothing and reach + * nothing: an `ls-remote` here would become one network round trip per checkout + * every 10 seconds on the backstop poll alone. The counts it reports are + * therefore as fresh as the last fetch — which is exactly the contract VS + * Code's own ↑↓ indicator has, and pulling is what refreshes it. When the app + * needs the truth it asks for a probe (`git:sync-status` with `probeRemote`), + * and that path uses [checkBranchAgainstRemote]. + */ +export async function readSyncState(cwd: string): Promise { + const branch = await currentBranch(cwd); + if (!branch) { + // A detached HEAD still has remotes; reporting `hasRemote` lets the app say + // "detached" rather than "no remote", which is a different fix. + const remotes = await runGitRemote(cwd, ["remote"]); + return { + ...EMPTY_SYNC_STATE, + hasRemote: remotes.exitCode === 0 && remotes.stdout.trim().length > 0, + }; + } + + const target = await resolvePushTarget(cwd, branch); + if (!target) return { ...EMPTY_SYNC_STATE, branch }; + + // `@{upstream}` resolves only for a branch with tracking config AND a present + // remote ref, which is precisely the condition for counts to mean anything. + // Its failure is the `hasUpstream: false` signal — never an error. + const counts = await runGitRemote(cwd, [ + "rev-list", "--left-right", "--count", `${branch}@{upstream}...${branch}`, + ]); + const base = { + branch, + remote: target.remote, + remoteBranch: target.remoteBranch, + hasRemote: true, + }; + if (counts.exitCode !== 0) { + return { ...base, ahead: 0, behind: 0, hasUpstream: false }; + } + + const [behindRaw, aheadRaw] = counts.stdout.trim().split(/\s+/); + const behind = Number(behindRaw); + const ahead = Number(aheadRaw); + if (!Number.isFinite(behind) || !Number.isFinite(ahead)) { + return { ...base, ahead: 0, behind: 0, hasUpstream: false }; + } + return { ...base, ahead, behind, hasUpstream: true }; +} + +// Runs unattended on a timer, not behind a user's tap — a slow or dark remote +// must give up quietly rather than hold the checkout runtime the way a +// pressed Push/Pull is allowed to. +const AUTOFETCH_TIMEOUT_MS = 20_000; + +/** + * Background, read-only `git fetch` for the tracked branch alone — updates + * `refs/remotes` so the next [readSyncState] sees a commit someone else + * pushed, without the user having to press Pull first. This is the periodic + * counterpart to [readSyncState]'s "as fresh as the last fetch" contract: it + * IS what keeps that fetch recent. Best-effort and silent on any failure (no + * remote, no upstream, offline, auth) — there is no user action to report a + * failure back to, only the counts this refreshes for. + */ +export async function fetchRemote(cwd: string): Promise { + const branch = await currentBranch(cwd); + if (!branch) return false; + const target = await resolvePushTarget(cwd, branch); + if (!target?.tracked) return false; + const res = await runGitRemote( + cwd, + ["fetch", target.remote, target.remoteBranch], + AUTOFETCH_TIMEOUT_MS, + ); + return res.exitCode === 0; +} + +function fail( + op: "push" | "pull", + branch: string | null, + kind: GitSyncFailureKind, + error: string, + extra: Partial = {}, +): GitSyncResult { + return { success: false, op, branch, failureKind: kind, error, ...extra }; +} + +/** + * Push the current branch, setting an upstream on a branch that has none. + * + * NEVER `--force` or `--force-with-lease`, under any failure. A force push is + * an unrecoverable action the Handler takes a §5.2 snapshot before allowing + * (`force_push` in `HandlerSnapshotWire`); it has no business behind a one-tap + * control that a phone can reach. A rejected push returns the rejection intact + * so the app can hand it to the agent, which reconciles it deliberately. + */ +export async function gitPush(cwd: string): Promise { + const branch = await currentBranch(cwd); + if (!branch) { + return fail("push", null, "detached", "HEAD is detached — check out a branch first"); + } + + const target = await resolvePushTarget(cwd, branch); + if (!target) return fail("push", branch, "no-remote", "This repository has no remote"); + + // `resolvePushTarget` falls back to same-name-on-origin for an untracked + // branch, which is only a safe guess when there is one obvious remote. With + // several and no `origin`, picking the first would publish the branch to + // whichever remote sorted first — a wrong destination the user cannot undo. + if (!target.tracked) { + const remotes = await runGitRemote(cwd, ["remote"]); + const names = remotes.stdout.split(/\r?\n/).map((n) => n.trim()).filter(Boolean); + if (names.length > 1 && !names.includes("origin")) { + return fail( + "push", + branch, + "ambiguous-remote", + `'${branch}' has no upstream and this repository has ${names.length} remotes`, + { remote: target.remote, remoteBranch: target.remoteBranch }, + ); + } + } + + const args = target.tracked + ? ["push"] + : ["push", "-u", target.remote, `${branch}:${target.remoteBranch}`]; + const res = await runGitRemote(cwd, args, TRANSFER_TIMEOUT_MS); + const base = { op: "push" as const, branch, remote: target.remote, remoteBranch: target.remoteBranch }; + const command = `git ${args.join(" ")}`; + + if (res.exitCode !== 0) { + const stderr = res.stderr.trim(); + return { + ...base, + success: false, + failureKind: classifySyncFailure(stderr, res.exitCode), + error: stderr || `git push exited ${res.exitCode}`, + command, + stderr, + }; + } + + // "Everything up-to-date" is git's own wording and arrives on STDERR, which + // is why success is decided by the exit code alone and this only picks copy. + const upToDate = res.stderr.includes("Everything up-to-date"); + return { + ...base, + success: true, + summary: upToDate + ? "Already up to date" + : `Pushed ${branch} to ${target.remote}/${target.remoteBranch}`, + command, + }; +} + +/** + * Fetch, then fast-forward only. + * + * `--ff-only` is the whole safety property: a diverged branch leaves HEAD, the + * index and the worktree byte-identical and reports `diverged`, instead of + * merging (a merge commit nobody asked for) or rebasing (a repo left mid-rebase + * with no UI able to finish it). Reconciling a diverged branch is exactly the + * judgement call the agent handoff exists for. + */ +export async function gitPull(cwd: string): Promise { + const branch = await currentBranch(cwd); + if (!branch) { + return fail("pull", null, "detached", "HEAD is detached — check out a branch first"); + } + + const target = await resolvePushTarget(cwd, branch); + if (!target) return fail("pull", branch, "no-remote", "This repository has no remote"); + + const base = { op: "pull" as const, branch, remote: target.remote, remoteBranch: target.remoteBranch }; + + // Checked BEFORE the fetch, not left to `pull` to refuse: git's own refusal + // names the files it would clobber only sometimes, and this is the one + // failure the user can act on without the agent (commit, or stash). + const unmerged = await runGitRemote(cwd, ["ls-files", "--unmerged"]); + if (unmerged.exitCode === 0 && unmerged.stdout.trim().length > 0) { + return fail("pull", branch, "conflict", "Resolve the merge conflicts in this checkout first", base); + } + + const fetchArgs = ["fetch", target.remote, target.remoteBranch]; + const fetched = await runGitRemote(cwd, fetchArgs, TRANSFER_TIMEOUT_MS); + if (fetched.exitCode !== 0) { + const stderr = fetched.stderr.trim(); + return { + ...base, + success: false, + failureKind: classifySyncFailure(stderr, fetched.exitCode), + error: stderr || `git fetch exited ${fetched.exitCode}`, + command: `git ${fetchArgs.join(" ")}`, + stderr, + }; + } + + const before = await runGitRemote(cwd, ["rev-parse", "HEAD"]); + const pullArgs = ["pull", "--ff-only"]; + const res = await runGitRemote(cwd, pullArgs, TRANSFER_TIMEOUT_MS); + const command = `git ${pullArgs.join(" ")}`; + + if (res.exitCode !== 0) { + const stderr = res.stderr.trim(); + return { + ...base, + success: false, + failureKind: classifySyncFailure(stderr, res.exitCode), + error: stderr || `git pull exited ${res.exitCode}`, + command, + stderr, + }; + } + + const after = await runGitRemote(cwd, ["rev-parse", "HEAD"]); + const moved = before.stdout.trim() !== after.stdout.trim(); + return { + ...base, + success: true, + summary: moved + ? `Updated ${branch} from ${target.remote}/${target.remoteBranch}` + : "Already up to date", + command, + }; +} diff --git a/bridge/src/git.ts b/bridge/src/git.ts index dee50f3b..72f47787 100644 --- a/bridge/src/git.ts +++ b/bridge/src/git.ts @@ -410,7 +410,12 @@ async function readPorcelain( cwd: string, ): Promise | null> { const [status, prefix] = await Promise.all([ - runGit(cwd, ["status", "--porcelain=v1", "-z"]), + // `--untracked-files=all`: git's default collapses a wholly-untracked + // directory into ONE entry with a trailing slash instead of walking into + // it, so a brand-new folder's files never got their own status/diff/line + // count — clicking one in the tree opened nothing, and staging the + // collapsed entry was the only way to make the individual files appear. + runGit(cwd, ["status", "--porcelain=v1", "--untracked-files=all", "-z"]), runGit(cwd, ["rev-parse", "--show-prefix"]), ]); if (status.exitCode !== 0) return null; diff --git a/bridge/src/handler/authorization.ts b/bridge/src/handler/authorization.ts index d644e767..e042e143 100644 --- a/bridge/src/handler/authorization.ts +++ b/bridge/src/handler/authorization.ts @@ -1,11 +1,11 @@ // bridge/src/handler/authorization.ts -// Instruction-scoped authorization. A lift is derived ONLY from the text a -// human typed into `handler:instruct` — the PA bar and the preset chips both funnel -// through `HandlerEngine.instruct`, and that method is the single feed point. Never -// the transcript, never judge/Assistant output: those are attacker-influenced, and an -// agent that could authorize itself by writing "the user approved this" into its own -// output would turn the advisory floor into decoration. +// Instruction-scoped authorization. A lift is derived ONLY from the text a human +// typed into `handler:instruct` — the arm sheet's composer and the backlog drawer's +// both funnel through `HandlerEngine.instruct`, and that method is the single feed +// point. Never the transcript, never judge/Assistant output: those are attacker- +// influenced, and an agent that could authorize itself by writing "the user approved +// this" into its own output would turn the advisory floor into decoration. // // Two grades, because "the user named this operation" and "the user named this // target" are different claims: diff --git a/bridge/src/handler/decision.ts b/bridge/src/handler/decision.ts index d80aec05..b4f3a276 100644 --- a/bridge/src/handler/decision.ts +++ b/bridge/src/handler/decision.ts @@ -6,6 +6,32 @@ import type { CapCommand } from "../structured/chat-session"; import { ItemTransitionSchema } from "./backlog"; import { extractJsonObject } from "./json-extract"; import { MAX_REPLY_CHARS } from "./reply-shape"; +import type { HandlerPersonality } from "../protocol"; + +// What a session judges under when the user has never picked a posture. The +// cautious one on purpose: an unattended supervisor that guesses wrong costs +// more than one that asks, and every other preset is something the user opted +// into knowingly. +export const DEFAULT_PERSONALITY: HandlerPersonality = "watchdog"; + +// Each preset states WHERE THE LINE SITS and what `notify` should read like, +// and nothing else. Two properties every line here has to keep: +// +// It is subordinate to the RULES section it is printed under. `autopilot` +// widens what counts as handleable; it does NOT lower the confidence floor, and +// no preset may read as permission to make progress instead of escalating. +// +// It says nothing about `transitions`. Evidence is the anti-inflation guard, +// and a posture that could soften it would let the confident presets close +// items on belief. +export const PERSONALITY_RULES: Record = { + watchdog: + "Escalate freely. Handle only what is unambiguous — a question with one defensible answer, or a step the goal plainly already authorises. Where two readings of the situation are both reasonable, that is the user's call, not yours. Keep `notify` short and factual.", + closer: + "Handle what the session itself settles; escalate genuine ambiguity. If the goal, the backlog or the RECENT CONTEXT answers the question, answer it rather than waking the user. If answering it needs something none of them contain, escalate. `notify` says what you did and why, in a sentence or two.", + autopilot: + "Handle wherever you can; escalate only where you must. Treat the goal as standing authority for the steps it plainly implies, and prefer answering the agent over parking the work. This widens what counts as handleable — it does not lower the confidence floor: an answer you are not confident in is still an escalation, however routine it looks. Keep `notify` brief.", +}; export const HandlerDecisionSchema = z.object({ decision: z.enum(["continue", "handle", "escalate"]), @@ -79,6 +105,10 @@ export function buildDecidePrompt(opts: { // Non-empty or absent: an empty catalog is indistinguishable from a failed // or not-yet-landed discovery, so it is never announced as a complete set. commands?: CapCommand[]; + // Absent = DEFAULT_PERSONALITY. Resolved rather than defaulted at the caller + // so a prompt built for a test, or by a future call site, still carries a + // posture — the judge is never asked to decide without one. + personality?: HandlerPersonality; }): string { return [ opts.agentTool @@ -124,6 +154,13 @@ export function buildDecidePrompt(opts: { `- \`reply\` is free text typed at the agent and submitted as ONE line, under ${MAX_REPLY_CHARS} characters. Write one line: a line break would submit early, so any you write are collapsed to spaces before sending.`, "- `action` with `kind: \"slash_command\"` types a command at the agent instead. `value` is `\"/verb\"` or `\"/verb \"` — the verb is a single token with no spaces and no further `/`. The whole value is ONE line of command, verb and arguments only, whitespace inside it collapsed to spaces before sending; it carries no prose. Put what you need to explain in `reason`, which the user reads, and if the agent itself must be told something first, send that as `reply` this pass and the command on the next.", "- Set either `reply` or `action`, never both. A decision carrying both is refused and reaches the agent as nothing.", + "", + // Printed AFTER the whole rules list, never inside it: the two rules above — + // escalating trumps progress, low confidence escalates — bind every preset, + // and a posture stated among them would read as one more rule of equal + // standing rather than as something they frame. + "POSTURE — where your line between handling and escalating sits, and how `notify` reads. It never relaxes the rules above, and it never changes what a transition must cite:", + `- ${PERSONALITY_RULES[opts.personality ?? DEFAULT_PERSONALITY]}`, // The point of turning the floor advisory is that the Assistant sees // which of its own proposals were dangerous. Stating that these are its past // replies, not the agent's commands, is what makes them actionable. diff --git a/bridge/src/handler/engine.ts b/bridge/src/handler/engine.ts index 47346107..fa8392e5 100644 --- a/bridge/src/handler/engine.ts +++ b/bridge/src/handler/engine.ts @@ -1,6 +1,11 @@ // bridge/src/handler/engine.ts import { createHash } from "node:crypto"; -import { createMessage, type AbMessage } from "../protocol"; +import { + createMessage, + type AbMessage, + type HandlerEntitlement, + type HandlerPersonality, +} from "../protocol"; import { classifyDestructive, describeWarning, type FloorWarning } from "./destructive-floor"; import { authorizeInstruction, createAuthorization, partitionWarnings, @@ -35,7 +40,7 @@ import type { CapCommand } from "../structured/chat-session"; import type { SessionAdapter } from "./session-adapter"; import { handlerObservable, judgeCapable } from "../agents/registry"; import { createEntitlementReader, type EntitlementReader } from "../entitlement"; -import { type HandlerDecision } from "./decision"; +import { type HandlerDecision, DEFAULT_PERSONALITY } from "./decision"; import { LIMIT_FALLBACK_MS, LIMIT_PARK_CEILING, MIN_PARK_MS, TRANSIENT_CEILING, transientBackoffMs, defaultSchedule, TimerRegistry, type LifecycleDeps, @@ -506,6 +511,11 @@ interface ArmedSession { escalations: OpenEscalation[]; judgeTool?: string; judgeModel?: string; + // Absent until the user picks one. Resolved to DEFAULT_PERSONALITY at the two + // points that consume it — the status emit and the decide prompt — rather than + // defaulted here, so "never chosen" stays distinguishable on disk from a + // session the user deliberately set back to the default preset. + personality?: HandlerPersonality; parkKind?: "limit" | "outage"; parkedUntil?: number; selfResuming?: boolean; @@ -673,9 +683,10 @@ export class HandlerEngine { private entitledForHandler(terminalId: string, at: "arm" | "event"): boolean { const verdict = this.entitlement("handler"); if (verdict.allowed) return true; - // The log line is the whole user-visible signal by design: the refusal - // lands in the not-armed state the app already renders, and Handler carries - // no upgrade path on either end of the wire. + // Logged for the machine's owner, who is the only reader who can see a + // tier claim go wrong. What the USER is told rides the status frame + // instead (see entitlementForApp) — a warn line on a desktop is no answer + // for a shield pressed on a phone. log.warn( "handler %s refused for %s: entitlement %s (tier=%s)", at, terminalId, verdict.reason, verdict.tier ?? "unknown", @@ -683,6 +694,24 @@ export class HandlerEngine { return false; } + /** + * The same verdict, shaped for the app — null whenever Handler is available, + * so a status frame carries this key only while the answer is "no". + * + * Derived on every emit rather than latched at the refusal: the claim is a + * thunk over a token re-minted roughly hourly, so an account that upgrades + * (or a machine whose credentials come back) is entitled from the next frame + * with nothing to clear. The app is told once and the shield stops gating — + * which is why nothing here is remembered between emits. + */ + private entitlementForApp(): HandlerEntitlement | null { + const verdict = this.entitlement("handler"); + if (verdict.allowed) return null; + return verdict.tier === undefined + ? { reason: verdict.reason } + : { reason: verdict.reason, tier: verdict.tier }; + } + // Judge choice application, shared by fresh-arm and edit-arm. Fields arrive // through HandlerConfigureWire (typed string|undefined), but the VALUES are // still untrusted: '' = clear to default, an unknown tool is ignored @@ -698,6 +727,16 @@ export class HandlerEngine { if (p.judgeModel !== undefined) s.judgeModel = p.judgeModel.trim() || undefined; } + // Absent leaves the stored posture alone, the same rule applyJudgeChoice + // follows. Zod has already bounded the value to the three presets, so unlike + // judgeTool there is nothing further to validate here. + private applyPersonality( + s: { personality?: HandlerPersonality }, + p: { personality?: HandlerPersonality }, + ): void { + if (p.personality !== undefined) s.personality = p.personality; + } + // The session's stored judge: the live armed session if one exists, else the // on-disk record (a disarmed session keeps its pick for the next arm). // Callers that already loaded the record pass it as `rec` (null = "loaded, @@ -776,7 +815,7 @@ export class HandlerEngine { this.saveSession({ version: 2, terminalId, armed, suspended, goal: s.goal, backlog: s.backlog, armedAt: s.armedAt, escalations: s.escalations, - judgeTool: s.judgeTool, judgeModel: s.judgeModel, + judgeTool: s.judgeTool, judgeModel: s.judgeModel, personality: s.personality, parkKind: s.parkKind, parkedUntil: s.parkedUntil, transientFailures: s.transientFailures, parkAwaitingJudge: s.parkAwaitingJudge, }); @@ -784,7 +823,7 @@ export class HandlerEngine { arm(p: { terminalId: string; goal?: string; backlog?: InstructionItem[]; - judgeTool?: string; judgeModel?: string; + judgeTool?: string; judgeModel?: string; personality?: HandlerPersonality; }): void { // Entitlement first, ahead of every side effect below — the backlog clamp // records an activity row, and a refused arm must leave nothing behind. @@ -797,9 +836,10 @@ export class HandlerEngine { if (!this.entitledForHandler(p.terminalId, "arm")) { // Refuse WITHOUT disarming, exactly as a malformed `handler:configure` // does (agent-core.ts): a live session must not be torn down by a request - // that failed to replace it. Re-emit status so the sender's UI resyncs to - // the state that actually holds — which, for a slot that was never armed, - // is the ordinary not-armed state every layer already renders. + // that failed to replace it. The re-emit is what ANSWERS the sender: the + // frame carries the refusal (entitlementForApp), so an app whose cached + // verdict was stale learns the real one within a round trip instead of + // waiting out its arm-confirmation window on a state that never moved. this.emitStatus(); return; } @@ -838,6 +878,7 @@ export class HandlerEngine { // judged even if the agent has not moved. existing.lastJudgedContextHash = undefined; this.applyJudgeChoice(existing, p); + this.applyPersonality(existing, p); this.persist(p.terminalId, existing, true); // Only when the goal actually moved: `handler:configure` is also the // backlog-edit and judge-pick path (see updateBacklog in the app), and a @@ -889,6 +930,9 @@ export class HandlerEngine { // pick), then let an explicit choice on this arm override it. judgeTool: stored.tool, judgeModel: stored.model, + // Off the RECORD, not off `resumed`: a deliberate disarm keeps the pick + // for the next arm, exactly as the judge fields above do. + personality: rec?.personality, transientFailures: resumed?.transientFailures ?? 0, limitParks: 0, floorWarnings: [], @@ -898,6 +942,7 @@ export class HandlerEngine { auth: createAuthorization(), }; this.applyJudgeChoice(s, p); + this.applyPersonality(s, p); this.sessions.set(p.terminalId, s); this.persist(p.terminalId, s, true); // "armed" either way: nothing is edited on this path — the goal is whatever the @@ -1691,6 +1736,7 @@ export class HandlerEngine { const runDecisionFn = this.deps.runDecisionFn ?? defaultRunDecision; decision = await runDecisionFn({ tool: s.judgeTool ?? tool, model: s.judgeModel, goal: s.goal, + personality: s.personality ?? DEFAULT_PERSONALITY, backlogText: renderBacklog(s.backlog), context: ctx.text, transcriptPath: ctx.transcriptPath ?? transcriptPath, cwd: this.deps.projectPath(evt.terminalId), @@ -2521,8 +2567,13 @@ export class HandlerEngine { // Re-derived on every emit rather than frozen at arm time: a slot's mode // and its judge pick both change under a live arm. observability: this.observabilityFor(terminalId), + // Resolved, never the raw field: the app renders the picker off this, and + // an absent value would leave it showing nothing while the judge runs + // under a posture all the same. + personality: s.personality ?? DEFAULT_PERSONALITY, })); const wrapUps = this.wrapUps(); + const entitlement = this.entitlementForApp(); this.deps.sendAb(createMessage("handler:status", { projectId: this.deps.projectId, // What an absent per-session judge resolves to for PTY slots — lets the @@ -2536,6 +2587,11 @@ export class HandlerEngine { // Optional and appended LAST (see HandlerWrapUpWire): absent and [] mean the // same thing, so a project that has never wrapped up sends neither. ...(wrapUps.length ? { wrapUps: wrapUps.map(wrapUpWire) } : {}), + // Omitted whenever Handler is available, so the key's presence IS the + // refusal. Every emit carries it, not just the one arm() raises on its + // way out: the shield the user has yet to press is the surface that most + // needs to know, and it is on screen long before any arm. + ...(entitlement ? { entitlement } : {}), })); } } diff --git a/bridge/src/handler/judge.ts b/bridge/src/handler/judge.ts index ef69534e..08d0376c 100644 --- a/bridge/src/handler/judge.ts +++ b/bridge/src/handler/judge.ts @@ -6,6 +6,7 @@ import { type HandlerDecision, } from "./decision"; import type { InstructionItem } from "./backlog"; +import type { HandlerPersonality } from "../protocol"; import { buildExtractPrompt, parseExtractionOutput, type ExtractionResult } from "./extract"; // Eval-only judge override (Task 16's e2e harness): the spawned agent process can't @@ -117,6 +118,7 @@ export async function runDecision(opts: { evidenceRejections?: string[]; agentTool?: string; commands?: CapCommand[]; + personality?: HandlerPersonality; retryIfShape?: (decision: HandlerDecision) => string | null; onTimeout?: () => void; }): Promise { @@ -128,6 +130,9 @@ export async function runDecision(opts: { goal: opts.goal, backlogText: opts.backlogText, context: opts.context, transcriptPath: path, floorWarnings: opts.floorWarnings, evidenceRejections: opts.evidenceRejections, agentTool: opts.agentTool, commands: opts.commands, + // The retry legs below append to this prompt rather than rebuilding one, + // so the posture rides through them with nothing further to do. + personality: opts.personality, }), parse: (stdout) => { const r = parseDecisionFromOutput(stdout); diff --git a/bridge/src/handler/session-store.ts b/bridge/src/handler/session-store.ts index 002059e3..dad966c8 100644 --- a/bridge/src/handler/session-store.ts +++ b/bridge/src/handler/session-store.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, chmodSync } from "node:fs"; import { join } from "node:path"; import { BacklogSchema } from "./backlog"; +import { HandlerPersonalitySchema } from "../protocol"; // One tap-to-answer option on a quick-choice escalation. `text` is sent as // the USER's own reply through the ordinary reply transport, so it must be @@ -109,6 +110,10 @@ export const HandlerSessionRecordSchema = z.object({ // model). judgeTool: z.string().optional(), judgeModel: z.string().optional(), + // Per-session posture. Optional rather than defaulted so a record written + // before this field parses as itself — `version` stays 2, and the engine + // resolves the absent case to the preset a fresh session gets. + personality: HandlerPersonalitySchema.optional(), // Park state, so a bridge restart mid-park strands nothing. Optional because // an unparked session genuinely has none. parkKind: z.enum(["limit", "outage"]).optional(), diff --git a/bridge/src/host-server.ts b/bridge/src/host-server.ts index 70c64aae..037c376f 100644 --- a/bridge/src/host-server.ts +++ b/bridge/src/host-server.ts @@ -78,6 +78,7 @@ const GitCheckoutParams = z.object({ projectId: z.string(), branch: z.string().min(1), allowActiveSessions: z.boolean().optional(), + stashIfDirty: z.boolean().optional(), }); /** Desktop warm-core cap (mirrors the app's kWarmCapLocal). The host runs on a @@ -987,7 +988,7 @@ export class HostServer { error: { code: "E_BAD_PARAMS", message: parsed.error.issues.map((i) => i.message).join("; ") }, }); } - const { projectId, branch, allowActiveSessions } = parsed.data; + const { projectId, branch, allowActiveSessions, stashIfDirty } = parsed.data; if (!isSafeProjectId(projectId)) { return createMessage("response", { requestId: req.requestId, @@ -1045,12 +1046,12 @@ export class HostServer { } } - const res = await checkoutLocalBranch(seen.path, branch); + const res = await checkoutLocalBranch(seen.path, branch, { stashIfDirty }); await this.refreshWarmGitState(projectId, seen.path); return createMessage("response", { requestId: req.requestId, ok: true, - result: { current: res.current }, + result: { current: res.current, stashed: res.stashed }, }); } catch (err: any) { return createMessage("response", { @@ -1367,9 +1368,9 @@ export class HostServer { } } - const res = await checkoutLocalBranch(req.projectPath, req.branch); + const res = await checkoutLocalBranch(req.projectPath, req.branch, { stashIfDirty: req.stashIfDirty }); await this.refreshWarmGitState(req.projectId, req.projectPath); - return { id: req.id, ok: true, type: "git:checkout", current: res.current }; + return { id: req.id, ok: true, type: "git:checkout", current: res.current, stashed: res.stashed }; } catch (err: any) { return { id: req.id, diff --git a/bridge/src/index.ts b/bridge/src/index.ts index fc873e2a..1b132e70 100644 --- a/bridge/src/index.ts +++ b/bridge/src/index.ts @@ -11,6 +11,7 @@ import { resolveAbDir } from "./antgrid-dir"; import { startOwnerWatchdog } from "./owner-watchdog"; import { augmentHostPath } from "./host-path"; import { runHookInvocation } from "./hook-runner"; +import { initCrashReporting, captureBridgeError, flushCrashReports } from "./crash-reporting"; // Component-tagged child for this module's own lifecycle logs. const log = logger.child({ component: "bridge" }); @@ -60,6 +61,13 @@ program .argument("") .argument("[payload]") .action(async (agent: string, event: string, payload?: string) => { + // Deliberately NOT crash-reported. A hook is spawned by the agent CLI, not + // by the app, so it is handed no bootstrap payload and there is no consent + // to act on — and the two costs land on a path the agent blocks on for + // every tool use: SDK init on entry, and a transport flush before an exit + // that is otherwise immediate. The field failure this would seem to catch + // (a hook that never runs at all — see the MSIX `` note in + // CLAUDE.md) is a CreateProcess denial, which no in-process SDK can observe. await runHookInvocation({ agent, event, payload }); // Exit explicitly: hooks are advisory and must never linger. An agent that // holds this process's stdin open (copilot does) would otherwise keep the @@ -103,6 +111,20 @@ program process.exit(64); // EX_USAGE } + // First thing after the payload, because the payload is where consent + // arrives — nothing before this point is reportable, which is the honest + // answer rather than a gap to close. Absence of the flag is OFF: a host + // started by the CLI or a test has nobody who consented to anything. + if ( + initCrashReporting({ + enabled: payload.telemetryEnabled ?? false, + dsn: process.env.SENTRY_DSN ?? "", + release: payload.ownerBuild, + }) + ) { + log.debug("crash reporting enabled"); + } + const host = new HostServer({ ...(payload.machine ? { @@ -138,8 +160,6 @@ program onShutdownRequested: () => void shutdown("app-close"), }); - await host.startControlPlane(); // bind loopback control + write host.json - // RSS sampler runs for the whole host process when --debug-perf is set — // started here (not gated on a first project) so a machine-only warm-up // spawn that only ever serves project:open RPCs is still sampled. Labelled @@ -179,15 +199,22 @@ program await host.shutdown(reason); } catch (err) { log.error("Shutdown failed: %s", err); + captureBridgeError(err, "shutdown"); } + // After the drain, not before: this is the last chance to send whatever + // the SDK's top-level handlers queued. ~15ms when there is nothing to + // send, so a clean exit is not measurably slower for a consenting host. + await flushCrashReports(); clearTimeout(bail); process.exit(exitCode); }; - // Wire teardown BEFORE the (possibly multi-second) first-project open, so an - // owner death or signal mid-open can't leave the host registered on the - // relay for an app that's already gone. The owner-watchdog self-exits when - // the spawning app's pid vanishes — the backstop for exits that never reach + // Wire teardown BEFORE the control plane comes up, so an owner death or a + // signal during bring-up or the first-project open can't leave the host + // registered on the relay for an app that's already gone. `host.shutdown()` + // is null-safe against a host that never started, which is what lets this + // sit ahead of it. The owner-watchdog self-exits when the spawning app's + // pid vanishes — the backstop for exits that never reach // the app's didRequestAppExit teardown (force-kill, crash, or a window close // under `flutter run --machine`), which would otherwise orphan this // machine-level host. @@ -200,9 +227,26 @@ program process.on("SIGINT", () => shutdown("SIGINT")); process.on("SIGTERM", () => shutdown("SIGTERM")); process.on("SIGHUP", () => shutdown("SIGHUP")); + // These own the EXIT; Sentry's own handler for each owns the CAPTURE (it is + // installed by initCrashReporting above), which is what keeps a fatal marked + // `handled: false` rather than re-reported here as an ordinary handled + // error — so there is deliberately no captureBridgeError call in either. + // + // **These must be registered for as much of the process's life as possible.** + // The SDK decides whether to exit on its own AT CRASH TIME, by counting the + // OTHER uncaughtException listeners: with one of ours present it defers and + // this teardown sweeps the PTYs; as the sole listener it logs and + // `process.exit(1)`s, skipping the sweep. Hence the order below: everything + // between initCrashReporting and here is straight-line setup that opens + // nothing, whereas startControlPlane publishes host.json and only THEN + // spends seconds on the relay handshake and OAuth mint — with host.json on + // disk the app can drive project:open over loopback for that whole stretch, + // so it is not a window in which "no PTY exists yet" may be assumed. process.on("uncaughtException", (err) => { log.error("Uncaught exception: %s", err); shutdown("uncaughtException"); }); process.on("unhandledRejection", (err) => { log.error("Unhandled rejection: %s", err); shutdown("unhandledRejection"); }); + await host.startControlPlane(); // bind loopback control + write host.json + // Inline the first project when one was provided. An eager warm-up spawn // (app launch) sends no firstProject — the control plane is already up from // startControlPlane() above; the host then waits for project:open RPCs. @@ -214,6 +258,12 @@ program await host.open(payload.firstProject.projectId, payload.firstProject.projectPath, payload.firstProject.mode); } catch (err) { console.error(`antgrid-bridge: failed to open first project: ${(err as Error).message}`); + // Reported explicitly: this exit is a bare process.exit, so it reaches + // neither the shutdown path's flush nor either top-level handler, and a + // mint failure against a revoked credential pair lands here and nowhere + // else. + captureBridgeError(err, "first-project-open"); + await flushCrashReports(); process.exit(1); } } diff --git a/bridge/src/keystrokes.ts b/bridge/src/keystrokes.ts index 14b00381..7b014a42 100644 --- a/bridge/src/keystrokes.ts +++ b/bridge/src/keystrokes.ts @@ -23,15 +23,21 @@ export function isSubmitKeystroke(data: string): boolean { } /** - * Reports the terminal EMITS rather than input a human gave it: mouse tracking - * (SGR `\x1b[ 0; } diff --git a/bridge/src/message-bus.ts b/bridge/src/message-bus.ts index 382ee2b8..72cfa105 100644 --- a/bridge/src/message-bus.ts +++ b/bridge/src/message-bus.ts @@ -36,6 +36,9 @@ const REPLAY_TYPES: ReadonlySet = new Set([ "agent:projects", "agent:tools", "git:status", + // Latest-wins ahead/behind. Without the replay a reconnecting app shows a + // synced branch until the next op, which is the exact wrong answer. + "git:sync-state", "tree:full", // Latest per-project handler snapshot (armed sessions + open escalations). // Must be cached: the app rebuilds its escalation list from the status diff --git a/bridge/src/protocol.ts b/bridge/src/protocol.ts index 5317a978..524547b3 100644 --- a/bridge/src/protocol.ts +++ b/bridge/src/protocol.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { AbConfigSchema } from "./config"; +import { KNOWN_TIERS } from "./entitlement"; const BaseMessage = z.object({ id: z.string().uuid(), @@ -187,7 +188,15 @@ const AgentStatusMessage = BaseMessage.extend({ services: z.array(ServiceStatusInfo).optional(), commands: z.array(CommandInfo).optional(), ports: z.array(PortInfo).optional(), - git: z.object({ branch: z.string() }).optional(), + // Counts are LOCAL (against the upstream ref), so they are as fresh as the + // last fetch — see [readSyncState] in git-sync.ts for why nothing here may + // reach the network. All three are optional so an older bridge still parses. + git: z.object({ + branch: z.string(), + ahead: z.number().int().nonnegative().optional(), + behind: z.number().int().nonnegative().optional(), + hasUpstream: z.boolean().optional(), + }).optional(), agent: z.object({ tool: z.string().optional(), name: z.string().optional(), @@ -350,6 +359,199 @@ const GitUnstageResultMessage = BaseMessage.extend({ ...CheckoutScoped, }); +const GitStashEntrySchema = z.object({ + ref: z.string(), + /** "" when unparseable — see `parseStashSubject` in git-branches.ts. */ + branch: z.string(), + message: z.string(), + createdAt: z.number(), +}); + +const GitStashListRequestMessage = BaseMessage.extend({ + type: z.literal("git:stash-list"), + projectId: z.string(), + ...CheckoutScoped, +}); + +const GitStashListResultMessage = BaseMessage.extend({ + type: z.literal("git:stash-list-result"), + projectId: z.string(), + stashes: z.array(GitStashEntrySchema), + error: z.string().optional(), + ...CheckoutScoped, +}); + +const GitStashPopMessage = BaseMessage.extend({ + type: z.literal("git:stash-pop"), + projectId: z.string(), + ref: z.string(), + ...CheckoutScoped, +}); + +const GitStashPopResultMessage = BaseMessage.extend({ + type: z.literal("git:stash-pop-result"), + projectId: z.string(), + ref: z.string(), + success: z.boolean(), + error: z.string().optional(), + ...CheckoutScoped, +}); + +const GitStashDropMessage = BaseMessage.extend({ + type: z.literal("git:stash-drop"), + projectId: z.string(), + ref: z.string(), + ...CheckoutScoped, +}); + +const GitStashDropResultMessage = BaseMessage.extend({ + type: z.literal("git:stash-drop-result"), + projectId: z.string(), + ref: z.string(), + success: z.boolean(), + error: z.string().optional(), + ...CheckoutScoped, +}); + +const GitLogEntrySchema = z.object({ + sha: z.string(), + shortSha: z.string(), + subject: z.string(), + authorName: z.string(), + authorEmail: z.string(), + authorDate: z.string(), +}); + +const GitLogRequestMessage = BaseMessage.extend({ + type: z.literal("git:log"), + projectId: z.string(), + skip: z.number().int().nonnegative().default(0), + limit: z.number().int().positive().default(50), + ...CheckoutScoped, +}); + +const GitLogResultMessage = BaseMessage.extend({ + type: z.literal("git:log-result"), + projectId: z.string(), + commits: z.array(GitLogEntrySchema), + skip: z.number().int().nonnegative(), + /** Whether a further page exists past `skip + commits.length` — what the + * History tab's scroll-triggered fetch checks before asking for more. */ + hasMore: z.boolean(), + error: z.string().optional(), + ...CheckoutScoped, +}); + +const GitCommitFileEntrySchema = z.object({ + path: z.string(), + status: z.enum(["M", "A", "D", "R"]), + oldPath: z.string().optional(), + additions: z.number().int(), + deletions: z.number().int(), +}); + +const GitCommitFilesRequestMessage = BaseMessage.extend({ + type: z.literal("git:commit-files"), + projectId: z.string(), + sha: z.string(), + ...CheckoutScoped, +}); + +const GitCommitFilesResultMessage = BaseMessage.extend({ + type: z.literal("git:commit-files-result"), + projectId: z.string(), + sha: z.string(), + files: z.array(GitCommitFileEntrySchema), + error: z.string().optional(), + ...CheckoutScoped, +}); + +const GitCommitDiffRequestMessage = BaseMessage.extend({ + type: z.literal("git:commit-diff"), + projectId: z.string(), + sha: z.string(), + path: z.string(), + ...CheckoutScoped, +}); + +const GitCommitDiffContentMessage = BaseMessage.extend({ + type: z.literal("git:commit-diff-content"), + projectId: z.string(), + sha: z.string(), + path: z.string(), + diff: z.string().nullable(), + additions: z.number().int(), + deletions: z.number().int(), + ...CheckoutScoped, +}); + +/** Why a push/pull did not happen. Mirrors [GitSyncFailureKind] in git-sync.ts + * and `GitSyncFailureKind` in the Dart model BY HAND; a receiver that meets an + * unrecognized value must read it as "unknown" rather than reject the frame, + * which is what lets a newer bridge add a kind without an app release. */ +const GitSyncFailureKindSchema = z.enum([ + "no-remote", "no-upstream", "ambiguous-remote", "not-fast-forward", + "rejected", "diverged", "auth", "conflict", "dirty-tree", "detached", "unknown", +]); + +const GitSyncMessage = BaseMessage.extend({ + type: z.literal("git:sync"), + projectId: z.string(), + op: z.enum(["push", "pull"]), + ...CheckoutScoped, +}); + +const GitSyncResultMessage = BaseMessage.extend({ + type: z.literal("git:sync-result"), + projectId: z.string(), + op: z.enum(["push", "pull"]), + success: z.boolean(), + /** Null on a detached HEAD — the one shape with no branch to name. */ + branch: z.string().nullable(), + remote: z.string().optional(), + remoteBranch: z.string().optional(), + summary: z.string().optional(), + error: z.string().optional(), + failureKind: GitSyncFailureKindSchema.optional(), + /** The git invocation and its verbatim stderr, present only on failure. They + * are carried rather than summarized because they are what the agent handoff + * forwards — the app never re-parses git's prose to build its own copy. */ + command: z.string().optional(), + stderr: z.string().optional(), + ...CheckoutScoped, +}); + +const GitSyncStatusMessage = BaseMessage.extend({ + type: z.literal("git:sync-status"), + projectId: z.string(), + /** Ask the REMOTE, not just the local upstream ref (see [readSyncState] vs + * [checkBranchAgainstRemote]). Costs a network round trip, so it is opt-in + * and never set by the refresh that rides git:status. */ + probeRemote: z.boolean().optional(), + ...CheckoutScoped, +}); + +const GitSyncStateMessage = BaseMessage.extend({ + type: z.literal("git:sync-state"), + projectId: z.string(), + branch: z.string().nullable(), + remote: z.string().nullable(), + remoteBranch: z.string().nullable(), + ahead: z.number().int().nonnegative(), + behind: z.number().int().nonnegative(), + hasUpstream: z.boolean(), + hasRemote: z.boolean(), + /** Present only when a probe actually reached the remote; the same wire + * strings [BranchRemoteState] already uses. Absent means the counts are + * local-only — as fresh as the last fetch, which is what the up/down + * indicator promises. */ + state: z.enum([ + "no-remote", "no-upstream", "gone", "in-sync", + "behind", "ahead", "diverged", "differs", "unreachable", + ]).optional(), + ...CheckoutScoped, +}); + // Port scanning & preview messages const PortInfoSchema = z.object({ port: z.number().int().positive(), @@ -571,6 +773,28 @@ const FileContentMessage = BaseMessage.extend({ ...CheckoutScoped, }); +const FileResolvePathMessage = BaseMessage.extend({ + type: z.literal("file:resolve-path"), + projectId: z.string(), + requestId: z.string(), + // Raw path as it appeared in terminal output (an OSC 8 `file://` hyperlink + // target) — absolute on the bridge machine, or already checkout-relative. + path: z.string(), + ...CheckoutScoped, +}); + +const FileResolvePathResultMessage = BaseMessage.extend({ + type: z.literal("file:resolve-path-result"), + projectId: z.string(), + requestId: z.string(), + // Checkout-relative, `/`-separated — the only form the app's file tree + // understands. Null when the path does not resolve inside this checkout (a + // path from elsewhere, a symlink escape, or unparsable garbage). + relPath: z.string().nullable(), + isDirectory: z.boolean(), + ...CheckoutScoped, +}); + const FileSearchMessage = BaseMessage.extend({ type: z.literal("file:search"), projectId: z.string(), @@ -793,6 +1017,17 @@ const BacklogWire = z.array(InstructionItemWire).refine( // stay in lockstep, or the hot path admits what the union rejects. Field-level // rules (BacklogWire) ride along through `.shape`; a whole-payload `.refine` // would have to be written on both. +// How far Handler leans toward answering on the user's behalf: it moves where +// the line between `handle` and `escalate` sits, and the tone of `notify`. +// Nothing else — the evidence a transition must cite is the anti-inflation +// guard, and a posture able to relax it would let a confident preset report +// progress that never happened. +// +// A bounded preset, deliberately not a free-text guidance field: the value is +// interpolated into the judge prompt, and a fixed set carries no injection. +export const HandlerPersonalitySchema = z.enum(["watchdog", "closer", "autopilot"]); +export type HandlerPersonality = z.infer; + export const HandlerConfigureWire = z.object({ terminalId: z.string(), armed: z.boolean(), @@ -808,6 +1043,11 @@ export const HandlerConfigureWire = z.object({ // tool / CLI default model); absent = leave the stored choice untouched. judgeTool: z.string().optional(), judgeModel: z.string().optional(), + // Absent = leave the session's stored posture untouched, the same + // absent-keeps rule judgeTool follows. There is no "clear to default": every + // preset is a real choice, and the default is only what a session that has + // never been given one judges as. + personality: HandlerPersonalitySchema.optional(), }); const HandlerConfigureMessage = BaseMessage.extend({ @@ -822,8 +1062,7 @@ const HandlerConfigureMessage = BaseMessage.extend({ // envelope below rides on `.shape` so the two cannot drift apart. // // Keep whole-payload rules off this schema too: stacking is one line typed on a -// phone (or one preset chip), and a cross-field precondition would put a form in -// front of it. +// phone, and a cross-field precondition would put a form in front of it. export const HandlerInstructWire = z.object({ terminalId: z.string(), // Untrusted remote text that ends up interpolated into the extraction prompt. @@ -1024,6 +1263,33 @@ const HandlerSessionSnapshot = z.object({ // handler/engine.ts). Optional and appended LAST: an older app still parses // the snapshot, and every key it reads keeps its position. observability: z.enum(["full", "escalate_only", "unsupported"]).optional(), + // The posture this session actually judges under, resolved by the bridge and + // so always present on a status frame — an app reading it never has to know + // what an absent value would have meant. Optional and appended LAST for the + // same reason `observability` is: an older app still parses the snapshot. + personality: HandlerPersonalitySchema.optional(), +}); + +// Why this machine will not run the Handler, in the words the app has to answer +// with. Mirrors `EntitlementRefusal` (./entitlement.ts) — the REFUSED half of +// the verdict only, which is what makes presence on a status frame mean +// "refused" with no second boolean to disagree with. +// +// A refusal is otherwise invisible: it leaves the slot in the ordinary +// not-armed state, which is exactly what a tap that never registered looks +// like, and the engine's warn line is on a machine the reader may not be +// sitting at. This is the one thing the bridge knows and the app cannot derive. +// +// The two reasons are two different sentences, and collapsing them would send +// half the users to the wrong fix: `not_entitled` is the paywall and the app +// offers the upgrade, `unreadable` is a machine whose credentials stopped +// answering and the app says to sign in again. +export const HandlerEntitlementWire = z.object({ + reason: z.enum(["not_entitled", "unreadable"]), + // The tier the claim carried, when it carried a recognised one — so the + // upgrade copy can name the plan the machine is actually on. Absent for + // `unreadable`, which is the case of having no readable tier at all. + tier: z.enum(KNOWN_TIERS).optional(), }); const HandlerStatusMessage = BaseMessage.extend({ @@ -1046,6 +1312,16 @@ const HandlerStatusMessage = BaseMessage.extend({ // and [] mean the same thing — unlike `observability`, presence here is not a // capability signal, so a bridge with nothing to report simply omits it. wrapUps: z.array(HandlerWrapUpWire).optional(), + // Present ONLY while this machine refuses Handler; absent is the ordinary + // entitled machine AND every bridge predating the field, which want the same + // rendering. Appended LAST for the reason `wrapUps` is — an older app still + // parses the frame and every key it already reads keeps its position. + // + // Project-scoped rather than per session, because entitlement is neither: it + // is a fact about the account behind the machine, and a session-shaped copy + // would have nowhere to live on the surface that needs it most — the shield + // over a session that is not armed and, while this is set, cannot become so. + entitlement: HandlerEntitlementWire.optional(), }); const HandlerEscalationMessage = BaseMessage.extend({ @@ -1892,6 +2168,8 @@ export const AbMessageSchema = z.discriminatedUnion("type", [ TreeUpdateMessage, FileReadMessage, FileContentMessage, + FileResolvePathMessage, + FileResolvePathResultMessage, FileSearchMessage, FileSearchCancelMessage, FileSearchResultMessage, @@ -1939,6 +2217,22 @@ export const AbMessageSchema = z.discriminatedUnion("type", [ GitStageResultMessage, GitUnstageMessage, GitUnstageResultMessage, + GitStashListRequestMessage, + GitStashListResultMessage, + GitStashPopMessage, + GitStashPopResultMessage, + GitStashDropMessage, + GitStashDropResultMessage, + GitLogRequestMessage, + GitLogResultMessage, + GitCommitFilesRequestMessage, + GitCommitFilesResultMessage, + GitCommitDiffRequestMessage, + GitCommitDiffContentMessage, + GitSyncMessage, + GitSyncResultMessage, + GitSyncStatusMessage, + GitSyncStateMessage, AgentEnableRelayMessage, AgentDisableRelayMessage, AgentActivationPendingMessage, @@ -2023,6 +2317,8 @@ export type TreeFull = z.infer; export type TreeUpdate = z.infer; export type FileRead = z.infer; export type FileContent = z.infer; +export type FileResolvePath = z.infer; +export type FileResolvePathResult = z.infer; export type PortInfo = z.infer; export type PortsUpdate = z.infer; export type PreviewUrl = z.infer; @@ -2044,6 +2340,7 @@ export type HandlerConfigureMsg = z.infer; export type HandlerInstructMsg = z.infer; export type HandlerSessionSnapshot = z.infer; export type HandlerStatusMsg = z.infer; +export type HandlerEntitlement = z.infer; export type HandlerEscalationMsg = z.infer; export type HandlerActivityMsg = z.infer; export type HandlerSnapshotMsg = z.infer; @@ -2064,6 +2361,25 @@ export type GitStage = z.infer; export type GitStageResult = z.infer; export type GitUnstage = z.infer; export type GitUnstageResult = z.infer; +export type GitStashEntryWire = z.infer; +export type GitStashListRequest = z.infer; +export type GitStashListResult = z.infer; +export type GitStashPop = z.infer; +export type GitStashPopResult = z.infer; +export type GitStashDrop = z.infer; +export type GitStashDropResult = z.infer; +export type GitLogEntryWire = z.infer; +export type GitLogRequest = z.infer; +export type GitLogResult = z.infer; +export type GitCommitFileEntryWire = z.infer; +export type GitCommitFilesRequest = z.infer; +export type GitCommitFilesResult = z.infer; +export type GitCommitDiffRequest = z.infer; +export type GitCommitDiffContent = z.infer; +export type GitSync = z.infer; +export type GitSyncResult = z.infer; +export type GitSyncStatus = z.infer; +export type GitSyncState = z.infer; export type FileSearch = z.infer; export type FileSearchCancel = z.infer; export type SearchMatch = z.infer; @@ -2155,11 +2471,17 @@ export const CHECKOUT_VARIABLE_MESSAGE_TYPES = new Set([ "terminal:snapshot:request", "terminal:snapshot", "agent:status", "tree:full", "tree:update", "file:read", "file:content", + "file:resolve-path", "file:resolve-path-result", "file:search", "file:search-cancel", "file:search-result", "file:search-done", "file:upload-start", "file:upload-ready", "file:upload-chunk", "file:upload-ack", "file:upload-done", "file:upload-result", "git:status", "git:diff", "git:diff-content", "git:list-branches", "git:branches", "git:checkout", "git:checkout-result", "git:commit", "git:commit-result", "git:discard", "git:discard-result", "git:stage", "git:stage-result", "git:unstage", "git:unstage-result", + "git:stash-list", "git:stash-list-result", "git:stash-pop", "git:stash-pop-result", + "git:stash-drop", "git:stash-drop-result", + "git:log", "git:log-result", "git:commit-files", "git:commit-files-result", + "git:commit-diff", "git:commit-diff-content", + "git:sync", "git:sync-result", "git:sync-status", "git:sync-state", "command:run", "command:output", "command:done", "config:read", "config:read-result", "config:write", "config:write-result", "config:changed", "config:detect-tools", "config:detect-tools-result", "ports:update", "port:detected", "preview:url", "file:tree:snapshot:request", "file:tree:snapshot", "preview:snapshot:request", "preview:snapshot", @@ -2225,6 +2547,7 @@ const KNOWN_TYPES = new Set([ "terminal:start", "terminal:stop", "terminal:resize", "terminal:size", "agent:status", "ping", "pong", "handshake:client-hello", "handshake:agent-hello", "handshake:agent-ready", "tree:full", "tree:update", "file:read", "file:content", + "file:resolve-path", "file:resolve-path-result", "ports:update", "preview:url", "agent:disconnecting", "agent:projects", "agent:tools", "stream-ready", "stream-invalid", "control:result", "app:ready", "command:run", "command:output", "command:done", "notification:push", "push:register", @@ -2234,6 +2557,11 @@ const KNOWN_TYPES = new Set([ "git:list-branches", "git:branches", "git:checkout", "git:checkout-result", "git:commit", "git:commit-result", "git:discard", "git:discard-result", "git:stage", "git:stage-result", "git:unstage", "git:unstage-result", + "git:stash-list", "git:stash-list-result", "git:stash-pop", "git:stash-pop-result", + "git:stash-drop", "git:stash-drop-result", + "git:log", "git:log-result", "git:commit-files", "git:commit-files-result", + "git:commit-diff", "git:commit-diff-content", + "git:sync", "git:sync-result", "git:sync-status", "git:sync-state", "file:search", "file:search-cancel", "file:search-result", "file:search-done", "file:upload-start", "file:upload-ready", "file:upload-chunk", "file:upload-ack", "file:upload-done", "file:upload-result", diff --git a/bridge/src/tunnel-manager.ts b/bridge/src/tunnel-manager.ts index bbe9b9f9..d9087951 100644 --- a/bridge/src/tunnel-manager.ts +++ b/bridge/src/tunnel-manager.ts @@ -13,8 +13,37 @@ interface WsUpstream { socket: WebSocket; open: boolean; pending: Array<{ data: string; binary: boolean }>; + pendingBytes: number; + checkoutId: string; } +/** A tunnelId the app has sent data for while no upstream socket exists. + * Either still buffering, or [poisoned] — the prefix is gone (overflowed, + * expired, or the tunnel already closed), so what follows can no longer be + * replayed as a faithful stream and the tunnel must be refused instead. */ +interface WsPreopen { + frames: Array<{ data: string; binary: boolean }>; + bytes: number; + poisoned: boolean; + timer: ReturnType; +} + +const WS_PREOPEN_TTL_MS = 5_000; +/** How long a poisoned tunnelId is remembered. A WebSocket carries a byte + * stream, so an open that arrives after its buffered prefix died must be + * refused rather than started mid-stream: a dev server handed a spliced + * message stream believes it holds a valid session and hangs, where a refused + * one gives the browser the close event its reconnect logic waits for. + * Outlives the app's 30s tunnel timeout so the refusal beats the give-up. */ +const WS_POISON_TTL_MS = 35_000; +const WS_PREOPEN_MAX_TUNNELS = 64; +const WS_BUFFER_MAX_FRAMES = 64; +const WS_BUFFER_MAX_BYTES = 1024 * 1024; +const WS_PREOPEN_MAX_TOTAL_BYTES = 16 * 1024 * 1024; +/** Both buffers are fed from the data path, so their drop paths must never log + * per frame — a streaming socket would emit thousands of lines. */ +const WS_PREOPEN_WARN_INTERVAL_MS = 5_000; + /** How long a sent response stays replayable. Must outlive the app's 30s tunnel * timeout so a retry issued just before it gives up still finds the entry. */ const OUTBOX_TTL_MS = 35_000; @@ -78,6 +107,17 @@ export class TunnelManager { private inflight = new Map>(); /** Live WS relays, keyed by tunnelId — see [WsUpstream]. */ private wsTunnels = new Map(); + /** Async sealing can put the first data frame ahead of its open frame. Keep + * that bounded orphan briefly so a Blazor/SignalR handshake is not lost. + * Insertion-ordered: the oldest tombstone is the first eviction candidate. */ + private wsPreopen = new Map(); + private wsPreopenBytes = 0; + private wsPreopenWarnedAt = 0; + private wsPreopenTtlMs: number; + /** [stop] is terminal. Without this a frame still in flight when a checkout + * is torn down re-arms a timer on a manager nothing owns any more — the + * callers null nothing, so the flag is what has to hold the line. */ + private stopped = false; constructor(opts: { projectId: string; @@ -87,6 +127,7 @@ export class TunnelManager { sendEncrypted: (msg: AbMessage) => void; relayHost: string; connState: ConnState; + wsPreopenTtlMs?: number; }) { this.projectId = opts.projectId; this.portLabels = opts.portLabels; @@ -95,6 +136,7 @@ export class TunnelManager { this.sendEncrypted = opts.sendEncrypted; this.relayHost = opts.relayHost; this.connState = opts.connState; + this.wsPreopenTtlMs = opts.wsPreopenTtlMs ?? WS_PREOPEN_TTL_MS; } onPortsUpdate(ports: PortInfo[]): void { @@ -164,6 +206,9 @@ export class TunnelManager { } async onHttpRequest(msg: TunnelHttpRequest): Promise { + // Deliberately NOT gated on [stopped], unlike the WS handlers: an HTTP + // request the app is waiting on costs it a 30s timeout if dropped, and + // serving one holds nothing open afterwards. // Outbox first, before anything can reach the dev server: this is the whole // safety property of the app's retry. const inflight = this.inflight.get(msg.requestId); @@ -264,7 +309,32 @@ export class TunnelManager { * through the normal `tunnel:ws-close` path, mirroring what a rejected * browser-side connect would look like, rather than dropping silently. */ onWsOpen(msg: TunnelWsOpen): void { + if (this.stopped) { + // Refuse rather than drop: this manager will never relay again, and the + // browser's socket only reconnects once it sees a close. + this.sendTunnel({ + type: "tunnel:ws-close", + tunnelId: msg.tunnelId, + reason: "tunnel manager stopped", + checkoutId: msg.checkoutId, + }); + return; + } if (this.wsTunnels.has(msg.tunnelId)) return; // duplicate open, ignore + if (this.wsPreopen.get(msg.tunnelId)?.poisoned) { + // Opening here would relay a stream whose prefix is missing. Refusing + // is what gets the browser a close event it can reconnect from. The + // tombstone is deliberately LEFT in place: frames still in flight behind + // this open must not start a second, tail-only buffer for the same id. + this.sendTunnel({ + type: "tunnel:ws-close", + tunnelId: msg.tunnelId, + reason: "buffered frames were dropped before the tunnel opened", + checkoutId: msg.checkoutId, + }); + return; + } + const preopen = this.takePreopen(msg.tunnelId); // The phone can only guess the scheme for a dev server it never saw // announce itself; `fetchLocalhost` has already corrected the guess for // this port by the time a page on it opens a socket. @@ -283,13 +353,20 @@ export class TunnelManager { ...(secure ? { tls: { rejectUnauthorized: false } } : {}), }; const socket = new WebSocket(url, wsOptions as unknown as string[]); - const entry: WsUpstream = { socket, open: false, pending: [] }; + const entry: WsUpstream = { + socket, + open: false, + pending: preopen?.frames ?? [], + pendingBytes: preopen?.bytes ?? 0, + checkoutId: msg.checkoutId, + }; this.wsTunnels.set(msg.tunnelId, entry); entry.socket.addEventListener("open", () => { entry.open = true; for (const frame of entry.pending) this.sendUpstream(entry, frame.data, frame.binary); entry.pending = []; + entry.pendingBytes = 0; }); entry.socket.addEventListener("message", (event) => { const binary = typeof event.data !== "string"; @@ -306,52 +383,223 @@ export class TunnelManager { checkoutId: msg.checkoutId, }); }); - const teardown = (code?: number, reason?: string) => { - if (!this.wsTunnels.delete(msg.tunnelId)) return; // already closed the other way - this.sendTunnel({ - type: "tunnel:ws-close", - tunnelId: msg.tunnelId, - ...(code !== undefined ? { code } : {}), - ...(reason ? { reason } : {}), - checkoutId: msg.checkoutId, - }); - }; - entry.socket.addEventListener("close", (event) => teardown(event.code, event.reason)); - entry.socket.addEventListener("error", () => teardown()); + entry.socket.addEventListener("close", (event) => + this.teardownWs(msg.tunnelId, event.code, event.reason), + ); + entry.socket.addEventListener("error", () => this.teardownWs(msg.tunnelId)); } - /** A browser-sent frame to relay upstream. Queued on [WsUpstream.pending] - * if the real connection hasn't finished its handshake yet. */ + /** Tell the app a tunnel is over and stop relaying it. Idempotent: a close + * already relayed the other way has removed the map entry, and this is what + * keeps the socket's own close event from sending a second frame. */ + private teardownWs(tunnelId: string, code?: number, reason?: string): void { + const entry = this.wsTunnels.get(tunnelId); + if (!entry) return; + this.wsTunnels.delete(tunnelId); + // The app answers a bridge-initiated close by dropping its own tunnel + // entry, so it never sends `tunnel:ws-close` back and [onWsClose] never + // runs for this id. Anything still in flight would otherwise land in + // [bufferPreopenFrame] and hold one of the 64 slots for a full TTL. + this.poisonPreopen(tunnelId); + this.sendTunnel({ + type: "tunnel:ws-close", + tunnelId, + ...(code !== undefined ? { code } : {}), + ...(reason ? { reason } : {}), + checkoutId: entry.checkoutId, + }); + } + + /** A browser-sent frame to relay upstream. Buffered on [WsUpstream.pending] + * while the real connection is still handshaking, or on [wsPreopen] when its + * `tunnel:ws-open` has not landed yet. Both buffers are bounded, and both + * answer an overflow by ending the tunnel rather than by relaying a stream + * with a hole in it. */ onWsData(msg: TunnelWsData): void { + if (this.stopped) return; const entry = this.wsTunnels.get(msg.tunnelId); - if (!entry) return; // closed/never opened — nothing to relay into + if (!entry) { + this.bufferPreopenFrame(msg); + return; + } if (!entry.open) { + // Same ceiling as the pre-open buffer, and for a stronger reason: this + // window is the LONGER of the two. A port that accepts TCP but stalls + // the upgrade — a dev server mid-startup, or an https-only port reached + // as `ws://` — holds it open for the OS connect timeout. + const bytes = Buffer.byteLength(msg.data); + if ( + entry.pending.length >= WS_BUFFER_MAX_FRAMES + || entry.pendingBytes + bytes > WS_BUFFER_MAX_BYTES + ) { + log.warn( + "Closing WS tunnel %s: upstream handshake did not finish before its buffer filled", + msg.tunnelId, + ); + // Report before closing: the socket's own close event runs the same + // teardown, and whichever wins owns the reason the app is told. + this.teardownWs(msg.tunnelId, undefined, "upstream handshake buffer overflow"); + entry.socket.close(); + return; + } entry.pending.push({ data: msg.data, binary: msg.binary === true }); + entry.pendingBytes += bytes; return; } this.sendUpstream(entry, msg.data, msg.binary === true); } + private bufferPreopenFrame(msg: TunnelWsData): void { + const existing = this.wsPreopen.get(msg.tunnelId); + if (existing?.poisoned) return; // already unreplayable; the open will be refused + const bytes = Buffer.byteLength(msg.data); + + let pending = existing; + if (!pending) { + if (!this.makeRoomForPreopen()) { + // Throttled: this fires from the data path, once per frame of every + // unknown tunnel, and the tunnelId is what makes it diagnosable. + const now = Date.now(); + if (now - this.wsPreopenWarnedAt >= WS_PREOPEN_WARN_INTERVAL_MS) { + this.wsPreopenWarnedAt = now; + log.warn( + "Dropping pre-open WS data for %s: %d tunnels already buffering", + msg.tunnelId, + this.wsPreopen.size, + ); + } + return; + } + pending = { + frames: [], + bytes: 0, + poisoned: false, + // Captures the id, not the frame — a timer that closed over `msg` + // would pin its whole payload for the TTL even after a rejection. + timer: this.armPreopenTimer(msg.tunnelId, this.wsPreopenTtlMs), + }; + this.wsPreopen.set(msg.tunnelId, pending); + } + + if ( + pending.frames.length >= WS_BUFFER_MAX_FRAMES + || pending.bytes + bytes > WS_BUFFER_MAX_BYTES + || this.wsPreopenBytes + bytes > WS_PREOPEN_MAX_TOTAL_BYTES + ) { + log.warn("Poisoning WS tunnel %s: pre-open buffer limit reached", msg.tunnelId); + this.poisonPreopen(msg.tunnelId); + return; + } + pending.frames.push({ data: msg.data, binary: msg.binary === true }); + pending.bytes += bytes; + this.wsPreopenBytes += bytes; + } + + /** Make a slot available under [WS_PREOPEN_MAX_TUNNELS], evicting the oldest + * tombstone first — a dev server in a reconnect loop churns a fresh tunnelId + * per attempt, and without this its dead ids starve the live one. */ + private makeRoomForPreopen(): boolean { + if (this.wsPreopen.size < WS_PREOPEN_MAX_TUNNELS) return true; + for (const [id, pending] of this.wsPreopen) { + if (!pending.poisoned) continue; + clearTimeout(pending.timer); + this.wsPreopen.delete(id); + return true; + } + return false; + } + + private armPreopenTimer(tunnelId: string, ms: number): ReturnType { + const timer = setTimeout(() => { + const pending = this.wsPreopen.get(tunnelId); + if (!pending) return; + // First expiry drops the buffered prefix but REMEMBERS that it existed; + // the second retires the tombstone. + if (pending.poisoned) { + this.wsPreopen.delete(tunnelId); + return; + } + this.poisonPreopen(tunnelId); + }, ms); + if (typeof timer.unref === "function") timer.unref(); + return timer; + } + + /** Mark [tunnelId] unreplayable and release what it held. The entry stays as + * a tombstone so a later open is refused rather than started mid-stream. */ + private poisonPreopen(tunnelId: string): void { + const pending = this.wsPreopen.get(tunnelId); + if (pending) { + if (pending.poisoned) return; + clearTimeout(pending.timer); + this.wsPreopenBytes -= pending.bytes; + pending.frames = []; + pending.bytes = 0; + pending.poisoned = true; + pending.timer = this.armPreopenTimer(tunnelId, WS_POISON_TTL_MS); + return; + } + if (!this.makeRoomForPreopen()) return; + this.wsPreopen.set(tunnelId, { + frames: [], + bytes: 0, + poisoned: true, + timer: this.armPreopenTimer(tunnelId, WS_POISON_TTL_MS), + }); + } + + private takePreopen(tunnelId: string): WsPreopen | undefined { + const pending = this.wsPreopen.get(tunnelId); + if (!pending) return undefined; + clearTimeout(pending.timer); + this.wsPreopenBytes -= pending.bytes; + this.wsPreopen.delete(tunnelId); + return pending; + } + private sendUpstream(entry: WsUpstream, data: string, binary: boolean): void { entry.socket.send(binary ? Buffer.from(data, "base64") : data); } /** The app's side of the tunnel closed (the browser tab's WS closed) — - * mirror it upstream. Idempotent: a close already relayed the other way - * (via [onWsOpen]'s teardown) has already removed the map entry. */ + * mirror it upstream, or discard the pre-open buffer when the tunnel never + * got that far. Idempotent: a close already relayed the other way (via + * [teardownWs]) has already removed the map entry. */ onWsClose(msg: TunnelWsClose): void { + if (this.stopped) return; const entry = this.wsTunnels.get(msg.tunnelId); - if (!entry) return; + if (!entry) { + this.takePreopen(msg.tunnelId); + return; + } this.wsTunnels.delete(msg.tunnelId); entry.socket.close(); } stop(): void { + this.stopped = true; this.sentUrlDetails.clear(); this.outbox.clear(); this.outboxBytes = 0; this.inflight.clear(); - for (const entry of this.wsTunnels.values()) entry.socket.close(); + for (const [tunnelId, entry] of this.wsTunnels) { + // Delete BEFORE closing so the socket's own close event finds nothing + // and cannot send a second frame — and send here rather than leave it to + // that event, which a socket still CONNECTING never fires at all. A + // session deleted mid-handshake would otherwise leave the app's tunnel + // entry and the browser's socket waiting on a close that never comes. + this.wsTunnels.delete(tunnelId); + this.sendTunnel({ + type: "tunnel:ws-close", + tunnelId, + reason: "tunnel manager stopped", + checkoutId: entry.checkoutId, + }); + entry.socket.close(); + } this.wsTunnels.clear(); + for (const pending of this.wsPreopen.values()) clearTimeout(pending.timer); + this.wsPreopen.clear(); + this.wsPreopenBytes = 0; } } diff --git a/bridge/tests/agent-core-status-cache.test.ts b/bridge/tests/agent-core-status-cache.test.ts index dba19e14..4407002f 100644 --- a/bridge/tests/agent-core-status-cache.test.ts +++ b/bridge/tests/agent-core-status-cache.test.ts @@ -85,6 +85,19 @@ async function bootCore(): Promise<{ bus: MessageBus; sent: AbMessage[] }> { core.attachTransport(bus); core.onHandshakeComplete(); await waitFor(() => sent.some((m) => m.type === "agent:status"), "the first agent:status"); + // Boot sends a git-less status IMMEDIATELY and re-sends once the background + // `refreshGitBranch`/`refreshGitStatus` pair lands, so a test that counts + // status frames races an emit it never asked for. `git:sync-state` is the + // last call in that same `.then()`, synchronously after the re-send, so it is + // the marker that the refresh is fully out — and it is emitted for a + // non-repository too (an unresolvable branch reads back as EMPTY_SYNC_STATE). + // Draining it here is also what makes the counts below MEAN something: the + // re-send then carries no terminal and no branch, so the bus dedups it, and + // the only thing that can move the count afterwards is a pull's recompute. + await waitFor( + () => sent.some((m) => m.type === "git:sync-state"), + "the boot git refresh to land", + ); return { bus, sent }; } diff --git a/bridge/tests/crash-scrubber.test.ts b/bridge/tests/crash-scrubber.test.ts new file mode 100644 index 00000000..bb3ef7e9 --- /dev/null +++ b/bridge/tests/crash-scrubber.test.ts @@ -0,0 +1,280 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import * as Sentry from "@sentry/bun"; +import type { ErrorEvent } from "@sentry/bun"; +import { + EXCLUDED_INTEGRATIONS, + __resetCrashReportingForTest, + captureBridgeError, + flushCrashReports, + initCrashReporting, + scrubCrashEvent, +} from "../src/crash-reporting"; + +/** Minimal well-typed event; each test fills only the field it is about. */ +function evt(fields: Partial): ErrorEvent { + return { type: undefined, ...fields } as ErrorEvent; +} + +describe("scrubCrashEvent", () => { + test("redacts paths in the message and keeps the surrounding prose", () => { + const e = scrubCrashEvent(evt({ message: "Failed reading C:/Users/me/project/secret.ts" })); + expect(e.message).not.toContain("secret.ts"); + expect(e.message).not.toContain("C:/Users"); + expect(e.message).toContain("Failed reading"); + expect(e.message).toContain(""); + }); + + // The primary leak vector: an ENOENT/git error carries the full path in its + // own message, and that is the field a reader would actually look at. + test("redacts paths in exception values", () => { + const e = scrubCrashEvent( + evt({ + exception: { + values: [ + { + type: "Error", + value: "ENOENT: no such file, open '/home/me/proj/secret.ts'", + }, + ], + }, + }), + ); + const value = e.exception!.values![0]!.value!; + expect(value).not.toContain("/home/me/proj"); + expect(value).not.toContain("secret.ts"); + expect(value).toContain("ENOENT: no such file"); + }); + + test("redacts frame paths and DROPS source lines and locals", () => { + const e = scrubCrashEvent( + evt({ + exception: { + values: [ + { + type: "Error", + stacktrace: { + frames: [ + { + filename: "C:\\Users\\me\\proj\\worktree.ts", + abs_path: "C:\\Users\\me\\proj\\worktree.ts", + function: "removeCheckout", + lineno: 42, + context_line: "const secret = readFileSync(userPath);", + pre_context: ["// user source above"], + post_context: ["// user source below"], + vars: { userPath: "/home/me/proj/.env" }, + }, + ], + }, + }, + ], + }, + }), + ); + const frame = e.exception!.values![0]!.stacktrace!.frames![0]!; + expect(frame.filename).toBe(""); + expect(frame.abs_path).toBe(""); + expect(frame.context_line).toBeUndefined(); + expect(frame.pre_context).toBeUndefined(); + expect(frame.post_context).toBeUndefined(); + expect(frame.vars).toBeUndefined(); + // Non-content frame fields are what makes the report readable at all. + expect(frame.function).toBe("removeCheckout"); + expect(frame.lineno).toBe(42); + }); + + test("scrubs thread stacks, which are attached independently of exceptions", () => { + const e = scrubCrashEvent( + evt({ + threads: { + values: [ + { stacktrace: { frames: [{ filename: "/home/me/proj/a.ts", context_line: "secret" }] } }, + ], + }, + }), + ); + const frame = e.threads!.values![0]!.stacktrace!.frames![0]!; + expect(frame.filename).toBe(""); + expect(frame.context_line).toBeUndefined(); + }); + + test("redacts breadcrumb data recursively, including keys, preserving non-strings", () => { + const e = scrubCrashEvent( + evt({ + breadcrumbs: [ + { + message: "opened /home/me/repo/notes.md", + data: { + "/home/me/repo/notes.md": "opened", + nested: { path: "/home/me/repo/x.ts", list: ["/home/me/y.ts"] }, + count: 3, + ok: true, + }, + }, + ], + }), + ); + const crumb = e.breadcrumbs![0]!; + expect(crumb.message).not.toContain("notes.md"); + const data = crumb.data as Record; + expect(Object.keys(data)).toContain(""); + expect(JSON.stringify(data)).not.toContain("/home/me"); + expect(data.count).toBe(3); + expect(data.ok).toBe(true); + }); + + // `logger.ts` drops pino's hostname binding for this exact reason; an event + // that carried the machine name would undo it. + test("replaces server_name and clears user/request", () => { + const e = scrubCrashEvent( + evt({ + server_name: "DESKTOP-0LT318M", + user: { id: "u1", email: "me@example.com" }, + request: { url: "http://127.0.0.1:9/hook", data: "prompt text" }, + }), + ); + expect(e.server_name).toBe(""); + expect(e.user).toBeUndefined(); + expect(e.request).toBeUndefined(); + }); + + test("leaves an event with nothing to scrub untouched", () => { + const e = scrubCrashEvent(evt({ message: "relay handshake timed out" })); + expect(e.message).toBe("relay handshake timed out"); + }); +}); + +// The filter in crash-reporting.ts matches the SDK's own integration `name`s. +// An upstream rename would not fail to compile and would not fail any test that +// only exercises the scrubber — it would just quietly re-enable the integration +// that reads hook request bodies. So assert the names still exist. +test("every excluded integration name still exists in the SDK defaults", () => { + const actual = new Set(Sentry.getDefaultIntegrations({}).map((i) => i.name)); + for (const name of EXCLUDED_INTEGRATIONS) { + expect([name, actual.has(name)]).toEqual([name, true]); + } +}); + +// The gate is the part with a wrong answer that matters: reporting on a user +// who was never asked. Both halves must fail CLOSED independently. +describe("initCrashReporting gate", () => { + const DSN = "https://abc123@example.invalid/1"; + + afterEach(async () => { + __resetCrashReportingForTest(); + await Sentry.close(0); + // `close()` disables the client but leaves it ON THE SCOPE, so a later case + // that never calls `Sentry.init` still reads the previous case's client — + // and its DSN — instead of the "no client" it is asserting about. + Sentry.getCurrentScope().setClient(undefined); + }); + + test("stays off without consent, even with a DSN", () => { + expect(initCrashReporting({ enabled: false, dsn: DSN })).toBe(false); + }); + + test("stays off without a DSN, even with consent", () => { + expect(initCrashReporting({ enabled: true, dsn: "" })).toBe(false); + }); + + test("captures and flushes are inert while off", async () => { + initCrashReporting({ enabled: false, dsn: DSN }); + captureBridgeError(new Error("boom"), "test"); + // Shutdown awaits this on every exit, reporting or not, so an un-consented + // host must get through it without the SDK ever being brought up. + await expect(flushCrashReports(1)).resolves.toBeUndefined(); + expect(Sentry.getClient()).toBeUndefined(); + }); + + test("comes up with consent and a DSN", () => { + expect(initCrashReporting({ enabled: true, dsn: DSN, release: "1.2.3 (abc)" })).toBe(true); + expect(Sentry.getClient()).toBeDefined(); + }); + + // Measured, not hypothetical: the JS SDKs reject a DSN whose project id is + // not numeric, and errex issues SLUGS. `Sentry.init` swallows that — no + // throw, no status — and every later capture and flush then succeeds while + // sending nothing, so without this guard the feature ships inert and looks + // healthy. The app is unaffected: sentry-dart takes the last path segment as + // an opaque String. + test("refuses a slug project id instead of reporting success", () => { + const beforeUncaught = process.listeners("uncaughtException").length; + const beforeRejection = process.listeners("unhandledRejection").length; + + expect( + initCrashReporting({ enabled: true, dsn: "https://abc123@example.invalid/antgrid-app" }), + ).toBe(false); + + // Refused BEFORE `Sentry.init` runs, so there is no client and no listener. + // The ordering is the point: init installs both top-level handlers before it + // ever looks at the DSN and nothing takes them off again, so a check made + // afterwards would leave a client that can never transmit owning every fatal + // path in the process. + expect(Sentry.getClient()).toBeUndefined(); + expect(process.listeners("uncaughtException").length).toBe(beforeUncaught); + expect(process.listeners("unhandledRejection").length).toBe(beforeRejection); + }); + + test("a refused DSN leaves capture and flush inert", async () => { + initCrashReporting({ enabled: true, dsn: "https://abc123@example.invalid/antgrid-app" }); + captureBridgeError(new Error("boom"), "test"); + await expect(flushCrashReports(1)).resolves.toBeUndefined(); + }); + + // The reason these two are kept rather than excluded: they are what stamps a + // fatal `handled: false`. Installed EXACTLY once each (our configured copy + // replaces the same-named default rather than doubling the listener), and + // pinned not to exit — an SDK that exits on its own skips the teardown that + // sweeps every PTY, which POSIX has no backstop for. + test("installs exactly one pinned top-level handler of each kind", () => { + const beforeUncaught = process.listeners("uncaughtException").length; + const beforeRejection = process.listeners("unhandledRejection").length; + + initCrashReporting({ enabled: true, dsn: DSN }); + const client = Sentry.getClient()!; + + expect(client.getIntegrationByName("OnUncaughtException")).toBeDefined(); + expect(client.getIntegrationByName("OnUnhandledRejection")).toBeDefined(); + expect(process.listeners("uncaughtException").length).toBe(beforeUncaught + 1); + expect(process.listeners("unhandledRejection").length).toBe(beforeRejection + 1); + }); + + // The payoff of keeping the SDK handler, and the condition it depends on. + // + // Sentry decides whether to exit AT CRASH TIME, by counting the other + // `uncaughtException` listeners — so the contract is not "we configured it + // right" but "index.ts's handler is registered before any crash". Standing + // in a listener for index.ts's is therefore the whole point of this test, not + // a convenience: without one the SDK is the sole listener, takes the fatal + // path, and exits (which is what it does in a bare script, verified). + test("with our handler present, a fatal is captured unhandled and we keep the exit", async () => { + const mechanisms: Array<{ type?: string; handled?: boolean }> = []; + const before = process.listeners("uncaughtException"); + + const ours = () => {}; // stands in for index.ts's shutdown handler + process.on("uncaughtException", ours); + try { + initCrashReporting({ enabled: true, dsn: DSN }); + const sdkOnly = process + .listeners("uncaughtException") + .filter((l) => l !== ours && !before.includes(l)); + expect(sdkOnly).toHaveLength(1); + + Sentry.addEventProcessor((event) => { + const m = event.exception?.values?.[0]?.mechanism; + if (m) mechanisms.push({ type: m.type, handled: m.handled }); + return null; // nothing leaves the process + }); + + (sdkOnly[0] as (e: Error) => void)(new Error("fatal")); + await flushCrashReports(500); + } finally { + process.removeListener("uncaughtException", ours); + } + + // `handled: false` is the entire reason this integration is kept: a + // hand-rolled captureException reports the same fatal as handled/generic. + expect(mechanisms).toEqual([{ type: "auto.node.onuncaughtexception", handled: false }]); + // Reaching this line at all is the other half — the SDK did not exit. + }); +}); diff --git a/bridge/tests/credentials.test.ts b/bridge/tests/credentials.test.ts index c0e15b19..e215b3cc 100644 --- a/bridge/tests/credentials.test.ts +++ b/bridge/tests/credentials.test.ts @@ -65,3 +65,19 @@ test("rejects a non-positive ownerPid", () => { expect(r.success).toBe(false); } }); + +// Consent is optional on the wire (an older app, the CLI, a test sends none) +// and index.ts resolves its ABSENCE to off — so the schema's job is only to +// keep a present value honest, never to supply one. +test("telemetryEnabled is optional and must be a boolean when present", () => { + const base = { firstProject: { projectId: "p", projectPath: "/tmp/p", mode: "local" } }; + // `.success` asserted separately: `.data?.x` is undefined both for a payload + // that parsed WITHOUT the field and for one the schema rejected outright, so + // on its own it cannot tell "optional" from "no longer accepted". + const absent = BootstrapPayloadSchema.safeParse(base); + expect(absent.success).toBe(true); + expect(absent.data?.telemetryEnabled).toBeUndefined(); + expect(BootstrapPayloadSchema.safeParse({ ...base, telemetryEnabled: true }).data?.telemetryEnabled).toBe(true); + expect(BootstrapPayloadSchema.safeParse({ ...base, telemetryEnabled: false }).data?.telemetryEnabled).toBe(false); + expect(BootstrapPayloadSchema.safeParse({ ...base, telemetryEnabled: "yes" }).success).toBe(false); +}); diff --git a/bridge/tests/file-watcher.test.ts b/bridge/tests/file-watcher.test.ts index 58e4480e..1e0502ca 100644 --- a/bridge/tests/file-watcher.test.ts +++ b/bridge/tests/file-watcher.test.ts @@ -75,6 +75,60 @@ describe("FileWatcher", () => { watcher.stop(); }); + // Windows' (and reportedly macOS's) recursive fs.watch reports a `change` + // event with filename === null when its internal notification buffer + // overflows — measured: a burst of ~40 file creations under one new + // directory was enough to drop every per-file event and report only this. + // `flushBatch` must treat that as "something changed, scope unknown" and + // resync the whole tree rather than silently doing nothing. + it("falls back to a full tree resync when the watcher reports an unnamed change", async () => { + const messages: AbMessage[] = []; + const watcher = new FileWatcher( + { id: "test", name: "Test", path: tempDir }, + (msg) => messages.push(msg), + createConnState(), + ); + + // The real callback, not the private field it sets: assigning + // `needsFullResync` by hand asserts only what the test just wrote, and + // deleting the null branch from `handleNativeEvent` left it green. + watcher.handleNativeEvent(null); + + await new Promise((r) => setTimeout(r, 200)); + + expect(messages.some((m) => m.type === "tree:full")).toBe(true); + expect(messages.some((m) => m.type === "tree:update")).toBe(false); + + watcher.stop(); + }); + + // A resync requested while the app is backgrounded must OUTLIVE the drop. + // `flushBatch` consumes the flag before it reaches the suppression gate, so + // returning there without restoring it silently loses the one signal that + // corrects a delta stream whose base is already wrong — and nothing ever + // asks again. + it("keeps a pending resync across a suppressed flush", async () => { + const messages: AbMessage[] = []; + const connState = createConnState(); + const watcher = new FileWatcher( + { id: "test", name: "Test", path: tempDir }, + (msg) => messages.push(msg), + connState, + ); + + connState.appFocusPaused = true; + watcher.handleNativeEvent(null); + await new Promise((r) => setTimeout(r, 200)); + expect(messages.length).toBe(0); + + connState.appFocusPaused = false; + watcher.handleNativeEvent(null); + await new Promise((r) => setTimeout(r, 200)); + expect(messages.some((m) => m.type === "tree:full")).toBe(true); + + watcher.stop(); + }); + it("detects file modifications", async () => { const messages: AbMessage[] = []; const watcher = new FileWatcher( @@ -143,6 +197,89 @@ describe("FileWatcher", () => { watcher.stop(); }); + + it("resolves an absolute path printed by a terminal program to its checkout-relative form", () => { + const messages: AbMessage[] = []; + const watcher = new FileWatcher( + { id: "test", name: "Test", path: tempDir }, + (msg) => messages.push(msg), + createConnState(), + ); + + watcher.handleResolvePathRequest("req-1", join(tempDir, "src", "app.ts")); + + expect(messages.length).toBe(1); + expect(messages[0].type).toBe("file:resolve-path-result"); + if (messages[0].type === "file:resolve-path-result") { + expect(messages[0].requestId).toBe("req-1"); + expect(messages[0].relPath).toBe("src/app.ts"); + expect(messages[0].isDirectory).toBe(false); + } + + watcher.stop(); + }); + + it("resolves a directory path and reports isDirectory", () => { + const messages: AbMessage[] = []; + const watcher = new FileWatcher( + { id: "test", name: "Test", path: tempDir }, + (msg) => messages.push(msg), + createConnState(), + ); + + watcher.handleResolvePathRequest("req-2", join(tempDir, "src")); + + expect(messages[0].type).toBe("file:resolve-path-result"); + if (messages[0].type === "file:resolve-path-result") { + expect(messages[0].relPath).toBe("src"); + expect(messages[0].isDirectory).toBe(true); + } + + watcher.stop(); + }); + + it("refuses a path outside the checkout root", () => { + const messages: AbMessage[] = []; + const watcher = new FileWatcher( + { id: "test", name: "Test", path: tempDir }, + (msg) => messages.push(msg), + createConnState(), + ); + + // A sibling directory that merely shares the checkout root as a string + // prefix — the traversal guard must compare path segments, not strings. + watcher.handleResolvePathRequest("req-3", `${tempDir}-sibling/secret.txt`); + watcher.handleResolvePathRequest("req-4", join(tempDir, "..", "outside.txt")); + + expect(messages.length).toBe(2); + for (const msg of messages) { + expect(msg.type).toBe("file:resolve-path-result"); + if (msg.type === "file:resolve-path-result") { + expect(msg.relPath).toBeNull(); + } + } + + watcher.stop(); + }); + + it("resolves a path already given relative to the checkout", () => { + const messages: AbMessage[] = []; + const watcher = new FileWatcher( + { id: "test", name: "Test", path: tempDir }, + (msg) => messages.push(msg), + createConnState(), + ); + + watcher.handleResolvePathRequest("req-5", "index.ts"); + + expect(messages[0].type).toBe("file:resolve-path-result"); + if (messages[0].type === "file:resolve-path-result") { + expect(messages[0].relPath).toBe("index.ts"); + expect(messages[0].isDirectory).toBe(false); + } + + watcher.stop(); + }); }); describe("FileWatcher pause", () => { diff --git a/bridge/tests/git-branches.test.ts b/bridge/tests/git-branches.test.ts index e5ed4523..6b0285d9 100644 --- a/bridge/tests/git-branches.test.ts +++ b/bridge/tests/git-branches.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { listLocalBranches, checkoutLocalBranch, GitHelperError } from "../src/git-branches"; +import { listLocalBranches, checkoutLocalBranch, listStashes, stashPop, stashDrop, GitHelperError } from "../src/git-branches"; async function run(cwd: string, args: string[]) { const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" }); @@ -180,7 +180,7 @@ describe("git-branches helper", () => { } }); - it("throws CHECKOUT_FAILED on conflicting dirty working tree", async () => { + it("throws DIRTY_WORKTREE, naming the file, on conflicting uncommitted changes", async () => { await run(dir, ["init"]); await run(dir, ["config", "user.email", "test@antgrid.local"]); await run(dir, ["config", "user.name", "Test"]); @@ -204,11 +204,88 @@ describe("git-branches helper", () => { expect(true).toBe(false); } catch (err: any) { expect(err).toBeInstanceOf(GitHelperError); - expect(err.code).toBe("CHECKOUT_FAILED"); + expect(err.code).toBe("DIRTY_WORKTREE"); + expect(err.message).toContain("file.txt"); + expect(err.message).toContain("dev"); } // Verify uncommitted content remains intact const content = Bun.file(join(dir, "file.txt")); expect(await content.text()).toBe("conflicting uncommitted content\n"); }); + + it("stashes conflicting uncommitted changes and switches when stashIfDirty is set", async () => { + await run(dir, ["init"]); + await run(dir, ["config", "user.email", "test@antgrid.local"]); + await run(dir, ["config", "user.name", "Test"]); + // Otherwise a Windows machine's global core.autocrlf rewrites LF -> CRLF + // on checkout, and the content assertions below would be testing git's + // line-ending conversion instead of the stash-and-retry logic. + await run(dir, ["config", "core.autocrlf", "false"]); + writeFileSync(join(dir, "file.txt"), "master content\n"); + await run(dir, ["add", "."]); + await run(dir, ["commit", "-m", "initial"]); + + await run(dir, ["checkout", "-b", "dev"]); + writeFileSync(join(dir, "file.txt"), "dev content\n"); + await run(dir, ["commit", "-am", "dev commit"]); + + const catalog = await listLocalBranches(dir); + const initialBranch = catalog.branches.find((b) => b !== "dev")!; + await run(dir, ["checkout", initialBranch]); + + writeFileSync(join(dir, "file.txt"), "conflicting uncommitted content\n"); + writeFileSync(join(dir, "untracked.txt"), "untracked\n"); + + const res = await checkoutLocalBranch(dir, "dev", { stashIfDirty: true }); + expect(res.current).toBe("dev"); + expect(res.stashed).toBeDefined(); + expect(res.stashed!.branch).toBe(initialBranch); + + // The switch actually landed, on dev's own committed content — the stash + // is not silently reapplied. + const content = await Bun.file(join(dir, "file.txt")).text(); + expect(content).toBe("dev content\n"); + expect(await Bun.file(join(dir, "untracked.txt")).exists()).toBe(false); + + const stashes = await listStashes(dir); + expect(stashes).toHaveLength(1); + expect(stashes[0]!.ref).toBe(res.stashed!.ref); + expect(stashes[0]!.branch).toBe(initialBranch); + }); + + it("pops a stash back onto the branch it came from, and drops it explicitly", async () => { + await run(dir, ["init"]); + await run(dir, ["config", "user.email", "test@antgrid.local"]); + await run(dir, ["config", "user.name", "Test"]); + await run(dir, ["config", "core.autocrlf", "false"]); + writeFileSync(join(dir, "file.txt"), "v1\n"); + await run(dir, ["add", "."]); + await run(dir, ["commit", "-m", "initial"]); + + const initialBranch = (await listLocalBranches(dir)).current!; + await run(dir, ["checkout", "-b", "other"]); + writeFileSync(join(dir, "file.txt"), "other content\n"); + await run(dir, ["commit", "-am", "other commit"]); + await run(dir, ["checkout", initialBranch]); + writeFileSync(join(dir, "file.txt"), "dirty again\n"); + + const res = await checkoutLocalBranch(dir, "other", { stashIfDirty: true }); + expect(res.stashed).toBeDefined(); + + // Popping back onto the branch it was stashed FROM (not wherever HEAD + // happens to be) is what the app's Restore action does — popping onto + // "other" instead would 3-way merge against the wrong base and conflict. + await run(dir, ["checkout", initialBranch]); + await stashPop(dir, res.stashed!.ref); + expect(await listStashes(dir)).toHaveLength(0); + expect(await Bun.file(join(dir, "file.txt")).text()).toBe("dirty again\n"); + + // Drop path: stash again, then discard it instead of restoring. + await run(dir, ["checkout", initialBranch]); + writeFileSync(join(dir, "file.txt"), "dirty once more\n"); + const res2 = await checkoutLocalBranch(dir, "other", { stashIfDirty: true }); + await stashDrop(dir, res2.stashed!.ref); + expect(await listStashes(dir)).toHaveLength(0); + }); }); diff --git a/bridge/tests/git-log.test.ts b/bridge/tests/git-log.test.ts new file mode 100644 index 00000000..d7033936 --- /dev/null +++ b/bridge/tests/git-log.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { getGitLog, getCommitFiles, getCommitFileDiff } from "../src/git-log"; + +async function run(cwd: string, args: string[]) { + const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" }); + await proc.exited; +} + +describe("git-log helper", () => { + let dir: string; + + beforeEach(async () => { + dir = mkdtempSync(join(tmpdir(), "antgrid-log-test-")); + await run(dir, ["init"]); + await run(dir, ["config", "user.email", "test@antgrid.local"]); + await run(dir, ["config", "user.name", "Test"]); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it("returns an empty page with no repository history", async () => { + const res = await getGitLog(dir, 0, 10); + expect(res).toEqual({ commits: [], hasMore: false }); + }); + + it("lists commits newest-first with correct subject/author fields", async () => { + writeFileSync(join(dir, "a.txt"), "1\n"); + await run(dir, ["add", "."]); + await run(dir, ["commit", "-m", "first commit"]); + writeFileSync(join(dir, "a.txt"), "2\n"); + await run(dir, ["add", "."]); + await run(dir, ["commit", "-m", "second commit"]); + + const res = await getGitLog(dir, 0, 10); + expect(res.hasMore).toBe(false); + expect(res.commits).toHaveLength(2); + expect(res.commits[0]!.subject).toBe("second commit"); + expect(res.commits[1]!.subject).toBe("first commit"); + expect(res.commits[0]!.sha).toHaveLength(40); + expect(res.commits[0]!.shortSha.length).toBeGreaterThan(0); + expect(res.commits[0]!.authorName).toBe("Test"); + expect(res.commits[0]!.authorEmail).toBe("test@antgrid.local"); + expect(res.commits[0]!.authorDate.length).toBeGreaterThan(0); + }); + + it("paginates with skip/limit and reports hasMore", async () => { + for (let i = 0; i < 5; i++) { + writeFileSync(join(dir, "a.txt"), `${i}\n`); + await run(dir, ["add", "."]); + await run(dir, ["commit", "-m", `commit ${i}`]); + } + + const page1 = await getGitLog(dir, 0, 2); + expect(page1.commits.map((c) => c.subject)).toEqual(["commit 4", "commit 3"]); + expect(page1.hasMore).toBe(true); + + const page2 = await getGitLog(dir, 2, 2); + expect(page2.commits.map((c) => c.subject)).toEqual(["commit 2", "commit 1"]); + expect(page2.hasMore).toBe(true); + + const page3 = await getGitLog(dir, 4, 2); + expect(page3.commits.map((c) => c.subject)).toEqual(["commit 0"]); + expect(page3.hasMore).toBe(false); + }); + + it("lists a root commit's files against the empty tree", async () => { + writeFileSync(join(dir, "a.txt"), "one\n"); + writeFileSync(join(dir, "b.txt"), "two\n"); + await run(dir, ["add", "."]); + await run(dir, ["commit", "-m", "initial"]); + + const { commits } = await getGitLog(dir, 0, 10); + const files = await getCommitFiles(dir, commits[0]!.sha); + const byPath = Object.fromEntries(files.map((f) => [f.path, f])); + expect(byPath["a.txt"]).toMatchObject({ status: "A", additions: 1, deletions: 0 }); + expect(byPath["b.txt"]).toMatchObject({ status: "A", additions: 1, deletions: 0 }); + }); + + it("reports a modify + a detected rename in one commit", async () => { + writeFileSync(join(dir, "keep.txt"), "same\n"); + writeFileSync(join(dir, "old.txt"), "line one\nline two\nline three\n"); + await run(dir, ["add", "."]); + await run(dir, ["commit", "-m", "initial"]); + + writeFileSync(join(dir, "keep.txt"), "same\nmodified\n"); + await run(dir, ["mv", "old.txt", "new.txt"]); + await run(dir, ["add", "."]); + await run(dir, ["commit", "-m", "modify and rename"]); + + const { commits } = await getGitLog(dir, 0, 10); + const files = await getCommitFiles(dir, commits[0]!.sha); + const byPath = Object.fromEntries(files.map((f) => [f.path, f])); + + expect(byPath["keep.txt"]).toMatchObject({ status: "M" }); + expect(byPath["keep.txt"]!.additions).toBeGreaterThan(0); + expect(byPath["new.txt"]).toMatchObject({ status: "R", oldPath: "old.txt" }); + expect(byPath["old.txt"]).toBeUndefined(); + }); + + it("returns a unified diff for one file within a commit", async () => { + writeFileSync(join(dir, "a.txt"), "one\n"); + await run(dir, ["add", "."]); + await run(dir, ["commit", "-m", "initial"]); + writeFileSync(join(dir, "a.txt"), "one\ntwo\n"); + await run(dir, ["commit", "-am", "add line"]); + + const { commits } = await getGitLog(dir, 0, 10); + const res = await getCommitFileDiff(dir, commits[0]!.sha, "a.txt"); + expect(res.diff).toContain("+two"); + expect(res.additions).toBe(1); + expect(res.deletions).toBe(0); + }); + + it("returns null diff for a path the commit did not touch", async () => { + writeFileSync(join(dir, "a.txt"), "one\n"); + writeFileSync(join(dir, "untouched.txt"), "same\n"); + await run(dir, ["add", "."]); + await run(dir, ["commit", "-m", "initial"]); + writeFileSync(join(dir, "a.txt"), "one\ntwo\n"); + await run(dir, ["commit", "-am", "add line"]); + + const { commits } = await getGitLog(dir, 0, 10); + const res = await getCommitFileDiff(dir, commits[0]!.sha, "untouched.txt"); + expect(res.diff).toBeNull(); + }); +}); diff --git a/bridge/tests/git-sync.test.ts b/bridge/tests/git-sync.test.ts new file mode 100644 index 00000000..3a6e6ecd --- /dev/null +++ b/bridge/tests/git-sync.test.ts @@ -0,0 +1,345 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { classifySyncFailure, gitPull, gitPush, readSyncState } from "../src/git-sync"; +import type { GitSyncFailureKind } from "../src/git-sync"; + +async function run(cwd: string, args: string[]) { + const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" }); + await proc.exited; +} + +async function capture(cwd: string, args: string[]): Promise { + const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" }); + const out = await new Response(proc.stdout).text(); + await proc.exited; + return out.trim(); +} + +async function commit(cwd: string, body: string) { + writeFileSync(join(cwd, "f.txt"), body); + await run(cwd, ["add", "."]); + await run(cwd, ["commit", "-m", body]); +} + +/** Bare repo as `origin` + a clone on `main`. A path remote keeps the whole + * suite offline — no push or fetch here ever leaves the filesystem. Same + * fixture shape as git-branch-remote-state.test.ts. */ +async function makeRepoWithRemote(root: string) { + const bare = join(root, "origin.git"); + const work = join(root, "work"); + await run(root, ["init", "--bare", "-b", "main", bare]); + await run(root, ["clone", bare, work]); + await run(work, ["config", "user.email", "test@antgrid.local"]); + await run(work, ["config", "user.name", "Test"]); + await run(work, ["checkout", "-b", "main"]); + await commit(work, "one"); + await run(work, ["push", "-u", "origin", "main"]); + return { bare, work }; +} + +/** A second clone, used to advance `origin` behind the first one's back. */ +async function makeSecondClone(root: string, bare: string) { + const other = join(root, "other"); + await run(root, ["clone", bare, other]); + await run(other, ["config", "user.email", "other@antgrid.local"]); + await run(other, ["config", "user.name", "Other"]); + return other; +} + +describe("readSyncState", () => { + let dir: string; + beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "antgrid-git-sync-")); }); + afterEach(() => rmSync(dir, { recursive: true, force: true })); + + it("reports a freshly pushed branch as level with its upstream", async () => { + const { work } = await makeRepoWithRemote(dir); + const state = await readSyncState(work); + expect(state).toMatchObject({ + branch: "main", + remote: "origin", + remoteBranch: "main", + ahead: 0, + behind: 0, + hasUpstream: true, + hasRemote: true, + }); + }); + + it("counts local commits as ahead", async () => { + const { work } = await makeRepoWithRemote(dir); + await commit(work, "two"); + await commit(work, "three"); + const state = await readSyncState(work); + expect(state.ahead).toBe(2); + expect(state.behind).toBe(0); + }); + + it("counts fetched-but-unmerged commits as behind", async () => { + const { bare, work } = await makeRepoWithRemote(dir); + const other = await makeSecondClone(dir, bare); + await commit(other, "remote-two"); + await run(other, ["push"]); + + // The counts read `refs/remotes`, so they move on the FETCH — which is the + // documented contract, and why pulling is what refreshes the indicator. + await run(work, ["fetch", "origin", "main"]); + const state = await readSyncState(work); + expect(state.behind).toBe(1); + expect(state.ahead).toBe(0); + }); + + it("reports a branch with no upstream, without inventing counts", async () => { + const { work } = await makeRepoWithRemote(dir); + await run(work, ["checkout", "-b", "feature"]); + await commit(work, "feature-one"); + + const state = await readSyncState(work); + expect(state.hasUpstream).toBe(false); + expect(state.hasRemote).toBe(true); + // `resolvePushTarget` guesses same-name-on-origin, which is what a first + // push would create — but the counts stay 0 rather than being guessed too. + expect(state.remote).toBe("origin"); + expect(state.ahead).toBe(0); + expect(state.behind).toBe(0); + }); + + it("reports a repository with no remote at all", async () => { + const solo = join(dir, "solo"); + await run(dir, ["init", "-b", "main", solo]); + await run(solo, ["config", "user.email", "test@antgrid.local"]); + await run(solo, ["config", "user.name", "Test"]); + await commit(solo, "one"); + + const state = await readSyncState(solo); + expect(state).toMatchObject({ branch: "main", hasRemote: false, hasUpstream: false }); + }); + + it("reports a detached HEAD as having no branch, but keeps hasRemote", async () => { + const { work } = await makeRepoWithRemote(dir); + await commit(work, "two"); + const head = await capture(work, ["rev-parse", "HEAD~1"]); + await run(work, ["checkout", "--detach", head]); + + const state = await readSyncState(work); + // `rev-parse --abbrev-ref` answers the literal "HEAD" here; reporting that + // as a branch name would hand it to push/pull as one. + expect(state.branch).toBeNull(); + expect(state.hasRemote).toBe(true); + }); +}); + +describe("gitPush", () => { + let dir: string; + beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "antgrid-git-push-")); }); + afterEach(() => rmSync(dir, { recursive: true, force: true })); + + it("pushes local commits and clears the ahead count", async () => { + const { work } = await makeRepoWithRemote(dir); + await commit(work, "two"); + + const res = await gitPush(work); + expect(res.success).toBe(true); + expect(res.branch).toBe("main"); + expect(await readSyncState(work)).toMatchObject({ ahead: 0, behind: 0 }); + }); + + it("sets an upstream on a branch that has none", async () => { + const { work } = await makeRepoWithRemote(dir); + await run(work, ["checkout", "-b", "feature"]); + await commit(work, "feature-one"); + + const res = await gitPush(work); + expect(res.success).toBe(true); + expect(await capture(work, ["config", "--get", "branch.feature.remote"])).toBe("origin"); + expect(await readSyncState(work)).toMatchObject({ hasUpstream: true, ahead: 0 }); + }); + + it("refuses a first push when several remotes and no origin make the target a guess", async () => { + const { bare, work } = await makeRepoWithRemote(dir); + const second = join(dir, "second.git"); + await run(dir, ["init", "--bare", "-b", "main", second]); + await run(work, ["remote", "rename", "origin", "alpha"]); + await run(work, ["remote", "add", "beta", second]); + await run(work, ["checkout", "-b", "feature"]); + await commit(work, "feature-one"); + + const res = await gitPush(work); + expect(res.success).toBe(false); + expect(res.failureKind).toBe("ambiguous-remote"); + // Nothing was published to either remote. + expect(await capture(bare, ["for-each-ref", "--format=%(refname:short)", "refs/heads"])) + .not.toContain("feature"); + }); + + it("reports no-remote in a repository with none", async () => { + const solo = join(dir, "solo"); + await run(dir, ["init", "-b", "main", solo]); + await run(solo, ["config", "user.email", "test@antgrid.local"]); + await run(solo, ["config", "user.name", "Test"]); + await commit(solo, "one"); + + const res = await gitPush(solo); + expect(res.success).toBe(false); + expect(res.failureKind).toBe("no-remote"); + }); + + it("reports a detached HEAD rather than pushing from one", async () => { + const { work } = await makeRepoWithRemote(dir); + await commit(work, "two"); + await run(work, ["checkout", "--detach", await capture(work, ["rev-parse", "HEAD"])]); + + const res = await gitPush(work); + expect(res.success).toBe(false); + expect(res.failureKind).toBe("detached"); + expect(res.branch).toBeNull(); + }); + + it("returns the rejection intact when the remote has moved on, and force-pushes nothing", async () => { + const { bare, work } = await makeRepoWithRemote(dir); + const other = await makeSecondClone(dir, bare); + await commit(other, "remote-two"); + await run(other, ["push"]); + const remoteHead = await capture(other, ["rev-parse", "HEAD"]); + + await commit(work, "local-two"); + const localHead = await capture(work, ["rev-parse", "HEAD"]); + + const res = await gitPush(work); + expect(res.success).toBe(false); + expect(res.failureKind).toBe("not-fast-forward"); + // The two halves the agent handoff forwards. + expect(res.command).toContain("git push"); + expect(res.stderr && res.stderr.length).toBeGreaterThan(0); + // The remote still holds the OTHER clone's commit — nothing was forced over + // it — and the local branch is untouched. + expect(await capture(bare, ["rev-parse", "refs/heads/main"])).toBe(remoteHead); + expect(await capture(work, ["rev-parse", "HEAD"])).toBe(localHead); + }); +}); + +describe("gitPull", () => { + let dir: string; + beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "antgrid-git-pull-")); }); + afterEach(() => rmSync(dir, { recursive: true, force: true })); + + it("fast-forwards onto the remote's new commits", async () => { + const { bare, work } = await makeRepoWithRemote(dir); + const other = await makeSecondClone(dir, bare); + await commit(other, "remote-two"); + await run(other, ["push"]); + const remoteHead = await capture(other, ["rev-parse", "HEAD"]); + + const res = await gitPull(work); + expect(res.success).toBe(true); + expect(await capture(work, ["rev-parse", "HEAD"])).toBe(remoteHead); + expect(await readSyncState(work)).toMatchObject({ ahead: 0, behind: 0 }); + }); + + it("reports already-up-to-date without moving HEAD", async () => { + const { work } = await makeRepoWithRemote(dir); + const before = await capture(work, ["rev-parse", "HEAD"]); + + const res = await gitPull(work); + expect(res.success).toBe(true); + expect(res.summary).toBe("Already up to date"); + expect(await capture(work, ["rev-parse", "HEAD"])).toBe(before); + }); + + it("leaves HEAD and the worktree byte-identical on a diverged branch", async () => { + const { bare, work } = await makeRepoWithRemote(dir); + const other = await makeSecondClone(dir, bare); + await commit(other, "remote-two"); + await run(other, ["push"]); + + await commit(work, "local-two"); + const before = await capture(work, ["rev-parse", "HEAD"]); + const beforeFile = readFileSync(join(work, "f.txt"), "utf8"); + + const res = await gitPull(work); + expect(res.success).toBe(false); + expect(res.failureKind).toBe("diverged"); + // The whole safety property of --ff-only: no merge commit, no rebase in + // progress, no conflict markers written into the tree. + expect(await capture(work, ["rev-parse", "HEAD"])).toBe(before); + expect(readFileSync(join(work, "f.txt"), "utf8")).toBe(beforeFile); + expect(await capture(work, ["status", "--porcelain"])).toBe(""); + }); + + it("refuses while the checkout holds unresolved merge conflicts", async () => { + const { work } = await makeRepoWithRemote(dir); + await run(work, ["checkout", "-b", "side"]); + await commit(work, "side-change"); + await run(work, ["checkout", "main"]); + await commit(work, "main-change"); + await run(work, ["merge", "side"]); + + const res = await gitPull(work); + expect(res.success).toBe(false); + expect(res.failureKind).toBe("conflict"); + }); + + it("reports a detached HEAD rather than pulling onto one", async () => { + const { work } = await makeRepoWithRemote(dir); + await run(work, ["checkout", "--detach", await capture(work, ["rev-parse", "HEAD"])]); + + const res = await gitPull(work); + expect(res.success).toBe(false); + expect(res.failureKind).toBe("detached"); + }); +}); + +describe("classifySyncFailure", () => { + // Every string here is real git output. The point of the table is that these + // keep classifying correctly as git rewords itself between versions — which + // is also why the app is never allowed to parse them itself. + const cases: Array<[string, GitSyncFailureKind]> = [ + [ + "! [rejected] main -> main (non-fast-forward)\nerror: failed to push some refs to '/tmp/origin.git'", + "not-fast-forward", + ], + [ + "! [rejected] main -> main (fetch first)\nerror: failed to push some refs", + "not-fast-forward", + ], + ["fatal: Not possible to fast-forward, aborting.", "diverged"], + [ + "fatal: Need to specify how to reconcile divergent branches.", + "diverged", + ], + [ + "fatal: could not read Username for 'https://github.com': terminal prompts disabled", + "auth", + ], + ["git@github.com: Permission denied (publickey).", "auth"], + ["remote: Invalid username or token. Password authentication is not supported.", "auth"], + [ + "error: Your local changes to the following files would be overwritten by merge:\n\tf.txt", + "dirty-tree", + ], + ["fatal: repository 'https://example.invalid/x.git' not found", "no-remote"], + ["fatal: The current branch feature has no upstream branch.", "no-upstream"], + ["", "unknown"], + ]; + + for (const [stderr, expected] of cases) { + it(`classifies ${JSON.stringify(stderr.slice(0, 44))} as ${expected}`, () => { + expect(classifySyncFailure(stderr, 1)).toBe(expected); + }); + } + + it("classifies the timeout sentinel as unknown", () => { + // A hung credential prompt and a black-holed host are indistinguishable + // from here, so `unknown` is the honest answer rather than a guess. + expect(classifySyncFailure("git push exceeded 120000ms", 124)).toBe("unknown"); + }); + + it("prefers the non-fast-forward reading over the bare rejection", () => { + // Both words appear in the same stderr; the more specific one is what the + // app branches its copy on. + expect( + classifySyncFailure("! [rejected] main -> main (non-fast-forward)", 1), + ).toBe("not-fast-forward"); + }); +}); diff --git a/bridge/tests/git.test.ts b/bridge/tests/git.test.ts index b0034174..7640db35 100644 --- a/bridge/tests/git.test.ts +++ b/bridge/tests/git.test.ts @@ -51,6 +51,28 @@ describe("git helpers", () => { }); }); + it("reports each file in a wholly-untracked directory individually, not the collapsed dir entry", async () => { + mkdirSync(join(dir, "newdir")); + writeFileSync(join(dir, "newdir", "a.txt"), "a\n"); + writeFileSync(join(dir, "newdir", "b.txt"), "b\n"); + const status = await getGitStatus(dir); + expect(status.map((e) => e.path)).not.toContain("newdir/"); + expect(status).toContainEqual({ + path: "newdir/a.txt", + status: "U", + staged: false, + additions: 1, + deletions: 0, + }); + expect(status).toContainEqual({ + path: "newdir/b.txt", + status: "U", + staged: false, + additions: 1, + deletions: 0, + }); + }); + it("gitStage moves a modified file into the staged bucket", async () => { writeFileSync(join(dir, "tracked.txt"), "v2\n"); const res = await gitStage(dir, ["tracked.txt"]); diff --git a/bridge/tests/handler/decision.test.ts b/bridge/tests/handler/decision.test.ts index ac0eec06..d85a1bf4 100644 --- a/bridge/tests/handler/decision.test.ts +++ b/bridge/tests/handler/decision.test.ts @@ -6,6 +6,7 @@ import { buildRetryPrompt, buildShapeRetryPrompt, parseDecisionFromOutput, + PERSONALITY_RULES, } from "../../src/handler/decision"; const GOAL = "Migrating auth"; @@ -344,3 +345,59 @@ describe("parseDecisionFromOutput", () => { expect(buildRetryPrompt("ORIG", r.error!)).toContain("ORIG"); }); }); + +describe("posture in the decide prompt", () => { + const build = (personality?: "watchdog" | "closer" | "autopilot") => + buildDecidePrompt({ goal: GOAL, backlogText: BACKLOG_TEXT, context: "ctx", personality }); + + it("judges under the cautious preset when the caller names none", () => { + expect(build()).toContain(build("watchdog").split("POSTURE")[1]); + }); + + it("prints exactly one posture", () => { + const p = build("closer"); + expect(p.match(/POSTURE/g)).toHaveLength(1); + expect(p).not.toContain("Escalate freely"); + expect(p).not.toContain("Handle wherever you can"); + }); + + // The two rules a preset may never read as permission to override. Ordering is + // the whole guard: printed above the posture they frame it, printed below it + // they read as exceptions to it. + it("prints the posture below the rules it is subordinate to", () => { + const p = build("autopilot"); + expect(p.indexOf("Escalating always trumps making progress")).toBeLessThan(p.indexOf("POSTURE")); + expect(p.indexOf("If you cannot answer with high confidence, escalate")).toBeLessThan(p.indexOf("POSTURE")); + }); + + // The widest preset is the one that could plausibly be written as a licence to + // guess. It must say the opposite in its own words, not merely inherit it. + it("keeps the confidence floor inside the most permissive preset", () => { + expect(build("autopilot")).toContain("it does not lower the confidence floor"); + }); + + // Evidence is the anti-inflation guard; a posture that could soften it would let + // the confident presets close items on belief. + it("says nothing about what evidence a transition needs", () => { + for (const rule of Object.values(PERSONALITY_RULES)) { + expect(rule).not.toContain("evidence"); + expect(rule).not.toContain("transition"); + } + }); + + // The rules list is one argument read top to bottom; a posture spliced into the + // middle of it separates `reply`/`action` from the rules they belong with. + it("leaves the rules list unbroken", () => { + const p = build("closer"); + expect(p.indexOf("Set either `reply` or `action`")).toBeLessThan(p.indexOf("POSTURE")); + }); + + // Both retry legs append to the original prompt rather than rebuilding one, so + // the posture rides through for free — asserted because a future retry that + // composed its own prompt would drop it silently. + it("survives both retry legs", () => { + const p = build("closer"); + expect(buildRetryPrompt(p, "bad json")).toContain("POSTURE"); + expect(buildShapeRetryPrompt(p, "two moves")).toContain("POSTURE"); + }); +}); diff --git a/bridge/tests/handler/engine.test.ts b/bridge/tests/handler/engine.test.ts index c62e2e00..72cbc209 100644 --- a/bridge/tests/handler/engine.test.ts +++ b/bridge/tests/handler/engine.test.ts @@ -260,6 +260,65 @@ test("decision runs on the session judge, falling back to the session's own tool expect(calls[1]).toEqual({ tool: "claude-code", model: undefined }); }); +test("arm persists the posture and reports it on the snapshot", () => { + const saved: HandlerSessionRecord[] = []; + const sent: AbMessage[] = []; + const { engine } = makeEngine({ saveSessionFn: (r: HandlerSessionRecord) => saved.push(r), sendAb: (m: AbMessage) => sent.push(m) }); + engine.arm({ terminalId: "t1", goal: GOAL, personality: "autopilot" }); + expect(saved.at(-1)?.personality).toBe("autopilot"); + const status = sent.filter((m) => m.type === "handler:status").at(-1) as never as { + sessions: Array<{ personality?: string }>; + }; + expect(status.sessions[0].personality).toBe("autopilot"); +}); + +// The app renders its picker straight off this field, so a session that has +// never been given a posture must still report the one it judges under — an +// absent value would leave the picker blank over a judge already running. +test("a session that never picked a posture still reports the default", () => { + const sent: AbMessage[] = []; + const { engine } = makeEngine({ sendAb: (m: AbMessage) => sent.push(m) }); + engine.arm({ terminalId: "t1", goal: GOAL }); + const status = sent.filter((m) => m.type === "handler:status").at(-1) as never as { + sessions: Array<{ personality?: string }>; + }; + expect(status.sessions[0].personality).toBe("watchdog"); +}); + +// Absent-keeps, the same rule the judge fields follow: a backlog edit and a +// goal edit both re-arm carrying no posture, and neither may reset one. +test("a re-arm carrying no posture keeps the stored one", () => { + const saved: HandlerSessionRecord[] = []; + const { engine } = makeEngine({ saveSessionFn: (r: HandlerSessionRecord) => saved.push(r) }); + engine.arm({ terminalId: "t1", goal: GOAL, personality: "closer" }); + engine.arm({ terminalId: "t1", goal: GOAL }); + expect(saved.at(-1)?.personality).toBe("closer"); +}); + +test("bridge-restart re-arm keeps the persisted posture", () => { + const saved: HandlerSessionRecord[] = []; + const { engine } = makeEngine({ + saveSessionFn: (r: HandlerSessionRecord) => saved.push(r), + loadSessionFn: () => sessionRecord({ personality: "autopilot" }), + }); + engine.arm({ terminalId: "t1", goal: GOAL }); + expect(saved.at(-1)?.personality).toBe("autopilot"); +}); + +test("the judge is never asked to decide without a posture", async () => { + const calls: { personality?: string }[] = []; + const { engine } = makeEngine({ + runDecisionFn: async (o: { personality?: string }) => { calls.push({ personality: o.personality }); return continueDecision; }, + }); + engine.arm({ terminalId: "t1", goal: GOAL, personality: "closer" }); + await engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + expect(calls[0]?.personality).toBe("closer"); + + engine.arm({ terminalId: "t2", goal: GOAL }); + await engine.handleEvent({ terminalId: "t2", event: "turn_end" }); + expect(calls[1]?.personality).toBe("watchdog"); +}); + test("bridge-restart re-arm keeps the persisted judge when the arm carries none", () => { const saved: HandlerSessionRecord[] = []; const { engine } = makeEngine({ diff --git a/bridge/tests/handler/entitlement-gate.test.ts b/bridge/tests/handler/entitlement-gate.test.ts index fad1fb26..bb26b0f2 100644 --- a/bridge/tests/handler/entitlement-gate.test.ts +++ b/bridge/tests/handler/entitlement-gate.test.ts @@ -73,7 +73,10 @@ function credentialed(tier: string | null): () => TierClaim { return () => ({ credentialed: true, tier }); } -interface StatusFrame { sessions: Array<{ terminalId: string; state: string }> } +interface StatusFrame { + sessions: Array<{ terminalId: string; state: string }>; + entitlement?: { reason: string; tier?: string }; +} function lastStatus(sent: AbMessage[]): StatusFrame { return sent.filter((m) => m.type === "handler:status").at(-1) as never as StatusFrame; } @@ -228,3 +231,64 @@ describe("the local/offline developer flow", () => { expect(lastStatus(sent).sessions).toHaveLength(1); }); }); + +describe("what the app is told about a refusal", () => { + // The gate's own behaviour is above; this is the half the USER meets. A + // refusal that only reaches the bridge log is indistinguishable from a tap + // that never registered, which is the failure these assert against. + + it("says nothing on an entitled machine", () => { + const { engine, sent } = makeEngine(credentialed("pro")); + engine.arm({ terminalId: "t1", goal: GOAL }); + // Presence IS the refusal, so an available Handler must not send the key + // at all — an app reading it as a gate would refuse every arm on a paid + // account. + expect(lastStatus(sent).entitlement).toBeUndefined(); + }); + + it("says nothing on a machine with no credentials to fail closed on", () => { + const { engine, sent } = makeEngine(() => ({ credentialed: false, tier: null })); + engine.arm({ terminalId: "t1", goal: GOAL }); + // `unwired` is an ALLOWED verdict. Reporting it would gate the offline + // developer flow the gate deliberately exempts. + expect(lastStatus(sent).entitlement).toBeUndefined(); + }); + + it("names the paywall and the plan the machine is on", async () => { + const { engine, sent } = makeEngine(credentialed("free")); + await capturingWarnings(() => { engine.arm({ terminalId: "t1", goal: GOAL }); }); + // The tier rides along so the app can say "you are on Free" rather than + // only "you need Pro" — the second leaves the user unable to tell whether + // they already bought it. + expect(lastStatus(sent).entitlement).toEqual({ reason: "not_entitled", tier: "free" }); + }); + + it("distinguishes an unreadable claim from the paywall, and names no tier", async () => { + const { engine, sent } = makeEngine(credentialed(null)); + await capturingWarnings(() => { engine.arm({ terminalId: "t1", goal: GOAL }); }); + // Two different fixes: this one is answered by signing in again, and an + // upgrade offer here would sell a plan the user may already hold. There is + // no tier to name — being unable to read one is the whole condition. + expect(lastStatus(sent).entitlement).toEqual({ reason: "unreadable" }); + }); + + it("rides every emit, not only the one the refused arm raises", () => { + // The shield the user has yet to press is the surface that most needs it, + // and it is on screen long before any arm. + const { engine, sent } = makeEngine(credentialed("free")); + engine.emitStatus(); + expect(lastStatus(sent).entitlement).toEqual({ reason: "not_entitled", tier: "free" }); + }); + + it("stops being sent the moment the tier grants again", () => { + // Derived per emit rather than latched at the refusal: an app told once + // that Handler is gated has no other way to learn it no longer is. + let tier = "free"; + const { engine, sent } = makeEngine(() => ({ credentialed: true, tier })); + engine.emitStatus(); + expect(lastStatus(sent).entitlement).toEqual({ reason: "not_entitled", tier: "free" }); + tier = "pro"; + engine.emitStatus(); + expect(lastStatus(sent).entitlement).toBeUndefined(); + }); +}); diff --git a/bridge/tests/submit-keystroke.test.ts b/bridge/tests/submit-keystroke.test.ts index fac40620..1dfda11a 100644 --- a/bridge/tests/submit-keystroke.test.ts +++ b/bridge/tests/submit-keystroke.test.ts @@ -1,5 +1,11 @@ import { expect, test } from "bun:test"; -import { hasTypedContent, isInterruptKeystroke, isSubmitKeystroke, submittedLine } from "../src/keystrokes"; +import { + hasTypedContent, + isInterruptKeystroke, + isSubmitKeystroke, + isTerminalReport, + submittedLine, +} from "../src/keystrokes"; // Gates the work-status turn inference for agents with no pre-turn hook. A false // positive opens a turn nothing will close, so the negatives matter more than the @@ -68,6 +74,38 @@ test("ordinary keys and an empty payload are not an interrupt", () => { } }); +// Gates the "not a user reply" branch in agent-core's terminal:input handler. +// A false negative is what let a window focus-change clear a blocked session's +// "needs you" dot; a false positive would silently drop real typing. + +test("focus and mouse reports are the terminal answering, not a reply", () => { + for (const data of [ + "\x1b[I", // DEC 1004 focus gained + "\x1b[O", // DEC 1004 focus lost + "\x1b[<0;12;7M", // SGR press + "\x1b[<0;12;7m", // SGR release + "\x1b[<64;1;1M", // SGR wheel + "\x1b[M !!", // X10, three trailing bytes + ]) { + expect(isTerminalReport(data)).toBe(true); + } +}); + +test("typed input is never mistaken for a report", () => { + for (const data of [ + "a", + "\r", + "\x1b", + "\x1b[A", // arrow up + "\x1b[Ihello", // a report the user typed through + "\x1b[13;2u", // kitty shift+enter + "\x1b[2~", // insert + "", + ]) { + expect(isTerminalReport(data)).toBe(false); + } +}); + // Splits the one shape a guest tokenizer absorbs the CR into. Everything else is // written through untouched, so the negatives are what keep an ordinary keystroke // off the deferred-CR path. See pty-submit.ts for what the split buys. diff --git a/bridge/tests/tunnel-manager-ws-order.test.ts b/bridge/tests/tunnel-manager-ws-order.test.ts new file mode 100644 index 00000000..ddddbbf4 --- /dev/null +++ b/bridge/tests/tunnel-manager-ws-order.test.ts @@ -0,0 +1,231 @@ +import { expect, test } from "bun:test"; +import { createConnState } from "../src/conn-state"; +import { TunnelManager } from "../src/tunnel-manager"; + +function startEchoServer() { + return Bun.serve({ + port: 0, + fetch(req, server) { + if (server.upgrade(req)) return; + return new Response("upgrade required", { status: 426 }); + }, + websocket: { + message(ws, data) { + ws.send(data); + }, + }, + }); +} + +function makeManager(opts: { wsPreopenTtlMs?: number } = {}) { + const sent: Record[] = []; + const manager = new TunnelManager({ + projectId: "project", + portLabels: new Map(), + previewPorts: new Set(), + sendTunnel: (data) => sent.push(data as Record), + sendEncrypted: () => {}, + relayHost: "relay.test", + connState: createConnState(), + ...opts, + }); + return { manager, sent }; +} + +async function waitUntil(condition: () => boolean): Promise { + const deadline = Date.now() + 2_000; + while (!condition()) { + if (Date.now() > deadline) throw new Error("condition was not met"); + await Bun.sleep(10); + } +} + +test("data arriving before open is replayed upstream in order", async () => { + const server = startEchoServer(); + const { manager, sent } = makeManager(); + try { + manager.onWsData({ + type: "tunnel:ws-data", + tunnelId: "early", + data: "signalr-handshake", + checkoutId: "main", + }); + manager.onWsData({ + type: "tunnel:ws-data", + tunnelId: "early", + data: Buffer.from([0, 1, 2, 255]).toString("base64"), + binary: true, + checkoutId: "main", + }); + manager.onWsOpen({ + type: "tunnel:ws-open", + tunnelId: "early", + port: server.port!, + scheme: "http", + path: "/", + checkoutId: "main", + }); + + await waitUntil( + () => sent.filter((m) => m.type === "tunnel:ws-data").length === 2, + ); + const frames = sent.filter((m) => m.type === "tunnel:ws-data"); + expect(frames[0]).toMatchObject({ data: "signalr-handshake" }); + expect(frames[0].binary).toBeUndefined(); + expect(frames[1]).toMatchObject({ data: "AAEC/w==", binary: true }); + } finally { + manager.stop(); + server.stop(true); + } +}); + +test("an open that misses the pre-open TTL is refused, not started mid-stream", async () => { + const server = startEchoServer(); + const { manager, sent } = makeManager({ wsPreopenTtlMs: 20 }); + try { + manager.onWsData({ + type: "tunnel:ws-data", + tunnelId: "expired", + data: "stale", + checkoutId: "main", + }); + await Bun.sleep(50); + manager.onWsOpen({ + type: "tunnel:ws-open", + tunnelId: "expired", + port: server.port!, + scheme: "http", + path: "/", + checkoutId: "main", + }); + + await waitUntil(() => sent.some((m) => m.type === "tunnel:ws-close")); + // The lost prefix must reach the browser as a close it can reconnect from. + // Relaying the tail into a live upstream is the failure this guards. + expect(sent.filter((m) => m.type === "tunnel:ws-data")).toHaveLength(0); + + // And the refusal is durable: frames still in flight behind the open must + // not quietly start a second, tail-only buffer for the same tunnelId. + manager.onWsData({ + type: "tunnel:ws-data", + tunnelId: "expired", + data: "post-expiry", + checkoutId: "main", + }); + manager.onWsOpen({ + type: "tunnel:ws-open", + tunnelId: "expired", + port: server.port!, + scheme: "http", + path: "/", + checkoutId: "main", + }); + await Bun.sleep(50); + expect(sent.filter((m) => m.type === "tunnel:ws-data")).toHaveLength(0); + expect(sent.filter((m) => m.type === "tunnel:ws-close")).toHaveLength(2); + } finally { + manager.stop(); + server.stop(true); + } +}); + +test("a pre-open buffer that overflows refuses its open rather than splicing", async () => { + const server = startEchoServer(); + const { manager, sent } = makeManager(); + try { + // 1 MB ceiling: the first frame is over it on its own, so the frames that + // follow are a stream missing its head. + manager.onWsData({ + type: "tunnel:ws-data", + tunnelId: "overflow", + data: "x".repeat(1024 * 1024 + 10), + checkoutId: "main", + }); + for (const data of ["frame-2", "frame-3"]) { + manager.onWsData({ + type: "tunnel:ws-data", + tunnelId: "overflow", + data, + checkoutId: "main", + }); + } + manager.onWsOpen({ + type: "tunnel:ws-open", + tunnelId: "overflow", + port: server.port!, + scheme: "http", + path: "/", + checkoutId: "main", + }); + + await waitUntil(() => sent.some((m) => m.type === "tunnel:ws-close")); + await Bun.sleep(50); + expect(sent.filter((m) => m.type === "tunnel:ws-data")).toHaveLength(0); + } finally { + manager.stop(); + server.stop(true); + } +}); + +test("closed tunnels do not starve a live one out of the pre-open table", async () => { + const server = startEchoServer(); + const { manager, sent } = makeManager(); + try { + // A dev server in a reconnect loop churns a fresh tunnelId per attempt. + for (let i = 0; i < 200; i++) { + manager.onWsData({ + type: "tunnel:ws-data", + tunnelId: `dead-${i}`, + data: "x".repeat(1024 * 1024 + 10), // poisons its tunnel immediately + checkoutId: "main", + }); + } + manager.onWsData({ + type: "tunnel:ws-data", + tunnelId: "live", + data: "signalr-handshake", + checkoutId: "main", + }); + manager.onWsOpen({ + type: "tunnel:ws-open", + tunnelId: "live", + port: server.port!, + scheme: "http", + path: "/", + checkoutId: "main", + }); + + await waitUntil(() => sent.some((m) => m.type === "tunnel:ws-data")); + expect(sent.filter((m) => m.type === "tunnel:ws-data")).toMatchObject([ + { tunnelId: "live", data: "signalr-handshake" }, + ]); + } finally { + manager.stop(); + server.stop(true); + } +}); + +test("stop() closes tunnels the app still believes are live", async () => { + const server = startEchoServer(); + const { manager, sent } = makeManager(); + try { + manager.onWsOpen({ + type: "tunnel:ws-open", + tunnelId: "live", + port: server.port!, + scheme: "http", + path: "/", + checkoutId: "main", + }); + // Deliberately NOT awaiting the upstream handshake: a session deleted + // while a preview page is mid-connect is the case where the socket's own + // close event never fires, so stop() has to send the frame itself. + manager.stop(); + + expect(sent.filter((m) => m.type === "tunnel:ws-close")).toMatchObject([ + { tunnelId: "live" }, + ]); + } finally { + server.stop(true); + } +}); diff --git a/bun.lock b/bun.lock index 9ad95b43..49815248 100644 --- a/bun.lock +++ b/bun.lock @@ -16,6 +16,7 @@ "@anthropic-ai/claude-agent-sdk": "0.3.201", "@inquirer/prompts": "^8.4.2", "@opencode-ai/sdk": "1.15.10", + "@sentry/bun": "^10.70.0", "@xterm/addon-serialize": "^0.14.0", "@xterm/headless": "^6.0.0", "antgrid-wire": "workspace:*", @@ -139,6 +140,12 @@ "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.105.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-sDyu+aM9cE6uZE+HgRjjHRb+qqb87GHZOx+8bE0YlWetdL1YcVLxn8h9ltxGOflyChTe6PMEo50kMQV4cw0hfg=="], + "@apm-js-collab/code-transformer": ["@apm-js-collab/code-transformer@0.18.1", "", { "dependencies": { "@types/estree": "^1.0.8", "astring": "^1.9.0", "esquery": "^1.7.0", "meriyah": "^6.1.4", "semifies": "^1.0.0", "source-map": "^0.6.0" }, "bin": { "code-transformer": "cli.js" } }, "sha512-u1Hb6bHjWtkSpiprwVP6YaHC1DTN4RAU3zYkUDUe7WMnJwdyU1pwTL9dFKiSJB9IiLue/EQovmyx6xhU7FFtAQ=="], + + "@apm-js-collab/code-transformer-bundler-plugins": ["@apm-js-collab/code-transformer-bundler-plugins@0.7.4", "", { "dependencies": { "@apm-js-collab/code-transformer": "^0.18.1", "es-module-lexer": "^2.1.0", "magic-string": "^0.30.21", "module-details-from-path": "^1.0.4" } }, "sha512-nAfOeZPSUAQvJa1iFT/5oCrTm5YQhMMrfCNthNnaXHZiOQhu1KGuLoIx7HtbAi3wfwaBYLaICPIeenIaEwcXIg=="], + + "@apm-js-collab/tracing-hooks": ["@apm-js-collab/tracing-hooks@0.13.0", "", { "dependencies": { "@apm-js-collab/code-transformer": "^0.18.0", "debug": "^4.4.1", "module-details-from-path": "^1.0.4" } }, "sha512-mTvWz9rnQwx1U3h0XPTHaX7bgfkpipLLTQyjlC2cdhQpQEuoLT0AGzoydeoq2NxfEVv6fWOOETcSbb2nptleyw=="], + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], @@ -411,6 +418,20 @@ "@opencode-ai/sdk": ["@opencode-ai/sdk@1.15.10", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-CUhpmMGGOqzvPnNNjjWmEIodAfP6Qnuki2ChIUKWYF7UImZ4zUcMZnzO5BtUxu/Ni1P8qzWxDioXs+7aIZQEhA=="], + "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], + + "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.220.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w=="], + + "@opentelemetry/core": ["@opentelemetry/core@2.10.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ=="], + + "@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.220.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.220.0", "import-in-the-middle": "^3.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw=="], + + "@opentelemetry/resources": ["@opentelemetry/resources@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA=="], + + "@opentelemetry/sdk-trace": ["@opentelemetry/sdk-trace@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-MfQGq3GRmTh5fM/y+OjaO0vj6+luCB1XO2gfXCalKCfgKw0eHL++sm75DNweC6ohlp+aFvACqeE0fYayqdRaoQ=="], + + "@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0", "@opentelemetry/sdk-trace": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-GuYQQT7QD2EeO8lcZLRQzcbOyhqAzL+6WWTKTU9mSUBYBazkEDl+VrQcXQhbB08OWM9anD1aHleVadzulpOaUQ=="], + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], "@paddle/paddle-js": ["@paddle/paddle-js@1.6.4", "", {}, "sha512-ncfnS6I8mCX6krZ3Sgz2iAYivGmhdI81yt9mT6prtPj4Ipd9J3M12LCJRUFL4FB7BYeeuV04c33RSEnbZUBCaA=="], @@ -485,6 +506,20 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.2", "", { "os": "win32", "cpu": "x64" }, "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA=="], + "@sentry/bun": ["@sentry/bun@10.70.0", "", { "dependencies": { "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.3", "@sentry/conventions": "^0.16.0", "@sentry/core": "10.70.0", "@sentry/node": "10.70.0", "@sentry/server-utils": "10.70.0" } }, "sha512-0Lf/VJVVJNoVHZgHQAG9BuX1Uajhwxg58gelwRTx0yWoTRwJmd0uyNcqoh+XHR5oJcxex6QZJc16trNK4dY20Q=="], + + "@sentry/conventions": ["@sentry/conventions@0.16.0", "", {}, "sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ=="], + + "@sentry/core": ["@sentry/core@10.70.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0" } }, "sha512-ozhCTDqg89oB4XmWfAwuHshABpvT7AkRpaPnogopPfMAaI61G1t8EKCJ4W7aum8JSBonlfyjPCyW5oYZFm0KvA=="], + + "@sentry/node": ["@sentry/node@10.70.0", "", { "dependencies": { "@opentelemetry/api": "^1.9.1", "@opentelemetry/instrumentation": "^0.220.0", "@opentelemetry/sdk-trace-base": "^2.9.0", "@sentry/conventions": "^0.16.0", "@sentry/core": "10.70.0", "@sentry/node-core": "10.70.0", "@sentry/opentelemetry": "10.70.0", "@sentry/server-utils": "10.70.0", "import-in-the-middle": "^3.0.0" } }, "sha512-SPOOVxmKTVIEtqvOKkQT163e/pOwucjS7OPsCHyRs8sFR4nfBNu0EThplyqnvqd5BWBMTPH6WTBQfo+QWHV+HA=="], + + "@sentry/node-core": ["@sentry/node-core@10.70.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0", "@sentry/core": "10.70.0", "@sentry/opentelemetry": "10.70.0", "import-in-the-middle": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1", "@opentelemetry/instrumentation": ">=0.57.1 <1", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/core", "@opentelemetry/exporter-trace-otlp-http", "@opentelemetry/instrumentation", "@opentelemetry/sdk-trace-base"] }, "sha512-oPOEVVNxv5WHtckx2i06Wi9FLWyvOg/1DUeX732jZ4iqT2nupINaMH4nF4f4kSvUThFnxkFSRQxwqOxgzMKhKA=="], + + "@sentry/opentelemetry": ["@sentry/opentelemetry@10.70.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0", "@sentry/core": "10.70.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" } }, "sha512-UNV/2tqypcUK6FDzerAsFJn1Km/c4VZCYkUZDNbnV5S0cwAq2BYKMo4M5vovaLDBQlxA+Wk9ovbxi5wYjjl9fw=="], + + "@sentry/server-utils": ["@sentry/server-utils@10.70.0", "", { "dependencies": { "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.3", "@apm-js-collab/tracing-hooks": "^0.13.0", "@sentry/conventions": "^0.16.0", "@sentry/core": "10.70.0", "meriyah": "^6.1.4" } }, "sha512-rzegZjMFFgCp3o+N8+XU13rfSvz4B+f8rU0ijBGrQcHdMNyfsFDTu1UTm262JofmrV2u+s+D0u0vFTnqtOGkbA=="], + "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], @@ -553,6 +588,8 @@ "antgrid-wire": ["antgrid-wire@workspace:packages/antgrid-wire"], + "astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="], + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], @@ -607,6 +644,8 @@ "citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="], + "cjs-module-lexer": ["cjs-module-lexer@2.2.1", "", {}, "sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q=="], + "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], @@ -707,6 +746,8 @@ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], @@ -717,6 +758,10 @@ "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], @@ -811,6 +856,8 @@ "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + "import-in-the-middle": ["import-in-the-middle@3.3.3", "", { "dependencies": { "cjs-module-lexer": "^2.2.0", "es-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-AiohS3H80sXO6owEltjGX+glb7qXaDhBoJb9XcQVH4UI207xu/bDLUcadVKp7Qe576reg9yr/PXZjV5qx8gfbA=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], @@ -893,6 +940,8 @@ "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + "meriyah": ["meriyah@6.1.4", "", {}, "sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ=="], + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], @@ -903,6 +952,8 @@ "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], + "module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "mute-stream": ["mute-stream@3.0.0", "", {}, "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw=="], @@ -1039,6 +1090,8 @@ "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="], + "rollup": ["rollup@4.60.2", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.2", "@rollup/rollup-android-arm64": "4.60.2", "@rollup/rollup-darwin-arm64": "4.60.2", "@rollup/rollup-darwin-x64": "4.60.2", "@rollup/rollup-freebsd-arm64": "4.60.2", "@rollup/rollup-freebsd-x64": "4.60.2", "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", "@rollup/rollup-linux-arm-musleabihf": "4.60.2", "@rollup/rollup-linux-arm64-gnu": "4.60.2", "@rollup/rollup-linux-arm64-musl": "4.60.2", "@rollup/rollup-linux-loong64-gnu": "4.60.2", "@rollup/rollup-linux-loong64-musl": "4.60.2", "@rollup/rollup-linux-ppc64-gnu": "4.60.2", "@rollup/rollup-linux-ppc64-musl": "4.60.2", "@rollup/rollup-linux-riscv64-gnu": "4.60.2", "@rollup/rollup-linux-riscv64-musl": "4.60.2", "@rollup/rollup-linux-s390x-gnu": "4.60.2", "@rollup/rollup-linux-x64-gnu": "4.60.2", "@rollup/rollup-linux-x64-musl": "4.60.2", "@rollup/rollup-openbsd-x64": "4.60.2", "@rollup/rollup-openharmony-arm64": "4.60.2", "@rollup/rollup-win32-arm64-msvc": "4.60.2", "@rollup/rollup-win32-ia32-msvc": "4.60.2", "@rollup/rollup-win32-x64-gnu": "4.60.2", "@rollup/rollup-win32-x64-msvc": "4.60.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ=="], "rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], @@ -1059,6 +1112,8 @@ "secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="], + "semifies": ["semifies@1.0.0", "", {}, "sha512-xXR3KGeoxTNWPD4aBvL5NUpMTT7WMANr3EWnaS190QVkY52lqqcVRD7Q05UVbBhiWDGWMlJEUam9m7uFFGVScw=="], + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], @@ -1095,6 +1150,8 @@ "sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="], + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], diff --git a/docs/release/build.md b/docs/release/build.md index fc2e3fa8..c7bfc271 100644 --- a/docs/release/build.md +++ b/docs/release/build.md @@ -49,3 +49,17 @@ symbols: - **iOS** — upload dSYMs, including Flutter's `App.framework.dSYM`, to App Store Connect. Xcode Cloud does this automatically; manual builds require `xcrun altool` or the Xcode Organizer. +- **Desktop (Windows/macOS/Linux)** — nowhere to upload to. errex implements the + ingest API but not the symbol-upload one: `sentry-cli` posts to + `/api/0/organizations//chunk-upload/` (or the legacy + `/api/0/projects///files/dsyms/`), and errex answers **404** on + both while answering **401** on routes it does implement, such as + `/api//envelope/`. So do not add a `sentry-cli upload-dif` step to the + desktop workflows expecting it to work. Desktop NATIVE frames therefore arrive + as module + offset, and hand-symbolicating one needs the matching PDBs/dSYMs — + which `build-desktop.yml` does not archive, so they die with the runner and a + native desktop report is unreadable today. Rebuilding the tag does not recover + them: the toolchains are not bit-reproducible, so the build ids would not match + the shipped binary. Uploading the symbol files as a build artifact is the fix. + DART frames stay readable because releases ship unobfuscated (see the top of + this file). Re-check this if errex gains the endpoint. diff --git a/packages/antgrid_relay_client/lib/src/machine_session.dart b/packages/antgrid_relay_client/lib/src/machine_session.dart index d8cd3b6a..809a6220 100644 --- a/packages/antgrid_relay_client/lib/src/machine_session.dart +++ b/packages/antgrid_relay_client/lib/src/machine_session.dart @@ -138,6 +138,10 @@ class MachineSession { final _streamReadyController = StreamController<({String projectId, String streamId})>.broadcast(); + /// channel → the decrypt-and-dispatch chain currently draining for it. See + /// [_onRouted]; an entry lives only while that channel has work in flight. + final Map> _inboundTails = {}; + /// projectId → streamId, learned from `agent:projects` / `stream-ready`. final Map _projectStreamIds = {}; final Map> _streamReadyWaiters = {}; @@ -540,7 +544,27 @@ class MachineSession { if (msg.kind == FrameKind.handshake) return; final keys = _keys; if (keys == null) return; // pre-establishment: driver owns sealed frames - unawaited(_decryptAndDispatch(msg, keys)); + // Chained per channel, never fired independently: `open()` is async and the + // platform AES-GCM implementation dispatches by payload size, so a small + // frame otherwise overtakes a large one — a `{"type":6}` ping ahead of the + // 30 KB render batch it acknowledges, one `terminal:output` chunk ahead of + // another, or a fragment ahead of its predecessor in [_reassembler]. The + // relay delivers a channel in order; this is what keeps that true through + // decryption. Channels stay independent of each other. + final ahead = _inboundTails[msg.channel] ?? Future.value(); + final next = ahead.then((_) => _decryptAndDispatch(msg, keys)); + // A rejection must not strand every frame queued behind it. + final chained = next.catchError((Object _) {}); + _inboundTails[msg.channel] = chained; + unawaited( + chained.whenComplete(() { + // Only the tail retires the entry — a later frame has already replaced + // it, and dropping that would let the next frame race this one. + if (identical(_inboundTails[msg.channel], chained)) { + _inboundTails.remove(msg.channel); + } + }), + ); } Future _decryptAndDispatch( @@ -801,6 +825,7 @@ class MachineSession { if (!w.isCompleted) w.completeError(StateError('session disposed')); } _streamReadyWaiters.clear(); + _inboundTails.clear(); await _established$.close(); await _takeovers.close(); await _sessionDown.close(); diff --git a/relay/relay-requirements.md b/relay/relay-requirements.md index c0d4a6fa..f0ee0689 100644 --- a/relay/relay-requirements.md +++ b/relay/relay-requirements.md @@ -9,7 +9,10 @@ > `pair-connected` pairing ceremony below. Both are gone: the relay now > authenticates a single signed `hello` and routing is account-derived > (`mayRoute`), with no pairing step at all. See `relay/CLAUDE.md` for the -> current protocol. +> current protocol. The offline message queue in section 4 never shipped either: +> a frame for a disconnected peer is answered `PEER_OFFLINE` and dropped, and +> nothing is buffered or written to disk — which is what `/privacy` on the site +> states, so do not implement section 4 without changing that page first. --- diff --git a/site/.env.example b/site/.env.example index 664ec636..1a83f475 100644 --- a/site/.env.example +++ b/site/.env.example @@ -1,2 +1,3 @@ PUBLIC_SITE_URL=https://antgrid.ai PUBLIC_APP_URL=https://app.antgrid.ai +PUBLIC_WEB_URL=https://app.antgrid.ai diff --git a/site/astro.config.mjs b/site/astro.config.mjs index 425dfe07..2c9a4e72 100644 --- a/site/astro.config.mjs +++ b/site/astro.config.mjs @@ -81,5 +81,24 @@ export default defineConfig({ // the docs document, so only the nesting should need to change. experimental: { fonts }, vite: { plugins: [tailwindcss()] }, - integrations: [icon(), sitemap({ filter: (page) => !page.includes("/og-card") })], + integrations: [ + // simple-icons is named explicitly because astro-icon otherwise assigns an + // installed collection `["*"]` and inlines the whole pack into the build's + // virtual module — 3,700 icons and ~4.7MB of source, to draw seven brand + // marks in Compat.astro. Collections left unnamed (tabler) keep `*`. + icon({ + include: { + "simple-icons": [ + "claudecode", + "openai", + "opencode", + "cursor", + "githubcopilot", + "kimi", + "mistralai", + ], + }, + }), + sitemap({ filter: (page) => !page.includes("/og-card") }), + ], }); diff --git a/site/bun.lock b/site/bun.lock index bdddff5d..524a8c7d 100644 --- a/site/bun.lock +++ b/site/bun.lock @@ -9,6 +9,7 @@ "@fontsource-variable/archivo": "^5.3.0", "@fontsource-variable/inter": "^5.3.0", "@fontsource-variable/jetbrains-mono": "^5.3.0", + "@iconify-json/simple-icons": "^1.2.94", "@iconify-json/tabler": "^1.2.0", "astro": "^5.0.0", "astro-icon": "^1.1.5", @@ -129,6 +130,8 @@ "@fontsource-variable/jetbrains-mono": ["@fontsource-variable/jetbrains-mono@5.3.0", "", {}, "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw=="], + "@iconify-json/simple-icons": ["@iconify-json/simple-icons@1.2.94", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-l8UWzVxKaqZd9ABsE/M/9p6NyGkQnmCnOoZyhQmjlXCtY5PuL2rcWxOFk2l9pk7ux3ERMPkTLE4jl6kQpTkwxA=="], + "@iconify-json/tabler": ["@iconify-json/tabler@1.2.35", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-/sJMqHvh5ZWrEERVfDCT5NjVDeKJdhosFtKjJofAVl+P/3AzLiryOQw7WvrfDF25Xa5N/eoOQ15Y1jnhYXxBoQ=="], "@iconify/tools": ["@iconify/tools@4.2.0", "", { "dependencies": { "@iconify/types": "^2.0.0", "@iconify/utils": "^2.3.0", "cheerio": "^1.1.2", "domhandler": "^5.0.3", "extract-zip": "^2.0.1", "local-pkg": "^1.1.2", "pathe": "^2.0.3", "svgo": "^3.3.2", "tar": "^7.5.2" } }, "sha512-WRxPva/ipxYkqZd1+CkEAQmd86dQmrwH0vwK89gmp2Kh2WyyVw57XbPng0NehP3x4V1LzLsXUneP1uMfTMZmUA=="], diff --git a/site/package.json b/site/package.json index 0518f97a..c94fe576 100644 --- a/site/package.json +++ b/site/package.json @@ -15,6 +15,7 @@ "@fontsource-variable/archivo": "^5.3.0", "@fontsource-variable/inter": "^5.3.0", "@fontsource-variable/jetbrains-mono": "^5.3.0", + "@iconify-json/simple-icons": "^1.2.94", "@iconify-json/tabler": "^1.2.0", "astro": "^5.0.0", "astro-icon": "^1.1.5" diff --git a/site/public/.well-known/security.txt b/site/public/.well-known/security.txt new file mode 100644 index 00000000..4e60fd12 --- /dev/null +++ b/site/public/.well-known/security.txt @@ -0,0 +1,6 @@ +Contact: mailto:contact@radhaai.com +Contact: https://github.com/antgrid-ai/antgrid/security/advisories/new +Preferred-Languages: en +Canonical: https://antgrid.ai/.well-known/security.txt +Expires: 2027-06-30T23:59:59.000Z +Policy: https://github.com/antgrid-ai/antgrid/blob/HEAD/SECURITY.md diff --git a/site/public/og/control-plane.png b/site/public/og/control-plane.png new file mode 100644 index 00000000..ebe0397a Binary files /dev/null and b/site/public/og/control-plane.png differ diff --git a/site/public/staticwebapp.config.json b/site/public/staticwebapp.config.json index 9bb43526..53dcfdc1 100644 --- a/site/public/staticwebapp.config.json +++ b/site/public/staticwebapp.config.json @@ -12,6 +12,7 @@ { "route": "/og/*", "headers": { "Cache-Control": "public, max-age=86400" } } ], "mimeTypes": { + ".txt": "text/plain", ".webmanifest": "application/manifest+json", ".svg": "image/svg+xml" } diff --git a/site/scripts/shoot-og.mjs b/site/scripts/shoot-og.mjs index 0dccb5ea..cf846617 100644 --- a/site/scripts/shoot-og.mjs +++ b/site/scripts/shoot-og.mjs @@ -17,6 +17,6 @@ await page.evaluate(() => document.fonts.ready); // shipping: the card should show the run resolved, not caught mid-populate. await page .locator("#og") - .screenshot({ path: "public/og/one-screen.png", animations: "disabled" }); + .screenshot({ path: "public/og/control-plane.png", animations: "disabled" }); await browser.close(); -console.log("wrote public/og/one-screen.png"); +console.log("wrote public/og/control-plane.png"); diff --git a/site/src/components/Footer.astro b/site/src/components/Footer.astro index 38da3e49..dfc5ce15 100644 --- a/site/src/components/Footer.astro +++ b/site/src/components/Footer.astro @@ -11,7 +11,7 @@ import Wordmark from "./ui/Wordmark.astro";

- Run your coding agents on your machine, steer them from your phone — and nothing counts as done without evidence. + Every agent you run, on every machine you own, in one control plane — and a gate on every phase when you arm Handler.

end-to-end encrypted · zero-knowledge relay @@ -25,6 +25,7 @@ import Wordmark from "./ui/Wordmark.astro"; Download Get started Support + Security
diff --git a/site/src/components/Seo.astro b/site/src/components/Seo.astro index 16ad9b0d..b8694c1a 100644 --- a/site/src/components/Seo.astro +++ b/site/src/components/Seo.astro @@ -6,19 +6,28 @@ interface Props { ogImage?: string; ogImageAlt?: string; path?: string; + // Only ever set to keep a page OUT of the index (/404). Left undefined the tag + // is omitted entirely rather than emitted as "index, follow" — that is already + // the default, and a page that states it invites the question of which pages + // set it deliberately. + robots?: string; } // The two defaults describe the same file — a page overriding one must override // the other, or the card ships someone else's alt text. The filename tracks the // card's claim on purpose: scrapers cache og:image by URL and re-shooting in // place leaves the superseded card in previews for as long as they hold it, so a // recut that changes what the card SAYS gets a new name. Keep it in step with -// scripts/shoot-og.mjs, which writes it. +// scripts/shoot-og.mjs, which writes it. The superseded PNG stays in public/og +// even though nothing references it — that is the point of the scheme, not +// leftovers: a scraper still holding the old URL re-fetches it, and deleting the +// file turns every one of those cached previews into a broken image. const { title, description, - ogImage = "/og/one-screen.png", - ogImageAlt = "Every agent. Every machine. One screen. Below, antgrid checking an agent's work against test output while you slept.", + ogImage = "/og/control-plane.png", + ogImageAlt = "Your machines. Your agents. One control plane. Below, antgrid checking an agent's work against test output while you slept.", path = "/", + robots, } = Astro.props; const canonical = new URL(path, SITE_URL).href; const ogUrl = new URL(ogImage, SITE_URL).href; @@ -27,6 +36,7 @@ const ogUrl = new URL(ogImage, SITE_URL).href; {title} +{robots && } {/* Keep in lockstep with --color-page in styles/global.css. */} diff --git a/site/src/components/pricing/PlanCard.astro b/site/src/components/pricing/PlanCard.astro index 1802ab97..acca7422 100644 --- a/site/src/components/pricing/PlanCard.astro +++ b/site/src/components/pricing/PlanCard.astro @@ -1,11 +1,11 @@ --- import { Icon } from "astro-icon/components"; import Button from "../ui/Button.astro"; +import WaitlistCta from "./WaitlistCta.astro"; import { links } from "../../config"; -import { BETA_FREE, type PlanCardData } from "../../data/pricing"; -interface Props { plan: PlanCardData } -const { plan } = Astro.props; -const discountPct = plan.listUsd ? Math.round((1 - plan.priceUsd / plan.listUsd) * 100) : null; +import { type PlanCardData, type WaitlistSource } from "../../data/pricing"; +interface Props { plan: PlanCardData; waitlistSource?: WaitlistSource } +const { plan, waitlistSource = "pricing" } = Astro.props; ---
@@ -17,9 +17,15 @@ const discountPct = plan.listUsd ? Math.round((1 - plan.priceUsd / plan.listUsd) reach for the type class, which tied a revenue test to a font size. */} ${plan.priceUsd} {plan.unit} - {plan.listUsd && ${plan.listUsd}} - {discountPct && {discountPct}% off launch}
+ {/* A forward price, never a struck one. The higher figure has never been + charged, so rendering it as a crossed-out "was" invents a reference price + the product never had — the thing CCPA's dark-pattern rules and EU Omnibus + Art. 6a both reach. Stated as the list price at launch it is the same + contrast and a true sentence. */} + {plan.listUsd && ( +
Founding price — ${plan.listUsd} at launch
+ )}
{plan.note}
{plan.features.map((f) => ( @@ -28,7 +34,7 @@ const discountPct = plan.listUsd ? Math.round((1 - plan.priceUsd / plan.listUsd)
{plan.comingSoon ? ( - + ) : ( )} diff --git a/site/src/components/pricing/WaitlistCta.astro b/site/src/components/pricing/WaitlistCta.astro new file mode 100644 index 00000000..706dd3d7 --- /dev/null +++ b/site/src/components/pricing/WaitlistCta.astro @@ -0,0 +1,165 @@ +--- +import { links } from "../../config"; + +import type { WaitlistSource } from "../../data/pricing"; + +interface Props { + /** Which surface the address came from — sent as `source` to the API. */ + source: WaitlistSource; + /** Unique per instance: two captures can share a page (label/status ids). */ + id: string; +} +const { source, id } = Astro.props; +const inputId = `${id}-email`; +const statusId = `${id}-status`; +--- +{/* `action` is the real endpoint rather than a data-* attribute so the markup + states its own target, but the submit button ships DISABLED: without the + script a native cross-origin POST would land the reader on a raw JSON body, + and a disabled default button also blocks implicit submission from the + input. The script enables it — so scriptless readers get the noscript note + instead of a control that looks live and is not. */} +
+ +
+ + +
+ {/* Reserves TWO lines, not one: every message here is 60+ characters and wraps + at the card's mobile width, so a one-line reservation still grows the card + under the thumb that just tapped it. */} +

+ +
+ + + + diff --git a/site/src/components/sections/Compat.astro b/site/src/components/sections/Compat.astro new file mode 100644 index 00000000..e51f2e7f --- /dev/null +++ b/site/src/components/sections/Compat.astro @@ -0,0 +1,74 @@ +--- +import { Icon } from "astro-icon/components"; +import Chip from "../ui/Chip.astro"; +import Eyebrow from "../ui/Eyebrow.astro"; + +// Keep in lockstep with bridge/src/agents/registry.ts. `handler: true` is what +// `handlerObservable` answers true for — a terminal session needs the agent's +// integration to POST /handler-event, a chat session needs a driver, and only +// these three clear either bar. +// +// They are also the only three that get signal. In this palette the accent means +// the system is doing something (see .live-cells in global.css, and the note in +// Eyebrow.astro on why section labels gave the colour up), so a row where all +// ten marks glow would promise the paid feature to six agents that cannot run +// it. The sentence below names the three in words too: colour reinforces here, +// it never carries alone. +const agents = [ + { name: "Claude Code", icon: "simple-icons:claudecode", handler: true }, + { name: "Codex", icon: "simple-icons:openai", handler: true }, + { name: "opencode", icon: "simple-icons:opencode", handler: true }, + { name: "Cursor", icon: "simple-icons:cursor" }, + { name: "GitHub Copilot", icon: "simple-icons:githubcopilot" }, + { name: "Antigravity" }, + { name: "Kilo" }, + { name: "Kimi", icon: "simple-icons:kimi" }, + { name: "Mistral Vibe", icon: "simple-icons:mistralai" }, +]; + +// Two rows rather than one that wraps: the chips are ~100px wider than the shell +// at every desktop width, so a single flex row breaks 9 + 1 and strands the +// dashed chip alone on the second line. Splitting on the distinction the row +// already encodes costs no words and fixes the orphan. +const supervised = agents.filter((a) => a.handler); +const unsupervised = agents.filter((a) => !a.handler); + +// Built from the same array the chips are, so the sentence cannot go on naming +// three while a fourth chip lights up. +const supervisedNames = supervised.map((a) => a.name); +--- +
+
+ The agent you already run + +
+
+ {supervised.map((a) => ( + + {a.icon + ? + : } + {a.name} + + ))} +
+
+ {unsupervised.map((a) => ( + + {a.icon + ? + : } + {a.name} + + ))} + any terminal agent +
+
+ +

+ Handler supervises {supervisedNames.map((name, i) => ( + <>{i === 0 ? "" : i === supervisedNames.length - 1 ? " and " : ", "}{name} + ))} today. Every other agent runs as a named session — terminal, files, git and alerts. +

+
+
diff --git a/site/src/components/sections/CrossAgent.astro b/site/src/components/sections/CrossAgent.astro index bee900a2..f93ed2ef 100644 --- a/site/src/components/sections/CrossAgent.astro +++ b/site/src/components/sections/CrossAgent.astro @@ -1,13 +1,8 @@ --- import { Icon } from "astro-icon/components"; import Eyebrow from "../ui/Eyebrow.astro"; -import Chip from "../ui/Chip.astro"; import UseCase from "../ui/UseCase.astro"; -// Keep in lockstep with AGENTS in bridge/src/agents/registry.ts — these are the -// agents wired for notifications and session naming. Anything else still runs (the -// "any terminal agent" chip), it just gets no integration, so it must not be named here. -const agents = ["Claude Code", "Codex", "opencode", "Cursor", "GitHub Copilot", "Kilo", "Kimi", "Mistral Vibe"]; const steps = [ { n: 1, icon: "tabler:download", title: "Install on your machine", body: "The desktop app runs your agents in real terminals and links them to your phone — Windows, macOS, Linux.", accent: false }, { n: 2, icon: "tabler:terminal-2", title: "Run any agent", body: "Start Claude Code, Codex or Cursor exactly how you do now. antgrid wraps them — no workflow change.", accent: false }, @@ -23,13 +18,9 @@ const steps = [ antgrid doesn't replace your coding agent — it's the command centre over the ones you already run. No new model, no new CLI to learn.

-
- {agents.map((a) => ( - {a} - ))} - any terminal agent -
- + {/* The roster moved to Compat.astro, directly under the hero: "does this work + with my agent" gates whether a reader keeps scrolling, so it cannot be + answered in section seven. This section keeps the promise and the setup. */} Pick the agent per task, not per tool — the command centre stays the same.

How it works

diff --git a/site/src/components/sections/Fleet.astro b/site/src/components/sections/Fleet.astro index a112c486..98935c4c 100644 --- a/site/src/components/sections/Fleet.astro +++ b/site/src/components/sections/Fleet.astro @@ -1,68 +1,24 @@ --- -import { Icon } from "astro-icon/components"; import Eyebrow from "../ui/Eyebrow.astro"; -import Readout from "../ui/Readout.astro"; +import FleetScene from "../shell/FleetScene.astro"; import UseCase from "../ui/UseCase.astro"; - -type Row = { name: string; agent: string; task: string; time: string; state: "working" | "needs" | "done" }; -type Machine = { host: string; tag?: string; rows: Row[] }; -const machines: Machine[] = [ - { host: "macbook-pro", rows: [ - { name: "api", agent: "Claude Code", task: "Refactoring auth middleware…", time: "2m", state: "working" }, - { name: "web", agent: "Codex", task: "Writing checkout tests…", time: "5m", state: "working" }, - ]}, - { host: "studio-workstation", rows: [ - { name: "relay", agent: "Claude Code", task: "Needs you — which migration strategy?", time: "just now", state: "needs" }, - { name: "app", agent: "Cursor", task: "Built release bundle", time: "12m", state: "done" }, - ]}, - { host: "prod-box", tag: "cloud", rows: [ - { name: "evals", agent: "Codex", task: "Running E2E suite…", time: "1m", state: "working" }, - { name: "infra", agent: "Claude Code", task: "Tailing deploy logs…", time: "3m", state: "working" }, - ]}, -]; - -const allRows = machines.flatMap((m) => m.rows); -const totalCount = allRows.length; -const needsCount = allRows.filter((r) => r.state === "needs").length; -const workingCount = allRows.filter((r) => r.state === "working").length; -const doneCount = allRows.filter((r) => r.state === "done").length; --- {/* The band the page is widest at — this is the "one screen" claim, so the - readout gets the full shell and an inset floor to sit on. */} + window gets the full shell and an inset floor to sit on. */}
Fleet view {/* The hero carries "every agent, every machine, one screen" now, so this section takes the narrower claim it is the actual evidence for. Keep the - needs-you line in exactly one place here — heading, lede and the readout's - foot all stated it before, which read as padding around the one screenshot - that proves it. */} + needs-you line in exactly one place here — heading, lede and the frame's + own marker all stated it before, which read as padding around the one + picture that proves it. */}

The one that needs you is never below the fold.

- Your laptop, your workstation, a cloud box — every agent you're running, grouped by the machine it's on, sorted so the one that's blocked is never the one you have to go looking for. + Grouped by the machine it's on, sorted so the one that's blocked is never the one you have to go looking for.

- - {machines.map((m) => ( -
-
- {m.host}{m.tag && · {m.tag}} -
- {m.rows.map((r) => ( -
- {r.state === "done" - ? - : } - {r.name} - {r.agent} - {r.task} - {r.time} -
- ))} -
- ))} - {workingCount} working · {doneCount} done -
+ Glance once, answer the one that's blocked, close the phone. No tabbing through six terminals to find it.
diff --git a/site/src/components/sections/Hero.astro b/site/src/components/sections/Hero.astro index 3575fb22..1961f446 100644 --- a/site/src/components/sections/Hero.astro +++ b/site/src/components/sections/Hero.astro @@ -2,11 +2,9 @@ import { Icon } from "astro-icon/components"; import Eyebrow from "../ui/Eyebrow.astro"; import Button from "../ui/Button.astro"; -import ProofCard from "./ProofCard.astro"; +import WorkspaceScene from "../shell/WorkspaceScene.astro"; import { links } from "../../config"; import { BETA_FREE, OFFER_ACTIVE, YEARLY_OFFER_USD, YEARLY_LIST_USD } from "../../data/pricing"; - -const offerPct = Math.round((1 - YEARLY_OFFER_USD / YEARLY_LIST_USD) * 100); --- {/* The proof loop is the argument, so it gets the stage rather than a column: full shell width under the headline, which also puts it just below the fold @@ -19,7 +17,11 @@ const offerPct = Math.round((1 - YEARLY_OFFER_USD / YEARLY_LIST_USD) * 100); stop around 1568px because past the shell the mask has faded the field out anyway, and the life belongs where the content is. */} - + {/* Geometry lives with the paint in .glow-hero, not in utilities here: the + two change together across the md breakpoint and are meaningless apart. */} +
@@ -57,8 +61,11 @@ const offerPct = Math.round((1 - YEARLY_OFFER_USD / YEARLY_LIST_USD) * 100); href={links.pricing} class="inline-flex items-center gap-2 rounded-full border border-signaldeep2 bg-signaldeep/40 px-3 py-1 font-mono text-marker text-signal2 transition-colors hover:border-signalbtn hover:text-signal3" > - {offerPct}% off - Launch offer — Pro ${YEARLY_OFFER_USD} per seat / year + {/* Forwards, never as a discount off a struck price — same rule + PlanCard.astro states and for the same reason: $99 has never been + charged, so "% off" would invent a reference price. */} + Founding + Pro ${YEARLY_OFFER_USD} per seat / year — ${YEARLY_LIST_USD} at launch )} @@ -70,17 +77,21 @@ const offerPct = Math.round((1 - YEARLY_OFFER_USD / YEARLY_LIST_USD) * 100); type, where the default wrap strands a two-word tail ("stuck since 2am.", "machine.") on its own line at some widths and not others. A hard
fixes the one width you tested and makes narrow viewports worse. */} - - Agents on your laptop, your workstation, a cloud box.{" "} + + Claude Code on your laptop, Codex on your workstation, another on a cloud box.{" "} One's been stuck since 2am. - {/* The headline claims the overview, so the tension has to sit in the kicker - above it — on its own "One screen." is a watching claim, and the product - acts. The stuck agent is what the lede's proof loop then answers. */} + {/* This deliberately takes the overview claim BACK from Fleet.astro, which + was handed it while the headline carried the evidence gate. The gate is + not entitled to a headline: Handler is opt-in and it is on Pro, so "make + it prove it" was false on every free machine until someone armed it — + the one promise a stranger is asked to believe has to be true on a bare + install. This one is. Fleet.astro keeps the PROOF; the hero takes back + the CLAIM, and the ProofCard below still shows the gate doing its job. */} - Every agent.{" "} - Every machine.{" "} - One screen. + Your machines.{" "} + Your agents.{" "} + One control plane. @@ -88,29 +99,51 @@ const offerPct = Math.round((1 - YEARLY_OFFER_USD / YEARLY_LIST_USD) * 100); rather than pinned to the shell's edges — pushed apart, the buttons read as stranded rather than as a deliberate second column. */}
- {/* Carries the proof loop, because the headline no longer does — it claims - the overview instead. Does not re-open on "your agents run on your own - machines": the kicker and headline directly above have just said that, - and the paragraph runs long on a phone as it is. */} -

- They run on your own machines — your repos, your branches, your existing subscriptions. Hand antgrid the sequence in plain text: it reads every result, refuses a "done" that arrives with no test output, exit code or diff, and moves to the next phase. -

+ {/* Two beats, and their order is the argument. The first is true on a free + install with nothing configured, which is what earns it the space next + to the headline. The second names the paid feature with a VERB — you arm + it — because a reader who takes the gate for a default will find it + missing and conclude we lied. It links rather than explains: Phases.astro + is two sections down and makes the whole case there. */} + {/* One grid child, not two paragraphs: the grid has exactly two columns and + a loose second

becomes a third item, which pushes the CTA column out + of row one and strands the buttons under the copy. */} +

+

+ Every agent you're running, on every machine you own — your repos, your branches, your existing subscriptions. Nothing rented from us, nothing to provision. +

+

+ Arm Handler when you want a phase held until the evidence is there. +

+
-

- No VPN or port forwarding · End-to-end encrypted · Desktop out now, iOS & Android coming to the App Store & Play. + {/* Two lines because there are two jobs here: the guarantees are a + dot-separated list of properties, availability is a sentence. Run + together they made a four-clause line that wrapped to three in this + 21rem column and split "iOS &" from "Android" across rows. */} +

No VPN or port forwarding · End-to-end encrypted

+

+ Desktop out now on Windows, macOS & Linux.{" "} + iOS & Android in private beta — request an invite.

-
- {/* min-w-0 for the same reason as the Worktrees card — see the comment there. */} -
- + {/* The window runs off the bottom of the hero rather than ending inside it: + the reader should feel the app continue past the fold. The fade is a + SIBLING of the window, never a child — as a child it sits under the + window's own border and leaves a bright hairline across the exact point + the page is trying to dissolve. */} +
+
+
+
+ diff --git a/site/src/components/sections/Phases.astro b/site/src/components/sections/Phases.astro index 81fbcee1..691dee27 100644 --- a/site/src/components/sections/Phases.astro +++ b/site/src/components/sections/Phases.astro @@ -1,7 +1,6 @@ --- -import { Icon } from "astro-icon/components"; import Eyebrow from "../ui/Eyebrow.astro"; -import Readout from "../ui/Readout.astro"; +import HandlerScene from "../shell/HandlerScene.astro"; import UseCase from "../ui/UseCase.astro"; // The phase-gated workflow (research → validate → plan → implement, a human @@ -20,12 +19,6 @@ const stats = [ { value: "0", label: "phases advanced on the agent's say-so", accent: true }, ]; -const phases = [ - { n: "01", name: "research", time: "00:36", state: "done", note: "pushed back — shallow. second pass closed it" }, - { n: "02", name: "validate", time: "02:34", state: "done", note: "no red flags — advanced" }, - { n: "03", name: "plan", time: "03:21", state: "needs", note: "woke you — two viable approaches, your pick" }, - { n: "04", name: "implement", time: "", state: "queued", note: "starts when you pick" }, -]; ---
Hand over the follow-ups @@ -48,33 +41,7 @@ const phases = [ ))}
- -
-
- - you · armed · 23:47 - research the provider swap; validate it against our rate limits; plan it — give me options before implementing -
- -
- {phases.map((p) => ( -
-
- {p.n} - {p.state === "done" && } - {p.state === "needs" && } - {p.state === "queued" && } -
-
- {p.name} - {p.time && {p.time}} -
-
{p.note}
-
- ))} -
-
-
+ Type the sequence once, before you leave. You get woken for the call that's actually yours. diff --git a/site/src/components/shell/AgentPane.astro b/site/src/components/shell/AgentPane.astro new file mode 100644 index 00000000..12a8b4c6 --- /dev/null +++ b/site/src/components/shell/AgentPane.astro @@ -0,0 +1,93 @@ +--- +import { Icon } from "astro-icon/components"; + +// The middle column: one session, as the app draws it. Both scenes that show a +// session share this file rather than each spelling out a transcript, because +// they ARE the same session — the workspace looks at it with Git open, the +// handler scene with Handler open. Two panes would drift into two different +// products in the same page. +// +// TERMINAL, not chat, because that is what a session is by default: the +// create-time picker makes Terminal the default for every agent and marks Chat +// alpha (mode_segmented.dart), so a page selling the chat view sells the newest +// and least finished surface in the app. The header carries the real switch +// between them at header density — glyphs, labels demoted to tooltips — with +// the live cell accented, exactly as `SessionModeControl` renders it. +// +// The scrollback is the agent's OWN pty (`terminalType: 'agent'`), not the +// sample's canned shell snapshot, and it deliberately ends on a completion +// claim with no test run behind it: the Handler scene refuses precisely that +// claim, and a terminal showing a green suite would make the refusal read as a +// bug. Everything else is the sample project's own — the `demo-shop $` prompt, +// the agent command, the touched paths, the +24 -3, and the `test` / `lint` +// tray, which is the command list the sample antgrid.yaml declares. +const trail = [ + { call: "Read(src/checkout.ts)", result: "84 lines" }, + { call: "Update(src/checkout.ts)", result: "+24 -3" }, + { call: "Write(tests/checkout.test.ts)", result: "41 lines" }, +]; +--- +
+
+
+ + {/* whitespace-pre goes on each LINE, never on this container: with it here + the newlines between the block children below are preserved too, and every + row gains a blank one under it. */} +
+
demo-shop $ claude
+
+ +
> The checkout endpoint accepts empty carts and
+
malformed emails. Add validation and a test.
+
+ + {trail.map((t) => ( + <> +
{t.call}
+
⎿ {t.result}
+ + ))} +
+ +
Checkout now refuses an empty cart, a malformed
+
email and a non-positive total, each a typed
+
CheckoutError. Tests pass.
+ +
+ > + +
+
+ + {/* The command tray, which is on this surface whenever the project declares + commands — the sample declares two. */} + +
diff --git a/site/src/components/shell/AppWindow.astro b/site/src/components/shell/AppWindow.astro new file mode 100644 index 00000000..c82a3d4f --- /dev/null +++ b/site/src/components/shell/AppWindow.astro @@ -0,0 +1,71 @@ +--- +import { Icon } from "astro-icon/components"; +import Mark from "../ui/Mark.astro"; + +// The one window chrome every scene reuses. Scenes fill panes; they never draw +// their own frame. Two spellings of the frame is how two scenes end up reading +// as two different products. +// +// This bar is the app's own, not a generic window: Antgrid draws its own title +// bar (no OS one exists), so the mac traffic lights this used to carry were a +// stock "screenshot" signal for a product that has never looked like that. The +// order below — mark, sidebar toggle, history, centred session search, the +// Remote pill, the panel toggle, then hand-drawn window controls — is the order +// the app ships. Changing it here without changing it there sells a window the +// reader will not find. +// +// Everything inside a scene is painted from the `ab-` tokens (global.css), +// which mirror app/lib/design/ab_colors.dart. A scene that reaches for +// --color-panel or --color-chrome has quietly turned the window back into a +// site card, which is the one thing this component exists to prevent. +interface Props { + /** 3 gives the context column back above 64rem. Only the workspace wants it. */ + panes?: 2 | 3; + /** The app's `Remote on` state chip. Off is the fresh-install default. */ + remote?: boolean; + class?: string; +} + +const { panes = 2, remote = true, class: cls = "" } = Astro.props; +--- +
+ {/* Decorative throughout: nothing in this bar is a control anyone can press, + so it is hidden from assistive tech rather than announced as a toolbar of + dead buttons. The scenes below it carry the readable content. */} + + + {/* Pane order is document order; which of them survive a given width is + decided in .appwin-panes (global.css), not here. */} +
+ + + +
+
diff --git a/site/src/components/shell/CtxTabs.astro b/site/src/components/shell/CtxTabs.astro new file mode 100644 index 00000000..0427f9ff --- /dev/null +++ b/site/src/components/shell/CtxTabs.astro @@ -0,0 +1,42 @@ +--- +import { Icon } from "astro-icon/components"; + +// The context pane's tab strip. The five tabs, their order, their icons and the +// count badge on Git are the app's; the underline on the active one is how the +// app marks it. +// +// The reason this is a strip and not a set of columns is the correction worth +// keeping: only ONE of Files / Git / Terminals / Preview / Handler is on screen +// at a time. The app is three columns, never four, and a scene that paints a +// diff beside a preview beside a terminal is promising a layout that does not +// exist. +const TABS = [ + { label: "Preview", icon: "tabler:browser" }, + { label: "Files", icon: "tabler:files" }, + { label: "Git", icon: "tabler:git-branch" }, + { label: "Terminals", icon: "tabler:terminal-2" }, + { label: "Handler", icon: "tabler:shield-lock" }, +] as const; + +interface Props { + active: (typeof TABS)[number]["label"]; + /** The changed-file count the app hangs off the Git tab. */ + gitCount?: number; +} +const { active, gitCount } = Astro.props; +--- +
+ {TABS.map((t) => ( + + + ))} +
diff --git a/site/src/components/shell/FleetScene.astro b/site/src/components/shell/FleetScene.astro new file mode 100644 index 00000000..d8ab302e --- /dev/null +++ b/site/src/components/shell/FleetScene.astro @@ -0,0 +1,145 @@ +--- +import { Icon } from "astro-icon/components"; +import AppWindow from "./AppWindow.astro"; +import Rail from "./Rail.astro"; + +// The "one screen" claim, rendered as the screen it actually is. +// +// There is no fleet page in the app. Sessions across machines is the HOME list +// with its grouping switched to MACHINE — the same rows, re-banded. That is a +// better argument than a bespoke dashboard would be, so the scene shows the +// chip row that does the switching rather than hiding it: the reader can see +// that the fleet view is one control away, not a separate product surface. +// +// Group bands are the app's hairline label (`STUDIO-WORKSTATION · 2`), not +// bordered cards, and `Needs you` is the app's own label for the `attention` +// work status (models/agent_work_status.dart, recent_sessions_summary.dart). +// +// home.spec.ts pins "studio-workstation" and the needs-you task string, so +// those travel with the picture rather than being restated underneath it. +type Row = { title: string; project: string; time: string; needs?: boolean; done?: boolean }; +type Machine = { host: string; rows: Row[] }; + +interface Props { + class?: string; +} +const { class: cls = "" } = Astro.props; + +const machines: Machine[] = [ + { host: "macbook-pro", rows: [ + { title: "Refactor auth middleware", project: "api", time: "2m" }, + { title: "Write checkout tests", project: "web", time: "5m" }, + ]}, + { host: "studio-workstation", rows: [ + { title: "Needs you — which migration strategy?", project: "relay", time: "just now", needs: true }, + { title: "Built release bundle", project: "app", time: "12m", done: true }, + ]}, + { host: "prod-box", rows: [ + { title: "Run the E2E suite", project: "evals", time: "1m" }, + { title: "Tail deploy logs", project: "infra", time: "3m" }, + ]}, +]; + +const total = machines.reduce((n, m) => n + m.rows.length, 0); +const hot = (m: Machine) => m.rows.some((r) => r.needs); + +// The header says one session needs you, so the list has to put it first: a +// machine holding a blocked row floats above the rest, and the blocked row +// floats inside its band. The fixture above stays in its natural order so it +// reads as a machine list rather than as an answer — sort here, not there. +const ordered = [...machines].sort((a, b) => Number(hot(b)) - Number(hot(a))); +const rowsOf = (m: Machine) => [...m.rows].sort((a, b) => Number(!!b.needs) - Number(!!a.needs)); +--- + + + +
+
+ Sessions · {total} total + + + 1 needs you +
+ +
+ {ordered.map((m) => ( +
+
+ {m.host} · {m.rows.length} + +
+ {rowsOf(m).map((r) => ( +
+
+ ))} +
+ ))} +
+ + {/* The composer is always on this surface in the app — the new-session flow + is this chip row, not a wizard. */} +
+ {/* The machine chip names a machine rather than saying "Local": the + project beside it lives on that machine, and the whole point of this + surface is that a new session can start on any of them. */} + +
+ Describe a task or ask a question +
+
+
+
diff --git a/site/src/components/shell/HandlerScene.astro b/site/src/components/shell/HandlerScene.astro new file mode 100644 index 00000000..df09b1ab --- /dev/null +++ b/site/src/components/shell/HandlerScene.astro @@ -0,0 +1,106 @@ +--- +import { Icon } from "astro-icon/components"; +import AppWindow from "./AppWindow.astro"; +import SessionRail from "./SessionRail.astro"; +import AgentPane from "./AgentPane.astro"; +import CtxTabs from "./CtxTabs.astro"; + +// Handler, where Handler actually lives: a tab in the context pane, beside the +// running session, NOT a screen of its own. It shares this window and this +// transcript with the workspace scene because in the app it is the same window +// and the same transcript — only the open tab differs. +// +// Section names, wrap-up verdicts and activity lines are the app's +// (widgets/handler/handler_screen.dart): Needs you / Sessions / Wrap-up / Undo +// / Activity, and Done / Failed / Blocked / Skipped. Tones are the app's too, +// and they are not interchangeable: `Needs you` is the accent, while the amber +// warning is reserved for the flagged reason itself — here +// "Completion not verified", which is the exact event this scene turns on. +// +// The load-bearing row is the escalation: the pitch is that we don't believe +// the agent, so this scene has to show the judge refusing a claim. A ledger of +// green ticks argues the opposite of the product. +interface Props { + class?: string; +} +const { class: cls = "" } = Astro.props; + +const wrapUp = [ + { verdict: "Done", label: "validate cart and email", tone: "text-ab-ok" }, + { verdict: "Skipped", label: "fix lint", tone: "text-ab-dim" }, + { verdict: "Blocked", label: "open a PR", tone: "text-ab-attn" }, +]; + +const activity = [ + { label: "Completion not verified: no test command ran", at: "02:58", tone: "text-ab-attn" }, + { label: "Auto-answered: allow write to tests/checkout.test.ts", at: "02:52", tone: "text-ab-mute" }, + { label: "Armed", at: "02:41", tone: "text-ab-mute" }, +]; +--- + + + + + +
+ + +
+ + + + Judge +
+ +
+
+ Needs you + 1 + +
+ +
+
+ Claimed the checkout suite passed. It never ran. +
+
+ The goal says tests green before a PR. This phase produced three file + edits and no test run, so completion is not verified. Wrap-up held. +
+
+ Run the suite, then report + Custom reply… +
+
+ +
+ Wrap-up + 3 + +
+
+ {wrapUp.map((r) => ( +
+ {r.label} + {r.verdict} +
+ ))} +
+ +
+ Activity + +
+
+ {activity.map((a) => ( +
+ {a.label} + {a.at} +
+ ))} +
+
+
+
diff --git a/site/src/components/shell/Rail.astro b/site/src/components/shell/Rail.astro new file mode 100644 index 00000000..bb147f81 --- /dev/null +++ b/site/src/components/shell/Rail.astro @@ -0,0 +1,113 @@ +--- +import { Icon } from "astro-icon/components"; + +// The app's drawer, shared by every scene so the window keeps one left edge +// whatever pane is beside it. +// +// Structure is the app's: a `PROJECTS` label, then a BAND per machine with a +// hairline above it, then that machine's projects at the SAME indent a local +// project sits at (a machine is a container, not a third level of tree — +// drawer_entry_row.dart), then its sessions. The local band carries no chevron +// because there is nothing to disclose; a remote band does, because expanding +// it is what opens that machine's control-plane socket. +// +// What is NOT here matters as much as what is. The real drawer carries no +// per-row status colours, no counts and no branch sub-labels: a project is a +// folder glyph and a name, a session is a hollow ring and a title. The only +// colour in the column is on the bands — the liveness dot every machine has, +// and the aggregate status dot a COLLAPSED machine shows when a session under +// it needs a human. Session state otherwise belongs in the list and the panes, +// where the app puts it. +interface Entry { + /** A machine band, its projects, and the sessions under them, in that order. */ + kind: "machine" | "project" | "session"; + label: string; + active?: boolean; + /** Machine only: this machine. No chevron — its projects are already listed. */ + local?: boolean; + /** Machine only: its projects are not listed below it. */ + collapsed?: boolean; + /** Machine only, and only while collapsed: a session under it needs a human. */ + attention?: boolean; +} +interface Props { + entries: Entry[]; + /** The account row pinned to the drawer's foot. Omit where it would contradict + the scene — a fleet spanning machines is signed in by definition. */ + footer?: boolean; + class?: string; +} + +const { entries, footer = true, class: cls = "" } = Astro.props; +--- +
+
+
+
+
+ +
+ Projects + +
+ + {entries.map((e, i) => ( + e.kind === "machine" ? ( +
+ {/* No rule on the first band — the PROJECTS label above it is the + separator the app relies on there. */} + {i > 0 &&
} +
+ {e.label} + {!e.local && ( +
+
+ ) : e.kind === "project" ? ( +
+
+ ) : ( +
+
+ + {e.label} +
+
+ ) + ))} + + + + {/* The account row pins to the bottom of the real drawer, same as here: the + grid stretches this column to match its taller siblings (agent pane, + context pane), so `mt-auto` sends the row to that stretched edge instead + of leaving it stranded a few rows below the list. */} + {footer && ( +
+
+
+
+ )} +
diff --git a/site/src/components/shell/SessionRail.astro b/site/src/components/shell/SessionRail.astro new file mode 100644 index 00000000..25c31ff1 --- /dev/null +++ b/site/src/components/shell/SessionRail.astro @@ -0,0 +1,26 @@ +--- +import Rail from "./Rail.astro"; + +// The drawer both session scenes carry, in one place because they are one +// session in one window — spelling the list twice is how the workspace and the +// handler quietly become two different machines with two different fleets. +// +// Three machines, not one: the drawer is where a fleet is actually reached, so +// a scene showing only `This machine` sells a single-machine tool. The sample +// project sits under the local band (that is where it opens), a second machine +// is expanded to show projects arriving from elsewhere, and the third is left +// collapsed with its aggregate dot lit — which is the app's own way of saying a +// session over there needs a human without listing it. +--- + diff --git a/site/src/components/shell/WorkspaceScene.astro b/site/src/components/shell/WorkspaceScene.astro new file mode 100644 index 00000000..30e651a6 --- /dev/null +++ b/site/src/components/shell/WorkspaceScene.astro @@ -0,0 +1,77 @@ +--- +import { Icon } from "astro-icon/components"; +import AppWindow from "./AppWindow.astro"; +import SessionRail from "./SessionRail.astro"; +import AgentPane from "./AgentPane.astro"; +import CtxTabs from "./CtxTabs.astro"; + +// The session, with its evidence open beside it. Three columns, which is all +// the app ever has: drawer, agent, and ONE context tab — here Git. +// +// The diff is the sample project's real one (`src/checkout.ts`, +24 -3 inside a +// +71 -4 changeset), rendered the way the app renders it: hunk header, both +// gutters, whole-row tint. The earlier version of this scene printed bare +/- +// lines with no numbers, which is what a diff looks like in a marketing mockup +// and not what it looks like here. +interface Props { + class?: string; +} +const { class: cls = "" } = Astro.props; + +type Line = { old?: number; new?: number; sign?: "+" | "-"; code: string }; +const diff: Line[] = [ + { old: 1, sign: "-", code: "import { Cart } from './cart';" }, + { new: 1, sign: "+", code: "import { Cart, cartTotal } from './cart';" }, + { old: 2, new: 2, code: "" }, + { old: 3, new: 3, code: "export type CheckoutInput = {" }, + { old: 4, new: 4, code: " cart: Cart;" }, + { old: 5, new: 5, code: " email: string;" }, + { new: 6, sign: "+", code: " couponCode?: string;" }, + { old: 6, new: 7, code: "};" }, + { new: 9, sign: "+", code: "export class CheckoutError extends Error {" }, + { new: 10, sign: "+", code: " constructor(readonly field: string, message: string) {" }, +]; + +const rowTone = (s?: "+" | "-") => + s === "+" ? "bg-ab-ok/10 text-ab-ok" : s === "-" ? "bg-ab-err/10 text-ab-err" : "text-ab-mute"; +--- + + + + + +
+ + +
+
+ +
+ M + src/checkout.ts + +24 + -3 +
+ +
+
@@ -1,10 +1,24 @@
+ {diff.map((l) => ( +
+ {l.old ?? ""} + {l.new ?? ""} + {l.sign ?? ""} + {l.code} +
+ ))} +
+
+
diff --git a/site/src/config.ts b/site/src/config.ts index ae2b5c52..ef92df90 100644 --- a/site/src/config.ts +++ b/site/src/config.ts @@ -1,5 +1,11 @@ export const SITE_URL = import.meta.env.PUBLIC_SITE_URL ?? "https://antgrid.ai"; export const APP_URL = import.meta.env.PUBLIC_APP_URL ?? "https://app.antgrid.ai"; +// The web service's API origin. Same deployment as APP_URL today, but declared +// separately because it is overridden for a different reason: pointing a preview +// build's waitlist POST at a local web server must not also move sign-in and +// checkout off production. The site is a static build on another origin, so +// anything under here is a cross-origin request the web service must allow. +export const WEB_URL = import.meta.env.PUBLIC_WEB_URL ?? "https://app.antgrid.ai"; // Public releases repo. `releases/latest/download/` redirects to the // newest stable release's asset of that exact filename, so these URLs never @@ -12,7 +18,11 @@ export const links = { // the product entry point; billing sign-in stays on links.signIn. startFree: "/#download", pricing: "/pricing", - features: "/#fleet", + // #handler, not #fleet. Phases.astro sits ABOVE Fleet.astro on the home page, + // so a "Features" link aimed at the fleet view opened one section PAST the only + // feature anyone pays for. Anchor hrefs are excluded from the dead-link sweep in + // home.spec.ts, so the id this depends on is pinned in contracts.spec.ts instead. + features: "/#handler", download: "/#download", getStarted: "/get-started", downloadMacos: `${RELEASES_URL}/releases/latest/download/antgrid-macos.dmg`, @@ -21,9 +31,33 @@ export const links = { downloadWindows: "https://get.microsoft.com/installer/download/9N0P7ZRL4D9W?referrer=appbadge&cid=site", downloadLinux: `${RELEASES_URL}/releases/latest/download/antgrid-linux.AppImage`, support: "/support", + security: "/security", + // Verification surfaces for /security. `HEAD` rather than a branch name: + // GitHub resolves it to whatever the repo's default branch is, so renaming + // that branch never turns these into 404s under a page whose whole argument + // is that the reader can go and check. + repo: RELEASES_URL, + securityPolicyFile: `${RELEASES_URL}/blob/HEAD/SECURITY.md`, + securityAdvisory: `${RELEASES_URL}/security/advisories/new`, + handshakeSpec: `${RELEASES_URL}/blob/HEAD/docs/protocol/e2e-handshake.md`, + handshakeVectors: `${RELEASES_URL}/blob/HEAD/evals/fixtures/e2e-handshake-vectors.json`, + relayClient: `${RELEASES_URL}/tree/HEAD/packages/antgrid_relay_client`, + wirePackage: `${RELEASES_URL}/tree/HEAD/packages/antgrid-wire`, + securityEmail: "mailto:contact@radhaai.com?subject=Security", + // Enterprise leads go straight to a human. Pointing them at /support put a + // budget holder on the troubleshooting page; the subject line sorts them out + // of general support mail on arrival. + enterprise: "mailto:contact@radhaai.com?subject=Antgrid%20for%20teams", + // The mobile apps ship through TestFlight and Play internal testing today, so + // the hero has to route the ask somewhere. "Coming to the App Store" read as + // "you can't have it yet" while invites were in fact open — see get-started. + mobileInvite: "mailto:contact@radhaai.com?subject=Antgrid%20mobile%20invite", privacy: "/privacy", terms: "/terms", refunds: "/refunds", company: "https://radhaai.com", + // Interest capture for founding pricing. Posted to by the inline script in + // WaitlistCta.astro — never rendered as an href, since a GET on it does nothing. + waitlist: `${WEB_URL}/api/waitlist`, checkout: (planId: string) => `${APP_URL}/checkout?planId=${planId}`, }; diff --git a/site/src/data/pricing.ts b/site/src/data/pricing.ts index 601e42ad..cf5ad55a 100644 --- a/site/src/data/pricing.ts +++ b/site/src/data/pricing.ts @@ -4,21 +4,23 @@ // every seat gets its own copy of (the cap is counted per user in // checkCapAndUpsert, so a team never pools them) — the only place a machine count // is a paywall is Free. If YEARLY_OFFER_ACTIVE is turned off in web, set -// OFFER_ACTIVE=false here so the struck price/discount disappear. +// OFFER_ACTIVE=false here so the founding-price line disappears and the card +// shows list. YEARLY_LIST_USD is a price we have not charged yet, so it is only +// ever rendered forwards ("$99 at launch"), never struck through as a former one. // // Checkout is deliberately unwired this release: every card that would charge carries -// `comingSoon`, which swaps the checkout link for a disabled button. Clearing it here -// re-points the CTA at web's live `/checkout` (`web/src/routes/ui.tsx`), but the same -// shutter is duplicated on web's own pricing page (`ComingSoonCta` in -// web/src/ui/pricing.tsx) and on the app's WORKER_CAP Upgrade button +// `comingSoon`, which swaps the checkout link for the founding-pricing capture +// (`WaitlistCta.astro`). Clearing it here re-points the CTA at web's live `/checkout` +// (`web/src/routes/ui.tsx`), but the same shutter is duplicated on web's own pricing +// page (web/src/ui/pricing.tsx) and on the app's WORKER_CAP Upgrade button // (app/lib/screens/device_cap_dialog.dart) — flip all three together, or the funnel // sells a plan two of its three entry points still refuse. import { links } from "../config"; // Single switch for the beta-free period: banners the pricing page, hides the -// trial card, relabels paid CTAs to "Available after beta", and swaps the hero -// pill and closing-CTA copy. Flip to false when plans activate — and update -// support.md's beta note by hand, it is static markdown. +// trial card, and swaps the hero pill, closing-CTA and paid-card copy. Flip to +// false when plans activate — and update support.md's beta note by hand, it is +// static markdown. export const BETA_FREE = true; export const TRIAL_DAYS = 7; @@ -34,13 +36,23 @@ export const OFFER_ACTIVE = true; const seatPriceUsd = OFFER_ACTIVE ? YEARLY_OFFER_USD : YEARLY_LIST_USD; +/** Which surface a founding-pricing address came from — sent as `source` to + * web's /api/waitlist, which bounds it to `/^[a-z0-9][a-z0-9_-]*$/`. A closed + * union rather than `string` so a surface added with a space or a capital fails + * `astro check` instead of 400ing at every reader with copy that blames their + * email address. Adding a member needs no web deploy — the endpoint takes any + * slug of that shape — but it must not collide with a source web sends itself + * (`app_pricing`), or the two surfaces become one row. */ +export type WaitlistSource = "pricing"; + export type PlanCardData = { id: "free" | "trial" | "pro_yearly"; // checkoutId overrides the planId sent to the checkout URL (e.g. yearly trial uses sku "trial"). checkoutId?: string; // ctaHref bypasses checkout entirely — for CTAs that are just sign-in links. ctaHref?: string; - // Renders a disabled "Coming soon" button in place of the checkout CTA. + // Renders the founding-price capture (WaitlistCta.astro) in place of the + // checkout CTA. comingSoon?: boolean; name: string; priceUsd: number; @@ -97,10 +109,10 @@ export const proYearly: PlanCardData = { priceUsd: seatPriceUsd, listUsd: OFFER_ACTIVE ? YEARLY_LIST_USD : undefined, unit: "/ seat / year", - // Under BETA_FREE the card's button is disabled ("Available after beta"), so the + // Under BETA_FREE the card's CTA is an interest capture, not a checkout, so the // copy must not promise a startable trial or a running subscription. note: BETA_FREE - ? "Free while the beta runs — this is the launch price" + ? "Free while the beta runs" : `${TRIAL_DAYS}-day free trial, then $${seatPriceUsd} per seat / year`, features: [ "Handler AI assistant — stack instructions, evidence-gated \"done\", one-tap undo", @@ -111,8 +123,10 @@ export const proYearly: PlanCardData = { "E2E zero-knowledge relay · priority support", ], cta: "Get Pro", + // No figure on this line while the CTA is a capture: the reader is agreeing to + // hear from us, not to a price, and the headline above already carries the number. ctaFooter: BETA_FREE - ? `$${seatPriceUsd} per seat / year when plans activate` + ? "Founding pricing at launch · no card, nothing charged during the beta" : `$${seatPriceUsd} per seat / year · renews automatically · cancel anytime`, recommended: true, }; @@ -145,7 +159,7 @@ export const faq: { q: string; a: string }[] = [ { q: BETA_FREE ? "What happens when the beta ends?" : "Is there a free trial?", a: BETA_FREE - ? `Nothing switches off without warning. Paid plans activate, the prices on this page are the launch prices, and Pro starts with a ${TRIAL_DAYS}-day free trial. The free plan stays free.` + ? `Nothing switches off without warning. Paid plans activate${OFFER_ACTIVE ? ` at the founding prices on this page — below the $${YEARLY_LIST_USD} list price at launch` : " at the prices on this page"}, and Pro starts with a ${TRIAL_DAYS}-day free trial. The free plan stays free.` : `Yes — Pro starts with a ${TRIAL_DAYS}-day free trial on one seat. Your card is not charged until the trial ends, and cancelling before then costs nothing.`, }, { diff --git a/site/src/layouts/Base.astro b/site/src/layouts/Base.astro index a698f3b4..a65c0439 100644 --- a/site/src/layouts/Base.astro +++ b/site/src/layouts/Base.astro @@ -10,8 +10,9 @@ interface Props { ogImage?: string; ogImageAlt?: string; path?: string; + robots?: string; } -const { title, description, ogImage, ogImageAlt, path } = Astro.props; +const { title, description, ogImage, ogImageAlt, path, robots } = Astro.props; --- @@ -25,7 +26,7 @@ const { title, description, ogImage, ogImageAlt, path } = Astro.props; - +
{/* 39rem, not max-w-xl: wide enough that the snapshot row stops wrapping to diff --git a/site/src/pages/pricing.astro b/site/src/pages/pricing.astro index cd39dbb1..7c0dfc55 100644 --- a/site/src/pages/pricing.astro +++ b/site/src/pages/pricing.astro @@ -19,22 +19,26 @@ import { links } from "../config"; {BETA_FREE && (
- antgrid is free while in beta — everything below is included. Paid plans activate when the beta ends; prices shown are launch prices. + antgrid is free while in beta — everything below is included. Paid plans activate when the beta ends; prices shown are founding prices.
)}
- {!BETA_FREE && } - + {!BETA_FREE && } +
Enterprise
-
Unlimited seats · SSO, audit log & IP allowlist · invoiced annually
+ {/* SSO, audit log and IP allowlist are named as roadmap, not as shipped: + the capability flags exist in web's plan model but nothing reads them + yet. Asking for the buyer's requirements is also the better opener — + it starts a conversation where a feature list ends one. */} +
Unlimited seats · invoiced annually · SSO, audit log and IP allowlist on the roadmap — tell us your requirements and your timeline.
- Talk to us + Talk to sales

All plans include end-to-end encryption. The relay never sees your code.

diff --git a/site/src/pages/privacy.md b/site/src/pages/privacy.md index c19b6e44..87521c43 100644 --- a/site/src/pages/privacy.md +++ b/site/src/pages/privacy.md @@ -32,6 +32,7 @@ Radha AI Products is the legal entity behind Antgrid. **Questions or concerns?** - **Account information** — name and email address (provided directly or via a third-party sign-in provider). - **Billing status** — your plan, subscription state, trial status, and transaction identifiers returned by our payment processors or app stores. **We do not collect or store full payment card numbers; these are handled entirely by our payment processors and the app stores.** +- **Waitlist email address** — if you join the founding-pricing waitlist from our website or pricing page, we store the email address you submit and the page you submitted it from, so we can tell you when pricing opens. This does not create an account, and the submitting IP address is used only for rate limiting and is never stored on the record. ### Information collected automatically @@ -60,7 +61,7 @@ Because of our end-to-end encryption, we do **not** have access to, and do **not All agent-to-app traffic is end-to-end encrypted after a handshake using X25519 key exchange and AES-256-GCM authenticated encryption. Encryption keys are generated on your devices, are per-connection, and are never persisted by us. As a result, **we cannot read the contents of the data you transmit through the Services.** -For full transparency about what the relay *can* see: the relay authenticates devices using their public keys and routes messages by device identity, so it processes device identifiers and the public keys exchanged during the handshake. When a recipient device is temporarily offline, the relay briefly buffers a small number of still-encrypted messages in memory so they can be delivered on reconnect; these buffers are never written to disk and remain encrypted. The relay does **not** store your IP address in a database or link it to your account; IP addresses on the relay are held only in memory for the duration of a connection (for rate limiting) and may appear in short-lived operational logs. +For full transparency about what the relay *can* see: the relay authenticates devices using their public keys and routes messages by device identity, so it processes device identifiers and the public keys exchanged during the handshake. When a recipient device is not connected, the relay does not hold the message: the frame is refused and dropped. Nothing is queued, buffered, or written to disk. The relay returns the same response whether the recipient is offline or the sender is not permitted to reach it, so it never discloses which of your devices are online. The relay does **not** store your IP address in a database or link it to your account; IP addresses on the relay are held only in memory for the duration of a connection (for rate limiting) and may appear in short-lived operational logs. ## 4. Analytics, Crash Reporting, and Tracking @@ -107,6 +108,7 @@ We keep personal information only for as long as necessary for the purposes set - **Session IP address and user-agent** — for the lifetime of the session; expired and deleted sessions are removed. - **Cross-device sign-in records** — automatically expire within approximately 10 minutes. - **Operational logs** (which may contain IP addresses) — retained for up to 30 days, then deleted. +- **Waitlist email address** — until founding pricing opens and we have contacted you, or until you ask us to remove it, whichever comes first. When we no longer have a legitimate need to process your information, we delete or anonymize it, or securely isolate it where deletion is not immediately possible (for example, in backups). @@ -122,6 +124,8 @@ Depending on your location, you may have the right to access, correct, update, o You can request deletion of your account and associated personal data at any time by emailing [contact@radhaai.com](mailto:contact@radhaai.com) with the subject "Account Deletion Request" from your registered email address. Upon verification, we will delete or anonymize your account data, except where retention is required by law (for example, tax and transaction records). We will action verified deletion requests within 30 days. +To be removed from the founding-pricing waitlist, email us from the address you submitted with the subject "Waitlist Removal" — no account is needed, and we delete the record on verification. + If you purchased through the Apple App Store or Google Play, manage or cancel any active subscription through your store account before requesting deletion, as those subscriptions are managed by the store (see our [Cancellation & Refund Policy](/refunds)). ## 11. India (DPDP Act, 2023) — Grievance Redressal diff --git a/site/src/pages/security.astro b/site/src/pages/security.astro new file mode 100644 index 00000000..37d23c48 --- /dev/null +++ b/site/src/pages/security.astro @@ -0,0 +1,397 @@ +--- +import { Icon } from "astro-icon/components"; +import Base from "../layouts/Base.astro"; +import Chip from "../components/ui/Chip.astro"; +import Eyebrow from "../components/ui/Eyebrow.astro"; +import Readout from "../components/ui/Readout.astro"; +import { links } from "../config"; + +// Written for someone deciding whether to run this on a work machine, so it +// leads with mechanism and puts the source links above the fold: the page is +// only worth anything to a reader who does not take its word for it. Two rules +// hold the whole file together — every claim here is one the public repository +// proves, and no strong claim ships without the limit that bounds it in the +// same block. A sentence that cannot be traced to source does not go on this +// page, and a gap is never written as if it were a feature. +// +// Colour discipline (see Readout.astro and Eyebrow.astro): amber means a human +// is needed, so the gaps below deliberately do NOT use it — they are facts, not +// alerts. Signal stays on the outbound source links, which are the one thing on +// the page a reader is meant to act on. + +const relayHolds = [ + { it: "Your account id", why: "taken from the verified token — it is the routing key" }, + { it: "Device ids", why: "one per machine, one per app slot" }, + { it: "The device name in the hello frame", why: "on a machine this defaults to its OS hostname" }, + { it: "Each device's Ed25519 public key", why: "and its connection epoch" }, + { it: "The licence credential id presented", why: "kept so a revocation can find the socket" }, + { it: "Your IP address", why: "in memory only, for per-IP connection caps and rate limits" }, + { it: "The Host header of the upgrade", why: "rebuilt into the signature body so a hello cannot be replayed elsewhere" }, + { it: "The hello timestamp and nonce", why: "for replay rejection and equal-epoch arbitration" }, + { it: "Connect time and last-seen time", why: "per open connection" }, + { it: "Who is sending to whom", why: "the destination in each frame's cleartext route header" }, + { it: "Which channel a frame is on", why: "control or preview — it keys the rate-limit bucket" }, + { it: "The size and timing of every frame", why: "and its kind byte, forwarded without interpretation" }, + { it: "Which of your devices are online", why: "and it tells your other live connections" }, + { it: "Push routing", why: "the destination token and provider transit the relay" }, + { it: "Device ids in operational logs", why: "a ping timeout names the device it dropped" }, +]; + +const relayNeverHolds = [ + { it: "Message payloads", why: "sealed on the sending device" }, + { it: "The stream envelope inside them", why: "including stream ids" }, + { it: "Project names and project ids", why: "" }, + { it: "File paths and file contents", why: "" }, + { it: "Terminal output", why: "" }, + { it: "Your prompts and the agent's replies", why: "" }, + { it: "The text of a push notification", why: "it forwards a sealed blob and a placeholder" }, +]; + +const gaps = [ + { + title: "No second factor.", + body: "Sign-in is single factor: an emailed magic link, GitHub, Google, or email and password. There is no TOTP, no passkey and no hardware-key support. The primary path is the magic link, which has no password to steal, and a password set on an unverified address is dropped as soon as someone proves that address another way.", + }, + { + title: "No audit, no penetration test, no certification.", + body: "What exists is a published protocol specification, cross-language test vectors that both implementations must pass, and a security policy. None of those is an external review, and we are not going to describe them as one. We hold no SOC 2 report and no ISO certification, and this page makes no compliance claim of any kind.", + }, + { + title: "Telemetry is on by default.", + body: "It is opt-out, in app settings. Events carry no account id, no device id and no content — an event name, your platform, the app version, and a random install-scoped id that goes only to our own backend and never to the analytics host. Switching it off stops new events and discards anything still queued; crash reporting follows the same toggle and picks the change up at the next launch.", + }, + { + title: "Crash reports are scrubbed on the device, not on receipt.", + body: "File paths are redacted throughout a report before it is sent, and raw source lines and local variables are dropped rather than redacted. That is a scrubber, and a scrubber is a best effort against a stack trace it has not seen before.", + }, + { + title: "Denial of service is out of scope.", + body: "There is per-IP and per-channel rate limiting, but it protects the relay, not your availability. A rate-limited frame is dropped unrecoverably and only the sender is told. Do not read it as an uptime guarantee.", + }, + { + title: "A machine that is already compromised is out of scope.", + body: "Physical access, a malicious local user, and an attacker who already has your shell are all outside what the bridge defends against. That user already has everything your agent has.", + }, +]; + +const sources = [ + { label: "Repository", href: links.repo, note: "the whole product, source-available" }, + { label: "SECURITY.md", href: links.securityPolicyFile, note: "scope, reporting, what to expect" }, + { label: "packages/antgrid_relay_client", href: links.relayClient, note: "the client-side encryption, Apache-2.0" }, + { label: "packages/antgrid-wire", href: links.wirePackage, note: "the wire protocol, Apache-2.0" }, + { label: "docs/protocol/e2e-handshake.md", href: links.handshakeSpec, note: "the handshake, specified to the byte" }, + { label: "e2e-handshake-vectors.json", href: links.handshakeVectors, note: "the vectors both implementations must pass" }, +]; +--- + +
+
+
+ Security +

What runs where, and who can reach it.

+

+ antgrid runs your coding agents on your own machines and carries your traffic to your phone through a relay that holds no key to it. This page is the architecture, not a set of assurances — every claim below is one you can check in the public repository, and every claim that has a limit is printed next to it. +

+

+ If you are evaluating this for a work machine, the three sections that matter are what the relay sees, what has to be true before a phone can drive a machine, and what doesn't exist yet. Nothing here is a compliance statement. +

+
+ {sources.slice(0, 3).map((s) => ( + + + {s.label} + + ))} +
+
+
+ +
+ The wire +

The relay forwards bytes it holds no key for.

+

+ Traffic between your devices and your machines is end-to-end encrypted. Keys are made on the two devices, and the relay is never given one. +

+ +
+ +
    +
  1. + 1 Both sides generate an ephemeral X25519 keypair, fresh for this connection, and exchange the public halves. +
  2. +
  3. + 2 Both sign a canonical transcript with their long-term Ed25519 identity key. The transcript binds both device identities, both ephemeral public keys, a 32-byte fresh nonce, the machine binding and the protocol version. +
  4. +
  5. + 3 Each side verifies the other's signature against the key it already holds for that device. A key exchange someone tampered with in transit produces no signature that verifies. +
  6. +
  7. + 4 Both sides check an HMAC key-confirmation tag, compared in constant time. No application data is sent or accepted before that passes. +
  8. +
  9. + 5 Transport is AES-256-GCM, with a separate key for each direction. +
  10. +
+
+ +
+

+ Because the session keys are ephemeral, a long-term signing key stolen later does not decrypt sessions that already happened. Session keys live in memory for the life of one connection, are never written to disk, and every teardown path overwrites their buffers. +

+
+

Limit

+

+ That overwrite is best effort. Both implementations run in garbage-collected runtimes, so residual key material in a process dump is expected and is not treated as a vulnerability. +

+
+
+

Scope

+

+ This covers app-to-machine traffic that crosses the network. The desktop app driving the machine it is running on does not use this channel at all: it talks to its own bridge over a loopback socket on 127.0.0.1, authenticated by a per-process token compared in constant time. That traffic never leaves the machine. The file carrying that token is written owner-only on POSIX, as are the machine's device inventory, its phone list, its remote-access switch and its session records. +

+
+
+ ephemeral X25519 per connection + Ed25519 transcript signatures + AES-256-GCM, one key per direction + constant-time key confirmation +
+
+
+
+ +
+
+ The relay +

What the relay does see.

+

+ Zero-knowledge is a claim about content, and only about content. Everything the relay needs in cleartext to admit a socket and route a frame, it has. Here is that list in full, beside the list of what it never holds. +

+ +
+ +
    + {relayHolds.map((r) => ( +
  • + {r.it} + {r.why} +
  • + ))} +
+
+ + +
    + {relayNeverHolds.map((r) => ( +
  • + {r.it} + {r.why && {r.why}} +
  • + ))} +
+ + + + no database, no file, no queue anywhere in the relay — an entry exists only while a socket is open + + +
+
+ +
+
+

Routing

+

+ The relay routes only between devices that belong to the same account, and that check runs fresh on every frame. It is the only routing authorization there is. A blocked route and an offline device get the identical answer, so the relay cannot be used to discover which of your machines are up, and presence is announced only to other live connections on your own account. +

+
+
+

Admission

+

+ Every connection is admitted by a single signed hello frame, verified in a fixed order: schema, clock window, Ed25519 signature over a body that binds the relay hostname, replay cache, licence, then connection arbitration. Nothing is queued for a device that is not connected — a frame addressed to one is refused and dropped. +

+
+
+

Push notifications

+

+ The notification body is sealed to your phone's own key on the machine — ephemeral X25519, HKDF, AES-256-GCM — before it ever reaches the relay, and the relay hands Apple or Google a generic placeholder that your phone replaces once it decrypts. Apple and Google see delivery metadata and an opaque blob. The destination push token and provider do transit the relay, which is why they are in the list above. +

+
+
+

What we are still trusted with

+

+ This design takes the relay out of the trust boundary. It does not take our account service out of it. A relay operator who tampers with the key exchange cannot produce a signature your device accepts — but your phone learns a machine's Ed25519 identity key from your account's device inventory, served by app.antgrid.ai, so a compromised account service could hand a device that has not yet cached the real key one of its choosing. The relay is not trusted with identity. The account service is. +

+
+
+
+
+ +
+ Remote access +

Three things have to be true before a phone can drive a machine.

+

+ They are independent and they are checked together. Any one of them false and the command does not run. +

+ +
+
+

01

+

The device is on your signed-in account.

+

+ A device is admitted from your account's own inventory — there is no pairing code or QR ceremony to intercept — and its Ed25519 identity must verify against the handshake transcript before anything proceeds. +

+
+
+

02

+

That machine's remote-access switch is on.

+

+ Off on a fresh install. One boolean for the whole machine, read live at every check, so turning it off takes effect immediately without restarting anything. +

+
+
+

03

+

The project is one that machine already knows.

+

+ A shape check plus a lookup in that machine's own catalog of projects bounds which project a remote device is allowed to name. +

+
+
+ +

+ With the switch off, a remote device sees nothing: every inbound verb is dropped at a single chokepoint, the preview and HTTP tunnel is gated separately because it bypasses that path, and outbound streaming stops at the send. +

+ +
+
+

Not per-project permissions

+

+ The switch is machine-wide. The project catalog is a bound on what a remote device may name, not a grant you issue per project. +

+
+
+

A known device is identity, not permission

+

+ The machine's phone list holds labels, push routing and last-seen. Removing a phone from it revokes nothing. Revocation is deleting the device on your account — which closes its live relay sockets immediately — or turning the machine's switch off. +

+
+
+

The desktop is exempt by design

+

+ The app driving its own machine goes over the loopback socket and never depends on the relay, so it keeps working with the switch off. +

+
+
+
+ +
+
+ Your agents +

Your agents talk to their providers, not to us.

+

+ Coding agents run on your machine as ordinary local processes, launched from your own environment, and reach their model providers directly under your own logins. antgrid operates no model endpoint and holds no model-provider credentials — there is no provider API key anywhere in the bridge, the relay or the web service. +

+

+ That is not the same sentence as "your code never leaves your machine", and we are not going to write that one. Your agent sends your code to its provider; that is what it is for. The claim is that antgrid adds no hop of its own and no key of its own. +

+ +
+
+

Handler runs your agent again

+

+ When you arm Handler on a session, it runs the agent you already picked for that session headlessly and restricted over that working tree — with a proven read-only tool set where the agent offers one, and with the agent's own restricted mode, and no transcript handed to it, where that restriction is configuration rather than a flag we can verify. So working-tree content and transcript excerpts reach the vendor you already chose, on your own account, without a fresh action from you for each call. +

+

+ It never borrows a different vendor's agent to supervise a session unless you pick one yourself, and those runs are kept out of your own session history. +

+
+
+

antgrid does not sandbox your agent

+

+ An agent runs with your environment and your permissions, and an agent you have configured to skip approvals will skip them. Handler's destructive-path floor is an advisory floor, not a sandbox. +

+

+ In an isolated session, the branch's own setup steps run as shell lines — the same trust class as the commands you run yourself. Escapes out of the checkout path are refused, but the commands themselves are branch content. +

+
+
+
+
+ +
+ Honest inventory +

What doesn't exist yet.

+

+ Every item here is something a reasonable reviewer will ask for and we do not have. None of it is written as a feature. +

+
+ {gaps.map((g) => ( +
+

{g.title}

+

{g.body}

+
+ ))} +
+

+ What we collect, and for how long, is set out on the privacy page. +

+
+ +
+
+ Verify it +

Check the crypto claims yourself.

+

+ The handshake is specified in public down to the byte layout of the signed transcript, with cross-language test vectors that both the TypeScript and the Dart implementation must pass. The wire protocol and the client-side encryption are Apache-2.0 and carry their own licence files, so you can read, reimplement and publish work built on them without asking us. +

+

+ The rest of antgrid is source-available under the Elastic License 2.0. That is not OSI-approved open source and we do not call it that. +

+ +
+
+ +
+ Disclosure +

Reporting a vulnerability.

+

+ If you can read traffic the relay is not supposed to read, or run something on a machine without its owner's account and consent, we want to hear about it before anyone else does. Please don't open a public issue, discussion or pull request for it. +

+
+
+

Email

+ contact@radhaai.com +

Put "Security" in the subject line.

+
+
+

GitHub private advisory

+ + + Report a vulnerability + +

Preferred, and it keeps the thread on the repository.

+
+
+

+ We acknowledge reports within three business days, tell you what we found, keep you updated while we fix it, and credit you in the release notes if you want it. We ask for a reasonable window before public disclosure. Please test against your own account and your own machines. +

+

+ What is in scope and what is not is listed in SECURITY.md. Machine-readable contact details are at /.well-known/security.txt. +

+
+
+ diff --git a/site/src/pages/support.md b/site/src/pages/support.md index e0cd59a3..1dbd0973 100644 --- a/site/src/pages/support.md +++ b/site/src/pages/support.md @@ -13,7 +13,7 @@ Need a hand? Email **[contact@radhaai.com](mailto:contact@radhaai.com)** and we' ## What is Antgrid? -Antgrid lets you run AI coding agents such as **Claude Code**, **Codex**, and **Gemini CLI** on your own computer, then monitor and control them from your phone, tablet, or desktop over an end-to-end encrypted connection. You see the agent's live terminal output, browse the project's files, and stay in control from anywhere — and Handler, the built-in assistant, marks nothing done without evidence. +Antgrid lets you run AI coding agents such as **Claude Code**, **Codex**, and **Cursor** on your own computer, then monitor and control them from your phone, tablet, or desktop over an end-to-end encrypted connection. You see the agent's live terminal output, browse the project's files, and stay in control from anywhere — and Handler, the built-in assistant, marks nothing done without evidence. ## Supported platforms @@ -32,19 +32,20 @@ Your agent's live output, files, and sessions appear once a project is running. ## Signing in -Antgrid supports three sign-in options: +Antgrid supports four sign-in options: - **Email magic link** — enter your email, open the link we send you, then press **"Approve sign-in"** on the page it opens. - **GitHub** - **Google** +- **Email and password** — available once you have set a password on your account; verify the address first. **Didn't get your magic-link email?** Check your spam folder and request a new link from the app. Links are single-use and expire after **10 minutes**. ## Plans and billing -**Antgrid is currently free while in beta** — every feature is included, Handler and remote control alike, on the house. Paid plans activate when the beta ends; the prices on [Pricing](/pricing) are the launch prices. +**Antgrid is currently free while in beta** — every feature is included, Handler and remote control alike, on the house. Paid plans activate when the beta ends; the prices on [Pricing](/pricing) are founding prices, below the list price at launch. -Once plans are live: **Antgrid is free on one worker machine**, with end-to-end encrypted **remote control**, fleet view and browser preview included. **Pro is billed per seat — one seat per person** — and gives every person up to **10 worker machines** of their own, plus **Handler** — the AI assistant that watches your sessions, takes instructions mid-run, and judges every item against evidence — and priority support. Pro includes a **7-day free trial** and covers up to **25 seats**. Larger teams, SSO, audit logs and IP allowlisting are **Enterprise** — email us at [contact@radhaai.com](mailto:contact@radhaai.com). See [Pricing](/pricing) for current details. +Once plans are live: **Antgrid is free on one worker machine**, with end-to-end encrypted **remote control**, fleet view and browser preview included. **Pro is billed per seat — one seat per person** — and gives every person up to **10 worker machines** of their own, plus **Handler** — the AI assistant that watches your sessions, takes instructions mid-run, and judges every item against evidence — and priority support. Pro includes a **7-day free trial** and covers up to **25 seats**. Larger teams are **Enterprise**, where SSO, audit logs and IP allowlisting are on the roadmap — email us at [contact@radhaai.com](mailto:contact@radhaai.com). See [Pricing](/pricing) for current details. **Manage or cancel your subscription:** diff --git a/site/src/pages/terms.md b/site/src/pages/terms.md index ee644009..c754d37e 100644 --- a/site/src/pages/terms.md +++ b/site/src/pages/terms.md @@ -25,7 +25,7 @@ The Services are intended for users who are at least 18 years old. ## 1. Our Services -Antgrid is a command centre for AI coding agents that you run on your own machine and can monitor and control remotely from your other devices. **Local use of Antgrid is free.** Paid plans add encrypted remote control and related features (see Section 5). +Antgrid is a command centre for AI coding agents that you run on your own machine and can monitor and control remotely from your other devices. **Antgrid has a free plan, and encrypted remote control is included on it.** Paid plans add more worker machines per person, the Handler assistant, and team seats (see Section 5). The Services are not intended for use in any jurisdiction where such use would be contrary to law or would subject us to any registration requirement. You access the Services on your own initiative and are responsible for compliance with applicable local laws. diff --git a/site/src/styles/global.css b/site/src/styles/global.css index c74883be..b36dc630 100644 --- a/site/src/styles/global.css +++ b/site/src/styles/global.css @@ -112,6 +112,42 @@ so they can't drift apart. Prose caps (max-w-xl/2xl) are deliberately NOT tied to this: body copy stays at a readable measure however wide the shell. */ --container-shell: 72rem; + + /* ---- App material ----------------------------------------------------- + The APP's palette (app/lib/design/ab_colors.dart), carried here so the + hand-built app shell reads as the product sitting on the page rather than + as more page with a border around it. + + These are deliberately NOT reconciled with the site ramp above. The app is + Zinc-neutral and the site is warm-neutral, and that difference is the only + thing doing the work — an app window painted in --color-panel is just a + card. Keep every `ab-` token pointing at the Dart value it mirrors; the two + drifting apart is silent and shows up as a window that looks almost right. + + The accent is absent on purpose: the app's `accent`/`accentHighlight`/ + `accentMuted` are ALREADY #db6f4b / #ea997f / #d2542a, byte-identical to + --color-signal / signal2 / signalbtn above, so the shell uses those. */ + --color-ab-deepest: #09090b; + --color-ab-deep: #0c0c0f; + --color-ab-surface: #18181b; + --color-ab-raised: #1f1f23; + --color-ab-elevated: #27272a; + --color-ab-selected: #2f2f35; + --color-ab-line-soft: #1a1a1f; + --color-ab-line: #27272a; + --color-ab-line-strong: #3f3f46; + --color-ab-text: #e4e4e7; + --color-ab-text2: #a1a1aa; + --color-ab-mute: #71717a; + --color-ab-dim: #52525b; + /* Status tones. `attn` is the app's statusAttention and it means what amber + means everywhere else on this site: a human is needed. Nothing else in a + scene may take it. */ + --color-ab-run: #8fcfae; + --color-ab-think: #e2c792; + --color-ab-attn: #e5a055; + --color-ab-ok: #22c55e; + --color-ab-err: #f87171; } html { @@ -136,11 +172,41 @@ body { /* ---- Atmosphere -------------------------------------------------------- */ -/* Signal radial glow — position with inset utilities on an absolute wrapper. */ +/* Signal radial glow — position with inset utilities on an absolute wrapper. + Only safe where a frame contains it (the closing CTA's bordered card); the hero + is full-bleed and needs .glow-hero below. */ .glow-signal { background: radial-gradient(closest-side, rgba(210, 84, 42, 0.16), transparent 70%); } +/* The hero's light, which is two different devices because a phone has no room + for the desktop one. Wide: a compact halo about a third of the frame across, + with dark air either side — the falloff is visible on every axis, which is the + only reason it reads as a light source at all. Narrow: that same fixed 980px + halo is wider than the viewport, so no falloff lands inside the frame and the + light flattens into a brown tint over the headline, ending in one hard + horizontal terminus under the kicker. + So below md it stops being an orb and becomes an edge: the gradient's centre + sits ON the top edge, so there is never a circle to resolve, and the falloff + runs down the one axis a phone has room for. Alpha drops with it — 0.16 across + the whole of a small frame is a far larger event than 0.16 across a third of a + large one, and it is sitting behind the lowest-contrast text on the page. */ +.glow-hero { + position: absolute; + inset: 0 0 auto 0; + height: 340px; + background: radial-gradient(ellipse 150% 100% at 50% 0%, rgba(210, 84, 42, 0.11), transparent 72%); +} +@media (min-width: 48rem) { + .glow-hero { + inset: -14rem auto auto 50%; + height: 620px; + width: min(980px, 68vw); + transform: translateX(-50%); + background: radial-gradient(closest-side, rgba(210, 84, 42, 0.16), transparent 70%); + } +} + /* The namesake, made literal. Ruled lines at one pitch (square cells at any zoom) plus a scattered handful of filled cells: a rack where most machines are idle and a few are working, which is the picture the page is selling before a @@ -167,6 +233,17 @@ body { .live-cells { mask-image: radial-gradient(130% 85% at 50% 8%, black 8%, transparent 72%); } +/* Not below md. The cells are placed in raw px against a wide canvas, so on a + phone only the leftmost column is on screen and every one of those sits behind + the copy rather than beside it — a warm block fading in and out under muted + body text reads as a rendering fault, not as a machine waking up. The static + field stays; liveness is carried in the same viewport by the beta pill and by + the ProofCard's own loop, which says it far better than a background can. */ +@media (max-width: 47.999rem) { + .live-cells { + display: none; + } +} .live-cells rect { fill: rgba(210, 84, 42, 0.14); opacity: 0; @@ -283,3 +360,84 @@ html.js .reveal.in { transition: none; } } + + +/* ---- App shell --------------------------------------------------------- */ + +/* The window's pane geometry. In CSS rather than utilities because the three + columns collapse in a specific ORDER as width runs out — the context pane + first, then the rail — and that sequence is one decision that belongs in one + place, not spread across three responsive prefixes on three elements. */ +.appwin-panes { + display: grid; + grid-template-columns: minmax(0, 1fr); +} +.appwin-rail, +.appwin-ctx { + display: none; +} +@media (min-width: 40rem) { + .appwin-panes { + grid-template-columns: 12.5rem minmax(0, 1fr); + } + .appwin-rail { + display: block; + } +} +/* Only the three-pane variant ever gets the context column back, and only once + the agent pane can still hold a terminal line without wrapping it. The + context column is sized by its tab strip, not by its content: Preview / + Files / Git / Terminals / Handler is the app's full set and all five have to + fit on one line, because a wrapped or clipped strip is the one detail that + gives a hand-built window away. */ +@media (min-width: 64rem) { + .appwin-panes--3 { + grid-template-columns: 13rem minmax(0, 1fr) 21rem; + } + .appwin-panes--3 .appwin-ctx { + display: block; + } +} +@media (min-width: 80rem) { + .appwin-panes { + grid-template-columns: 14rem minmax(0, 1fr); + } + .appwin-panes--3 { + grid-template-columns: 14rem minmax(0, 1fr) 24rem; + } +} + +/* The hero window runs off the bottom of its section, so the reader feels the + app continue past the fold rather than watching a card end. The fade is a + sibling rather than a pseudo-element on the window itself — as a child it + would sit under the window's own border and leave a bright hairline across + the point the page is trying to dissolve. */ +.appwin-bleed > .appwin { + border-bottom: 0; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.appwin-caret { + display: inline-block; + width: 0.5em; + height: 1.05em; + vertical-align: -0.18em; + background: var(--color-signal); + animation: ab-caret 1.1s steps(2) infinite; +} +@keyframes ab-caret { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .appwin-caret { + animation: none; + } +} diff --git a/site/tests/contracts.spec.ts b/site/tests/contracts.spec.ts index 90eadc6b..2015def5 100644 --- a/site/tests/contracts.spec.ts +++ b/site/tests/contracts.spec.ts @@ -14,6 +14,9 @@ const DOWNLOADS = { linux: "https://github.com/antgrid-ai/antgrid/releases/latest/download/antgrid-linux.AppImage", }; +// The web service, which is a different origin from this static build. +const WAITLIST_ORIGIN = "https://app.antgrid.ai"; + test("desktop downloads point at the published release artifacts", async ({ page }) => { await page.goto("/#download"); const band = page.locator("#download"); @@ -59,14 +62,22 @@ test("the paid path stays closed: no checkout links anywhere", async ({ page }) } }); -test("charging plans render a disabled button, never a live CTA", async ({ page }) => { +test("charging plans capture interest, never a live checkout CTA", async ({ page }) => { await page.goto("/pricing"); - // Any card carrying `comingSoon` (pricing.ts) must swap its checkout link for a - // disabled button. Asserted by state, not by label — the label is BETA_FREE-gated. + // Any card carrying `comingSoon` (pricing.ts) must swap its checkout link for the + // founding-price capture. Asserted by state, not by label — copy is BETA_FREE-gated. const yearlyCard = page.locator("span.font-mono", { hasText: /^Pro$/ }).locator("..").locator(".."); - await expect(yearlyCard.locator("button[disabled]")).toHaveCount(1); + const capture = yearlyCard.locator("form[data-waitlist]"); + await expect(capture).toHaveCount(1); + // The address goes to the web service, cross-origin from this static site. + await expect(capture).toHaveAttribute("action", `${WAITLIST_ORIGIN}/api/waitlist`); + await expect(capture).toHaveAttribute("data-waitlist", "pricing"); await expect(yearlyCard.locator("a[href]")).toHaveCount(0); + // The capture ships disabled so a scriptless reader is told to email instead; once + // the page's script has run nothing in the card may still be inert, or the dead + // paid CTA is back under a new name. + await expect(yearlyCard.locator("button[disabled]")).toHaveCount(0); // The free card is the one plan whose CTA stays live. const freeCard = page.locator("span.font-mono", { hasText: /^Free$/ }).locator("..").locator(".."); @@ -85,7 +96,7 @@ for (const path of ["/pricing", "/terms", "/refunds", "/support"]) { // Indexed pages. og-card is excluded on purpose: it is the screenshot source for // the social card, already noindex and filtered out of the sitemap. -const INDEXED = ["/", "/pricing", "/get-started", "/support", "/privacy", "/terms", "/refunds"]; +const INDEXED = ["/", "/pricing", "/get-started", "/support", "/privacy", "/terms", "/refunds", "/security"]; test("every indexed page ships a description search engines will show whole", async ({ page }) => { // 155 is where Google starts truncating. Social previews cut earlier — mobile @@ -99,6 +110,17 @@ test("every indexed page ships a description search engines will show whole", as } }); +// The filename tracks what the card SAYS (Seo.astro), so a recut renames it — +// and the rename is a string in Seo.astro that nothing else checks. Get it wrong +// and og:image 404s: every shared link loses its preview, on every page at once, +// with the site otherwise green. Assert the file, never the name. +test("the social card the meta tag names is actually in the build", async ({ page }) => { + await page.goto("/"); + const src = await page.locator('meta[property="og:image"]').getAttribute("content"); + const res = await page.request.get(new URL(src!).pathname); + expect(res.status(), `og:image is missing from the build: ${src}`).toBe(200); +}); + test("the social card declares its dimensions so previews reserve the box", async ({ page }) => { // Without these a client fetches the PNG before it can size the card, and the // preview reflows around it — or renders the link bare while it waits. @@ -108,3 +130,43 @@ test("the social card declares its dimensions so previews reserve the box", asyn const alt = await page.locator('meta[property="og:image:alt"]').getAttribute("content"); expect(alt, "the card carries no alt text").toBeTruthy(); }); + +// Every in-page anchor the site links to must exist. home.spec.ts's dead-link +// sweep skips "/#..." hrefs — it resolves them over HTTP, where the fragment is +// never sent — so a renamed section id breaks navigation with nothing red. These +// are the only links on the site that can rot silently. +test("every in-page anchor the nav and footer offer has a section to land on", async ({ page }) => { + await page.goto("/"); + const fragments = await page.locator("a[href^='/#'], a[href^='#']").evaluateAll((els) => + [...new Set(els.map((e) => (e as HTMLAnchorElement).getAttribute("href")!.split("#")[1]))] + ); + expect(fragments.length, "the home page offers no in-page anchors at all").toBeGreaterThan(0); + for (const id of fragments) { + await expect(page.locator(`#${id}`), `nothing on the page has id="${id}"`).toHaveCount(1); + } +}); + +// Features has to open on the paid feature. Handler is the only thing anyone pays +// for and its section sits ABOVE the fleet view, so aiming this at #fleet scrolled +// the reader straight past it — a revenue link that resolved fine and pointed at +// the wrong thing, which is why it is pinned by target here rather than by wording. +test("Features opens the section that sells Handler", async ({ page }) => { + await page.goto("/"); + const features = page.getByRole("link", { name: /^Features$/ }); + expect(await features.count(), "no Features link").toBeGreaterThan(0); + for (let i = 0; i < (await features.count()); i++) { + await expect(features.nth(i)).toHaveAttribute("href", "/#handler"); + } + await expect(page.locator("#handler")).toContainText("Handler"); +}); + +// The 404 template answers EVERY unknown path, so without this a mistyped inbound +// link can be indexed under its own URL as a page that says nothing exists. +test("the not-found page is kept out of the index", async ({ page }) => { + await page.goto("/404"); + await expect(page.locator('meta[name="robots"]')).toHaveAttribute("content", /noindex/); + + // Real pages must NOT inherit it — a stray default here delists the whole site. + await page.goto("/"); + await expect(page.locator('meta[name="robots"]')).toHaveCount(0); +}); diff --git a/site/tests/home.spec.ts b/site/tests/home.spec.ts index 2e894c3a..47b13801 100644 --- a/site/tests/home.spec.ts +++ b/site/tests/home.spec.ts @@ -41,12 +41,28 @@ test("privacy shows relay's-eye view and crypto chips", async ({ page }) => { await expect(page.getByText("AES-256-GCM")).toBeVisible(); }); -test("cross-agent shows agents and the 3 steps", async ({ page }) => { +// The roster lives in #agents now, not in the cross-agent band — asserting the +// chip from an unscoped page locator kept this test green off the OTHER section. +test("cross-agent shows the 3 steps", async ({ page }) => { await page.goto("/"); - await expect(page.getByRole("heading", { name: /bring the agent you already use\./i })).toBeVisible(); - await expect(page.getByText("any terminal agent")).toBeVisible(); - await expect(page.getByText("Windows, macOS, Linux")).toBeVisible(); - await expect(page.getByText("Take it with you")).toBeVisible(); + const section = page.locator("section").filter({ hasText: "Bring the agent you already use." }); + await expect(section.getByRole("heading", { name: /bring the agent you already use\./i })).toBeVisible(); + await expect(section.getByText("Windows, macOS, Linux")).toBeVisible(); + await expect(section.getByText("Take it with you")).toBeVisible(); +}); + +// The only wording assertion in this file, and it is not marketing copy: the +// supervised three are whatever `handlerObservable` answers true for in +// bridge/src/agents/registry.ts, so a fourth chip going accent — or the prose +// falling out of step with the chips — is a false capability claim, not a +// rewrite. The catch-all chip is the free-tier promise beside it. +test("the agent roster names the supervised three and a catch-all", async ({ page }) => { + await page.goto("/#agents"); + const agents = page.locator("#agents"); + for (const name of ["Claude Code", "Codex", "opencode"]) { + await expect(agents.getByText(name, { exact: true })).toHaveCount(2); + } + await expect(agents.getByText("any terminal agent")).toBeVisible(); }); test("closing CTA band renders with app stores still pending", async ({ page }) => { @@ -64,7 +80,7 @@ test("no horizontal overflow on mobile", async ({ page }) => { }); test("internal links resolve (no dangling hrefs to missing pages)", async ({ page }) => { - const removedPages = ["/docs", "/security"]; + const removedPages = ["/docs"]; for (const startPath of ["/", "/pricing", "/get-started"]) { await page.goto(startPath); diff --git a/site/tests/pricing.spec.ts b/site/tests/pricing.spec.ts index 94013ca0..4a28947c 100644 --- a/site/tests/pricing.spec.ts +++ b/site/tests/pricing.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { test, expect, type Page } from "@playwright/test"; // Prices and the tier axis. KEEP IN LOCKSTEP with src/data/pricing.ts, which is itself // pinned to the shipped catalog by web/tests/billing/site-pricing-lockstep.test.ts — @@ -23,15 +23,133 @@ test("plan cards carry the real prices, machine allowances and seat ceiling", as await expect(freeCard.locator("[data-price]", { hasText: "$0" })).toBeVisible(); await expect(freeCard.getByText("1 worker machine")).toBeVisible(); - // Yearly card: $49 offer price (the headline figure) + $99 struck list price, both - // per seat — the unit is the claim, so it is asserted beside the number. + // Yearly card: $49 founding price (the headline figure) and $99 named as the + // price at launch. The unit is part of the claim, so it is asserted beside the + // number. $99 must never render as a struck-through former price — it has never + // been charged, so a crossed-out "was" would be a reference price we invented. await expect(yearlyCard.locator("[data-price]", { hasText: "$49" })).toBeVisible(); - await expect(yearlyCard.locator("span.line-through", { hasText: "$99" })).toBeVisible(); + await expect(yearlyCard.locator("[data-list]", { hasText: "$99" })).toBeVisible(); + await expect(yearlyCard.getByText(/Founding price/)).toBeVisible(); + await expect(yearlyCard.locator("s, del, .line-through")).toHaveCount(0); await expect(yearlyCard.getByText("/ seat / year")).toBeVisible(); await expect(yearlyCard.getByText("Up to 10 worker machines per person")).toBeVisible(); await expect(yearlyCard.getByText(/Up to 25 seats/)).toBeVisible(); }); +// Founding-price capture. The paid card's CTA is an interest form, not a checkout — +// contracts.spec.ts pins its target and the closed paid path; these cover what the +// reader actually experiences at the control. + +const capture = (page: Page) => + page.locator("span.font-mono", { hasText: /^Pro$/ }).locator("..").locator("..").locator("form[data-waitlist]"); + +test("the capture asks for an address without naming a price", async ({ page }) => { + await page.goto("/pricing"); + const form = capture(page); + + // The waitlist trades on "founding pricing", never on a figure or a struck anchor — + // an address is not consent to a price. + await expect(form).not.toContainText("$"); + await expect(form.locator("s, del, .line-through")).toHaveCount(0); + + // Accessibility floor: a real label (visually hidden is fine), an email field, and a + // status line the reader's screen reader is told about. + const field = form.getByLabel(/email address/i); + await expect(field).toHaveAttribute("type", "email"); + await expect(form.locator("[aria-live]")).toHaveCount(1); + await expect(form.getByRole("button", { name: /^Join the list$/ })).toBeEnabled(); +}); + +test("joining posts the address with the surface it came from", async ({ page }) => { + const posted: unknown[] = []; + await page.route("**/api/waitlist", async (route) => { + posted.push(route.request().postDataJSON()); + await route.fulfill({ + status: 200, + headers: { "content-type": "application/json", "access-control-allow-origin": "*" }, + body: JSON.stringify({ ok: true }), + }); + }); + + await page.goto("/pricing"); + const form = capture(page); + await form.getByLabel(/email address/i).fill("founder@example.com"); + await form.getByRole("button", { name: /^Join the list$/ }).click(); + + // One verb throughout: the button says Join, so the confirmation says joined. + await expect(form.locator("[aria-live]")).toContainText(/joined the list/i); + await expect(form.getByRole("button", { name: /^Joined$/ })).toBeVisible(); + expect(posted).toEqual([{ email: "founder@example.com", source: "pricing" }]); +}); + +test("the control says it is working while the address is in flight", async ({ page }) => { + let release = () => {}; + const held = new Promise((resolve) => (release = resolve)); + await page.route("**/api/waitlist", async (route) => { + await held; + await route.fulfill({ + status: 200, + headers: { "content-type": "application/json", "access-control-allow-origin": "*" }, + body: JSON.stringify({ ok: true }), + }); + }); + + await page.goto("/pricing"); + const form = capture(page); + await form.getByLabel(/email address/i).fill("founder@example.com"); + await form.getByRole("button", { name: /^Join the list$/ }).click(); + + // Same verb in every state, so the reader never wonders whether a second thing + // happened: Join -> Joining -> Joined. + await expect(form.getByRole("button", { name: /^Joining/ })).toBeDisabled(); + release(); + await expect(form.getByRole("button", { name: /^Joined$/ })).toBeVisible(); +}); + +test("a malformed address is refused at the field, before anything is sent", async ({ page }) => { + let requests = 0; + await page.route("**/api/waitlist", async (route) => { + requests += 1; + await route.fulfill({ status: 200, headers: { "access-control-allow-origin": "*" }, body: "{}" }); + }); + + await page.goto("/pricing"); + const form = capture(page); + await form.getByLabel(/email address/i).fill("founder@"); + await form.getByRole("button", { name: /^Join the list$/ }).click(); + + await expect(form.locator("[aria-live]")).toContainText(/does not look like an email address/i); + // The error says what to do next and leaves the control usable, rather than + // dead-ending the way the button it replaced did. + await expect(form.locator("[aria-live]")).toContainText(/try again/i); + await expect(form.getByRole("button", { name: /^Join the list$/ })).toBeEnabled(); + expect(requests).toBe(0); +}); + +test("a rejected address explains itself and leaves the reader able to retry", async ({ page }) => { + // The API answers a rejection with a machine code, so the page owes the reader + // its own sentence — echoing "BAD_REQUEST" back at them is not an explanation. + await page.route("**/api/waitlist", async (route) => { + await route.fulfill({ + status: 400, + headers: { "content-type": "application/json", "access-control-allow-origin": "*" }, + body: JSON.stringify({ ok: false, error: "BAD_REQUEST" }), + }); + }); + + await page.goto("/pricing"); + const form = capture(page); + await form.getByLabel(/email address/i).fill("founder@example.com"); + await form.getByRole("button", { name: /^Join the list$/ }).click(); + + const status = form.locator("[aria-live]"); + await expect(status).toContainText(/not accepted/i); + await expect(status).toContainText(/try again/i); + await expect(status).not.toContainText("BAD_REQUEST"); + await expect(form.getByRole("button", { name: /^Join the list$/ })).toBeEnabled(); + await expect(form.getByLabel(/email address/i)).toBeEditable(); +}); + test("the FAQ answers the seat and machine questions in place", async ({ page }) => { await page.goto("/pricing"); await expect(page.getByRole("heading", { name: /what counts as a seat\?/i })).toBeVisible(); diff --git a/site/tests/security.spec.ts b/site/tests/security.spec.ts new file mode 100644 index 00000000..b9aab4d0 --- /dev/null +++ b/site/tests/security.spec.ts @@ -0,0 +1,90 @@ +import { test, expect } from "@playwright/test"; + +// The page only works if a reader can go and check it, so what is asserted here +// is the structure that makes that possible: the sections a sceptical reader is +// sent to, the outbound links that let them read the crypto themselves, and +// security.txt actually being served. Prose inside the sections is deliberately +// not asserted — see the note at the top of home.spec.ts. + +const REPO = "https://github.com/antgrid-ai/antgrid"; + +test("security page renders with one h1 and the sections it promises", async ({ page }) => { + await page.goto("/security"); + const h1 = page.getByRole("heading", { level: 1 }); + await expect(h1).toHaveCount(1); + expect((await h1.innerText()).trim().length).toBeGreaterThan(0); + + await expect(page.getByRole("heading", { name: /what the relay does see/i })).toBeVisible(); + await expect(page.getByRole("heading", { name: /three things have to be true/i })).toBeVisible(); + await expect(page.getByRole("heading", { name: /exist yet/i })).toBeVisible(); + await expect(page.getByRole("heading", { name: /reporting a vulnerability/i })).toBeVisible(); + + // The relay section is the page's central claim: both halves of the ledger + // must render, not just the flattering one. + const relay = page.locator("#relay"); + await expect(relay.getByText("in cleartext at the relay")).toBeVisible(); + await expect(relay.getByText("never at the relay")).toBeVisible(); +}); + +test("the verification links point at the public repository", async ({ page }) => { + await page.goto("/security"); + // Asserted as targets rather than fetched: these are third-party URLs, and a + // GitHub outage must not be able to fail the site suite. `.first()` because + // each of these is offered twice — once above the fold, once in the verify + // list — and a second copy appearing is not a regression. + await expect(page.getByRole("link", { name: "Repository" }).first()).toHaveAttribute("href", REPO); + await expect(page.getByRole("link", { name: "SECURITY.md" }).first()).toHaveAttribute( + "href", + `${REPO}/blob/HEAD/SECURITY.md` + ); + await expect(page.getByRole("link", { name: "packages/antgrid_relay_client" }).first()).toHaveAttribute( + "href", + `${REPO}/tree/HEAD/packages/antgrid_relay_client` + ); + await expect(page.getByRole("link", { name: /report a vulnerability/i })).toHaveAttribute( + "href", + `${REPO}/security/advisories/new` + ); + await expect(page.getByRole("link", { name: "contact@radhaai.com" })).toHaveAttribute( + "href", + /^mailto:contact@radhaai\.com/ + ); +}); + +test("every internal link on the page resolves", async ({ page }) => { + await page.goto("/security"); + const hrefs = await page.locator("a[href^='/']").evaluateAll((els) => + [...new Set(els.map((e) => (e as HTMLAnchorElement).getAttribute("href")!))].filter((h) => !h.startsWith("/#")) + ); + expect(hrefs.length).toBeGreaterThan(0); + for (const href of hrefs) { + const res = await page.request.get(href); + expect(res.status(), `dead link on /security: ${href}`).toBeLessThan(400); + } +}); + +test("security.txt is served with the fields a scanner reads", async ({ page }) => { + const res = await page.request.get("/.well-known/security.txt"); + expect(res.status()).toBe(200); + const body = await res.text(); + expect(body).toContain("Contact: mailto:contact@radhaai.com"); + expect(body).toContain("Canonical: https://antgrid.ai/.well-known/security.txt"); + expect(body).toContain("Preferred-Languages:"); + // RFC 9116 treats an expired file as stale, so the date has to stay ahead of + // the reader — bump it, never drop the field. + const expires = body.match(/^Expires: (.+)$/m); + expect(expires, "security.txt has no Expires field").toBeTruthy(); + expect(new Date(expires![1]).getTime()).toBeGreaterThan(Date.now()); +}); + +test("the footer routes readers to the security page", async ({ page }) => { + await page.goto("/"); + await expect(page.locator("footer").getByRole("link", { name: "Security" })).toHaveAttribute("href", "/security"); +}); + +test("no horizontal overflow on mobile", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/security"); + const overflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth + 1); + expect(overflow).toBe(false); +}); diff --git a/web/prisma/migrations/20260901000000_add_waitlist_signup/migration.sql b/web/prisma/migrations/20260901000000_add_waitlist_signup/migration.sql new file mode 100644 index 00000000..df5bb1ae --- /dev/null +++ b/web/prisma/migrations/20260901000000_add_waitlist_signup/migration.sql @@ -0,0 +1,21 @@ +-- Add waitlist_signup for the marketing site's launch-interest capture. +-- Rows are written by the anonymous, cross-origin POST /api/waitlist route; no +-- FK to user — a signup happens long before an account exists. +-- +-- The unique index on "email" is load-bearing, not hygiene: the route inserts +-- with ON CONFLICT DO NOTHING so a repeat submit is a silent no-op answered +-- with the same 200 as a first submit. Without it a second submit would create +-- a duplicate row, and any later de-dup would have to distinguish the two — +-- which is exactly the membership fact the endpoint must not expose. + +CREATE TABLE "waitlist_signup" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "email" TEXT NOT NULL, + "source" TEXT NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT now(), + + CONSTRAINT "waitlist_signup_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "waitlist_signup_email_key" ON "waitlist_signup" ("email"); +CREATE INDEX "waitlist_signup_created_at_idx" ON "waitlist_signup" ("created_at"); diff --git a/web/prisma/schema.prisma b/web/prisma/schema.prisma index 5a7f26ea..5fea2e22 100644 --- a/web/prisma/schema.prisma +++ b/web/prisma/schema.prisma @@ -367,6 +367,21 @@ model AnalyticEvent { @@map("analytic_event") } +// ---------- Marketing waitlist ---------- + +model WaitlistSignup { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + // Unique so a re-submit is an ON CONFLICT DO NOTHING rather than a duplicate + // row — POST /api/waitlist answers 200 either way and must never reveal which + // of the two happened. Stored already lowercased/trimmed by the route. + email String @unique(map: "waitlist_signup_email_key") + source String + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + + @@index([createdAt], map: "waitlist_signup_created_at_idx") + @@map("waitlist_signup") +} + // ---------- Better-Auth OAuth Provider plugin ---------- model OauthClient { diff --git a/web/src/app.ts b/web/src/app.ts index 03ba8d5b..68b5c5f4 100644 --- a/web/src/app.ts +++ b/web/src/app.ts @@ -14,6 +14,7 @@ import { devBillingRoutes } from "./routes/dev-billing.js"; import { oauthHandoffRoutes } from "./routes/oauth-handoff.js"; import { oauthStartRoutes } from "./routes/oauth-start.js"; import { eventsRoutes } from "./routes/events.js"; +import { waitlistRoutes } from "./routes/waitlist.js"; import { uiRoutes } from "./routes/ui.js"; import { setPublicOrigin } from "./ui/origin.js"; import type { DB } from "./db/index.js"; @@ -73,6 +74,10 @@ export function buildApp(deps: AppDeps) { credentials: true, allowHeaders: ["content-type", "authorization"], allowMethods: ["GET", "POST", "DELETE", "OPTIONS"], + // Without this the fetch spec caches a preflight for 5 seconds, so every + // retry on a cross-origin JSON POST (the marketing site's waitlist form) + // pays a second round trip before the one that carries the body. + maxAge: 86400, }) ); @@ -110,6 +115,7 @@ export function buildApp(deps: AppDeps) { }); app.route("/", health); app.route("/", eventsRoutes({ db: deps.db, clientIp })); + app.route("/", waitlistRoutes({ db: deps.db, clientIp })); app.route("/", deviceRoutes({ db: deps.db, auth: deps.auth, relay: deps.relay })); app.route("/", agentRoutes({ db: deps.db, auth: deps.auth, env: deps.env })); app.route("/", subscriptionRoutes({ db: deps.db, auth: deps.auth })); diff --git a/web/src/routes/ui.tsx b/web/src/routes/ui.tsx index 2f9a8122..848f00da 100644 --- a/web/src/routes/ui.tsx +++ b/web/src/routes/ui.tsx @@ -1535,10 +1535,10 @@ export function uiRoutes(deps: { const userId = c.get("userId"); await provisionProductAccountForUser(deps.db, userId); const plans = await listActivePlans(deps.db); - // TEMP-PROMO: every plan renders as a disabled "Coming soon" card while - // in-app purchases aren't live — grep "TEMP-PROMO" repo-wide for every - // related spot (backend grant logic in web/src/models/subscription.ts - // plus the matching disabled UI in web/src/ui/pricing.tsx). + // TEMP-PROMO: no plan can be bought while in-app purchases aren't live, so + // the Pro card takes waitlist signups instead of running a checkout — grep + // "TEMP-PROMO" repo-wide for every related spot (backend grant logic in + // web/src/models/subscription.ts plus the static UI in web/src/ui/pricing.tsx). // // TO RESTORE ONCE PAYMENT INTEGRATION SHIPS: delete the `const plans =` // line above and the `c.html(...)` call below, then uncomment the two @@ -1555,7 +1555,7 @@ export function uiRoutes(deps: { // if (plan && isPlanId(plan.slug)) currentPlanSlug = plan.slug; // } return c.html( - + // s.trim().toLowerCase()) + .pipe(z.email().max(254)), + source: z.string().min(1).max(40).regex(/^[a-z0-9][a-z0-9_-]*$/), +}); + +// Anonymous public writer keyed on an attacker-chosen email: burst 5, refill +// 1 per 10s per IP. Far tighter than the analytics ingest — a human submits +// this form once, and the row it writes is not idempotent per-IP the way an +// event batch is. +const signupLimiter = tokenBucket(5, 0.1); + +export function waitlistRoutes(deps: { db: DB; clientIp: ClientIpResolver }) { + const r = new Hono(); + + r.post("/api/waitlist", async (c) => { + // Spoof-safe resolution (peer + trusted-proxy XFF walk); the IP is used + // only for this bucket and is deliberately never stored on the row. + const ip = deps.clientIp(c) ?? "unknown"; + if (!signupLimiter(ip)) return c.json({ ok: false, error: "RATE_LIMITED" }, 429); + + // A bare code, no `issues`: this endpoint answers any origin anonymously and + // neither client reads the detail — both pick their wording from the status — + // so echoing Zod's paths and received values back is reach with no caller. + const parsed = Signup.safeParse(await c.req.json().catch(() => null)); + if (!parsed.success) return c.json({ ok: false, error: "BAD_REQUEST" }, 400); + + // createMany + skipDuplicates emits INSERT ... ON CONFLICT DO NOTHING, so + // two concurrent submits of the same address cannot race into a unique + // violation — which would surface as app.onError's 500 and tell the caller + // the address was already on the list. + await deps.db.waitlistSignup.createMany({ + data: [{ email: parsed.data.email, source: parsed.data.source }], + skipDuplicates: true, + }); + + // Identical response whether the row was inserted or already existed: + // membership in the list is not something a stranger may probe for. + return c.json({ ok: true }, 200); + }); + + return r; +} diff --git a/web/src/ui/asset.ts b/web/src/ui/asset.ts index 0dc7d870..76d694e1 100644 --- a/web/src/ui/asset.ts +++ b/web/src/ui/asset.ts @@ -15,6 +15,7 @@ const ENTRIES = { checkout: "src/ui/entries/checkout.ts", dashboard: "src/ui/entries/dashboard.ts", devices: "src/ui/entries/devices.ts", + waitlist: "src/ui/entries/waitlist.ts", } as const; // Fonts reach the manifest as dependencies of styles.css, not as inputs, so diff --git a/web/src/ui/entries/waitlist.ts b/web/src/ui/entries/waitlist.ts new file mode 100644 index 00000000..88e7c914 --- /dev/null +++ b/web/src/ui/entries/waitlist.ts @@ -0,0 +1,143 @@ +/** + * Founding-price waitlist capture on /pricing. + * + * Not htmx: the target is the public JSON endpoint the marketing site posts to + * as well (POST /api/waitlist), so there is no fragment to swap and no redirect + * to follow — the same reason entries/devices.ts issues its own request rather + * than going through the vendored htmx bundle. + */ + +const IDLE_LABEL = "Join the waitlist"; +const BUSY_LABEL = "Joining…"; +const DONE_LABEL = "Joined"; + +/** Confirms in the button's own words. "Submitted" would leave the reader + * guessing whether the thing they joined is the thing that answered. */ +const SUCCESS_NOTE = "You're on the waitlist. Founding pricing at launch."; + +const STATUS_BASE = "text-xs text-center mt-3 min-h-10"; +const STATUS_TONE = { + idle: "text-faint", + ok: "text-ink2", + error: "text-error", +} as const; + +type Tone = keyof typeof STATUS_TONE; + +/** + * The endpoint answers a bare code, and a code is not an instruction — each + * status has to say what the server did with the address and what the reader + * does next. No apology: nothing here is broken, and "sorry" would be the only + * word in the sentence that carries no information. + */ +function messageForStatus(status: number): string { + if (status === 400) { + return "That address wasn't accepted. Check it and submit again."; + } + if (status === 429) { + return "Too many submissions from this network. Wait a minute, then submit again."; + } + // A 2xx that did not carry `ok` is not a rejection — something in front of the + // endpoint answered instead of it, and quoting its status would explain nothing. + if (status < 400) { + return "The waitlist didn't answer. Submit again in a moment."; + } + return `The server rejected the request (HTTP ${status}). Submit again in a moment.`; +} + +// The status line sits OUTSIDE the form, so it is reached through the card +// wrapper rather than the form — everything else is scoped to the form itself, +// which is what lets a second card on the page drive its own controls. +function bind(form: HTMLFormElement): void { + const input = form.querySelector('input[type="email"]'); + const button = form.querySelector("[data-waitlist-submit]"); + const status = form + .closest("[data-waitlist-card]") + ?.querySelector("[data-waitlist-status]"); + if (!input || !button || !status) return; + + const setStatus = (message: string, tone: Tone) => { + status.textContent = message; + status.className = `${STATUS_BASE} ${STATUS_TONE[tone]}`; + }; + + const toIdle = () => { + button.disabled = false; + button.textContent = IDLE_LABEL; + }; + + // Disabling the control a reader just activated blurs it and focus falls to + // , so their next Tab restarts at the top of the page. Reclaim it only if + // that is in fact where it went — someone who tabbed on keeps their place. Not + // folded into toIdle(), which also runs at bind time, when focus is legitimately + // on and stealing it would scroll the page to this card on load. + const reclaimFocus = (el: HTMLElement) => { + if (document.activeElement === document.body) el.focus(); + }; + + // The markup ships it disabled so a page whose script never ran cannot fire a + // native urlencoded POST at a JSON endpoint. Enabling it here is what says the + // handler below is attached. + toIdle(); + + form.addEventListener("submit", (ev) => { + ev.preventDefault(); + // `disabled` is the whole re-entry guard: it blocks the click and the + // Enter-key implicit submit alike, and it survives the success path, which + // is terminal. + if (button.disabled) return; + + button.disabled = true; + button.textContent = BUSY_LABEL; + + void (async () => { + try { + const res = await fetch(form.action, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: input.value.trim(), + source: form.dataset.waitlist, + }), + }); + // `ok` is checked on the body as well as the status: a 200 from anything + // that is not this endpoint (a maintenance page, an error interstitial) + // must not be reported to the reader as a signup that was stored. + const body = (await res.json().catch(() => null)) as { ok?: boolean } | null; + + if (res.ok && body?.ok) { + // The endpoint answers identically for an address already on the list, + // so there is nothing to tell apart here — and telling them apart is + // exactly what it refuses to leak. + input.readOnly = true; + button.textContent = DONE_LABEL; + setStatus(SUCCESS_NOTE, "ok"); + // The button stays disabled for good on this path, so the blur above is + // permanent unless focus is placed somewhere. The input is the landing + // spot rather than the status line: still focusable when read-only, it + // sits before the message in tab order, and aria-describedby already + // points at that message. + reclaimFocus(input); + return; + } + + setStatus(messageForStatus(res.status), "error"); + toIdle(); + reclaimFocus(button); + } catch { + setStatus( + "The request never reached the server. Check your connection and submit again.", + "error", + ); + toIdle(); + reclaimFocus(button); + } + })(); + }); +} + +for (const form of document.querySelectorAll("form[data-waitlist]")) { + bind(form); +} + +export {}; diff --git a/web/src/ui/pricing.tsx b/web/src/ui/pricing.tsx index a3a1fedd..eeeaefc4 100644 --- a/web/src/ui/pricing.tsx +++ b/web/src/ui/pricing.tsx @@ -1,25 +1,20 @@ -import { Layout, PageHead } from "./layout.js"; -import { - BETA, - displayPriceCents, - formatUsd, - FREE_WORKER_LIMIT, - TRIAL_DAYS, - type BillingEnv, -} from "../billing/plans.js"; +import { Layout } from "./layout.js"; +import { asset } from "./asset.js"; +import { FREE_WORKER_LIMIT } from "../billing/plans.js"; import type { PlanRow } from "../models/plan.js"; export type PricingPageProps = { user: { email?: string | null }; plans: PlanRow[]; - env: BillingEnv; }; -/** Why a plan can't be bought, in the CTA itself. "Coming soon" reads as - * half-built to someone who arrived from a site that told them the beta is - * free; naming the beta makes the disabled button an explanation. Keep the - * beta wording identical to PlanCard.astro's on the marketing site. */ -const UNAVAILABLE_CTA_LABEL = BETA ? "Available after beta" : "Coming soon"; +/** The public waitlist endpoint (web/src/routes/waitlist.ts), which the + * marketing site posts to as well — same origin as this page, so a relative + * action reaches it. `source` is the bounded slug its schema expects, naming + * the surface that captured the signup; the marketing site's own card sends + * "pricing", so this one has to differ or the two surfaces are one row. */ +const WAITLIST_ACTION = "/api/waitlist"; +const WAITLIST_SOURCE = "app_pricing"; /** Sales address for the contract-only plan. */ const ENTERPRISE_MAILTO = "mailto:contact@radhaai.com"; @@ -46,7 +41,10 @@ const PRO_YEARLY_FEATURES = [ const ENTERPRISE_FEATURES = [ "Unlimited seats", "Run agents on up to {workers} — per person", - "SSO, audit log & IP allowlist", + // Roadmap, not shipped: the capability flags exist on the plan model but + // nothing reads them yet. Keep in lockstep with the Enterprise strip on the + // marketing site (site/src/pages/pricing.astro) and support.md. + "SSO, audit log & IP allowlist — on the roadmap", "Invoiced annually", ] as const; @@ -108,31 +106,86 @@ function FeatureList({ ); } -function UnavailableCta({ footer }: { footer: string }) { +/** The founding-price capture, in the slot a plan's buy button will take back. + * + * Every element the script touches is found by data attribute from the form + * outwards, so a second copy of this card binds its own controls rather than + * driving the first one's. */ +function WaitlistCta({ email, id }: { email?: string | null; id: string }) { + // Ids are per instance for the same reason the script's lookups are scoped to + // the form: a second card on the page would otherwise duplicate them, and a + // duplicate `for` focuses the FIRST card's input from the second card's label. + const inputId = `${id}-email`; + const statusId = `${id}-status`; return ( -
- -

- {footer} +

+ {/* `action` names the real endpoint, but the submit button ships DISABLED + and the script enables it. Without that, a page whose script failed to + load would do a native urlencoded POST, and the endpoint reads JSON — + so the reader would be navigated off /pricing onto a raw error body. */} +
+ + {/* Prefilled with the signed-in address: /pricing is behind the session + gate, so asking for an address the page already knows reads as a + form that wasn't paying attention. Still editable — a personal + address is a fair answer to "tell me when this launches". */} + + +
+ {/* Idle note, error and confirmation all land here, on the `min-h-10` the + plan footers already reserve — so none of the three resizes the card. */} +

+ Founding pricing at launch.

+
); } export function PricingPage(props: PricingPageProps) { - const yearlyPrice = displayPriceCents("pro_yearly", props.env); - const trialPlan = props.plans.find((p) => p.slug === "trial"); const yearlyPlan = props.plans.find((p) => p.slug === "pro_yearly"); const enterprisePlan = props.plans.find((p) => p.slug === "enterprise"); return ( - {/* Headline and lede match PricingHeader.astro on the marketing site, - same as the beta CTA wording below — this is the same three plans for - the same reader, and "Simple, honest pricing" said nothing that the - site's line does not say better. Keep them in lockstep. */} + {/* Headline and lede match PricingHeader.astro on the marketing site — + this is the same three plans for the same reader, and "Simple, honest + pricing" said nothing that the site's line does not say better. Keep + them in lockstep. */}

Priced per person. Bring your own machines. @@ -145,62 +198,19 @@ export function PricingPage(props: PricingPageProps) {

- {trialPlan && ( - - )} -
{/* The free plan row is excluded from listActivePlans, so its worker count comes from the same constant that seeds it. */} - {yearlyPlan && } + {yearlyPlan && }
{enterprisePlan && } +