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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions antgrid.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,8 @@ worktree:
workingDir: packages/antgrid_relay_client
timeoutMs: 900000
onFailure: warn
# The agent is useful before the installs finish — reading, planning and
# searching all work in a tree that is only checked out — and the five
# steps here are minutes on a cold worktree. It does race step 1: the
# service `.env` files can land after the agent is already up.
startAgent: immediate
2 changes: 1 addition & 1 deletion app/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ app's relay layer lives outside this tree: see

**Never start async work from a `void` callback without `detached` (`util/detached.dart`).** A tap handler, a post-frame callback and a `ref.listen` all DISCARD the future they start, so a rejection reaches `PlatformDispatcher.onError` as a FATAL with no in-app frames to point at the site — which is how a `session:*` reply dropped while the transport re-establishes (`PendingReply`'s routine 15s `TimeoutException`, already reconciled by the next `session:updated` push) shipped as a crash in 1.20668.151. `await`ing INSIDE such a callback is not a fix — the callback is the boundary, and an `async` closure passed where a `VoidCallback` is expected is the same bug wearing an await. Where the user pressed something and is owed an answer, also catch the `TimeoutException` at the call and say so in the UI; a log line alone makes a confirmed action indistinguishable from a dropped tap.

**A queued start is not a stopped session.** While `worktree.setup` runs the bridge HOLDS the session's `session:start`, so the entry reports `running: false` for the whole run with `setup.pendingStart` set. Every auto-start path must gate on `sessionStartQueued` (`providers/session_setup.dart`) or it sends a SECOND start carrying no `initialPrompt` — a start the user did not ask for, into a workspace that is not provisioned yet. `WorkspaceShellState._bootstrapSessions` and the drawer row's own tap (`session_row.dart`) are both such paths. The bridge keeps an already-queued prompt rather than letting a promptless start replace it, so the user's typing is no longer at stake, but that is a backstop and not the contract. For the same reason the create flow navigates on the SEND, not on the start reply.
**A queued start is not a stopped session.** While `worktree.setup` runs the bridge HOLDS the session's `session:start`, so the entry reports `running: false` for the whole run with `setup.pendingStart` set. Every auto-start path must gate on `sessionStartQueued` (`providers/session_setup.dart`) or it sends a SECOND start carrying no `initialPrompt` — a start the user did not ask for, into a workspace that is not provisioned yet. `WorkspaceShellState._bootstrapSessions` and the drawer row's own tap (`session_row.dart`) are both such paths. The bridge keeps an already-queued prompt rather than letting a promptless start replace it, so the user's typing is no longer at stake, but that is a backstop and not the contract. For the same reason the create flow navigates on the SEND, not on the start reply. **Rendering paths owe the same gate**, not only auto-start ones: `TerminalScreen` branches to a provisioning pane whose body is the setup transcript itself, because calling a queued session stopped — over a Start button whose press only re-enters the gate — is the one account of itself the workspace must never give. That pane and `SessionSetupBanner` are two views of one run, so the banner drops its own tail line and log disclosure while a start is queued (both derived from `sessionStartQueued`, never from the two widgets knowing about each other). `setup.stepNames` is the ledger's ONLY source and is optional on the wire — a state recovered from disk knows how many steps ran but not what they were called, and `SetupStepLedger` renders nothing rather than a column of blanks. It is also the one `SessionSetup` field whose equality is a pairwise walk (`_sameNames`): two structurally equal lists are different objects, so `==` on the reference would make every re-parse of an unchanged entry look like a change.

**`AbConfig` must round-trip every top-level `antgrid.yaml` key it does not model.** `ProjectSettingsScreen`'s Save re-serializes the whole config through `models/ab_config.dart`, so a key the model drops is DELETED from the user's file — `worktree` is carried verbatim as a raw map for exactly that reason, and a new block in `bridge/src/config.ts` with no home here is a data-loss bug nothing type-checks. A write also re-emits the YAML through the bridge's serializer, losing the user's comments and formatting: weigh that before adding another surface that edits the config on the user's behalf.

Expand Down
10 changes: 10 additions & 0 deletions app/lib/design/ab_tokens.dart
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,16 @@ abstract final class AbTokens {
/// out by scrolling internally rather than widening this measure.
static const transcriptMaxWidth = 680.0;

/// Max measure for a rendered markdown document in the file viewer. Wider
/// than [transcriptMaxWidth] because document body is [fontBody] where the
/// transcript's is [fontMd] — the same ~70ch, one step up the scale. Code
/// fences and tables opt out by scrolling internally.
static const documentMaxWidth = 720.0;

/// Width of the heading-outline rail beside a markdown document. Sized to
/// hold a nested `h3` label at [fontXs] without ellipsizing every entry.
static const documentOutlineWidth = 208.0;

static const sidebarWidth = 48.0;
static const commandTrayHeight = 44.0;
static const bottomNavHeight = 56.0;
Expand Down
22 changes: 18 additions & 4 deletions app/lib/models/ab_message.dart
Original file line number Diff line number Diff line change
Expand Up @@ -259,23 +259,28 @@ class HandlerStatusMessage {
/// What an absent per-session judge tool resolves to for PTY slots (the
/// project's agent tool); chat slots resolve from their own session entry.
final String? defaultTool;
final bool defaultNotifyOnly;
final List<Map<String, dynamic>> sessions;

/// Every §5.2 snapshot the project still knows about, replayed like the
/// Every snapshot the project still knows about, replayed like the
/// escalations so an app that restarted between the advert and the tap can
/// still reach the undo. Project-level, not per session: the offer matters
/// most once the session that took it has wrapped up.
final List<Map<String, dynamic>> snapshots;

/// The morning-after wrap-up reports, replayed on the same terms as the
/// snapshots — the session they describe is disarmed by the time anyone reads
/// one, so the replay is the only path that survives an app restart between
/// the wrap-up and the read.
final List<Map<String, dynamic>> wrapUps;

const HandlerStatusMessage({
required this.id,
required this.timestamp,
required this.projectId,
this.defaultTool,
required this.defaultNotifyOnly,
required this.sessions,
this.snapshots = const [],
this.wrapUps = const [],
});
}

Expand Down Expand Up @@ -1425,16 +1430,25 @@ Object? parseAbMessage(Map<String, dynamic> json) {
if (s is Map<String, dynamic>) snapshots.add(s);
}
}
// Same guard, same reason — and here absent and empty genuinely mean
// the same thing, so presence is never read as a capability signal.
final wrapUpsJson = json['wrapUps'];
final wrapUps = <Map<String, dynamic>>[];
if (wrapUpsJson is List) {
for (final w in wrapUpsJson) {
if (w is Map<String, dynamic>) wrapUps.add(w);
}
}
return HandlerStatusMessage(
id: id,
timestamp: timestamp,
projectId: projectId,
defaultTool: json['defaultTool'] is String
? json['defaultTool'] as String
: null,
defaultNotifyOnly: json['defaultNotifyOnly'] == true,
sessions: sessions,
snapshots: snapshots,
wrapUps: wrapUps,
);
}

Expand Down
Loading