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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions antgrid.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ worktree:
run: bun install
- name: Generate Prisma client
run: bun run --filter antgrid-web prisma:generate
- name: Apply local database migrations
run: bun run --filter antgrid-web migrate
- name: Install Flutter packages
run: flutter pub get
workingDir: app
Expand Down
8 changes: 8 additions & 0 deletions app/lib/design/ab_icons.dart
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ abstract final class AbIcons {
static const search = Codicon.search;
static const arrowUp = Codicon.arrow_up;
static const arrowDown = Codicon.arrow_down;
// A double chevron, deliberately not `chevronUp`: the single chevron is this
// app's fold/unfold mark everywhere it appears, and a list action borrowing it
// teaches the glyph a second meaning one row above a real move arrow.
static const moveToTop = Codicon.fold_up;
// A plain pencil. `revert` is the pencil-with-arrow and already means discard,
// so borrowing it for "change the words" would put two opposite actions behind
// one glyph in the same menu.
static const edit = Codicon.edit;
static const copy = Codicon.copy;
static const check = Codicon.check;
static const deviceMobile = Codicon.device_mobile;
Expand Down
17 changes: 15 additions & 2 deletions app/lib/design/widgets/ab_control_box.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ class AbControlBox extends StatelessWidget {
super.key,
required this.child,
this.height,
this.minHeight,
this.focused = false,
this.fillColor,
this.padding,
Expand All @@ -26,9 +27,18 @@ class AbControlBox extends StatelessWidget {
/// Box contents (typically a [Row]). Vertically centred within [height].
final Widget child;

/// Outer box height. Defaults to [AbTokens.rowHeightSm].
/// Outer box height. Defaults to [AbTokens.rowHeightSm], unless [minHeight]
/// asks the box to grow with its child.
final double? height;

/// Grows the box with its child, never falling below this. For the one
/// control that has no single row — a wrapping text field — which still has
/// to start at the same height as the fields it sits beside.
///
/// Wins over [height], which is a floor and a ceiling at once and so cannot
/// express this.
final double? minHeight;

/// Paints the border in [context.antgrid.accent] when true.
final bool focused;

Expand All @@ -41,7 +51,10 @@ class AbControlBox extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
height: height ?? AbTokens.rowHeightSm,
height: minHeight == null ? (height ?? AbTokens.rowHeightSm) : null,
constraints: minHeight == null
? null
: BoxConstraints(minHeight: minHeight!),
padding:
padding ?? const EdgeInsets.symmetric(horizontal: AbTokens.space8),
decoration: BoxDecoration(
Expand Down
16 changes: 14 additions & 2 deletions app/lib/design/widgets/ab_dialog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,17 @@ const abDialogTitlePadding = EdgeInsets.fromLTRB(
);

/// Builds a standard dialog title row with close button.
Widget abDialogTitle(String title, {required VoidCallback onClose}) {
///
/// [wraps] budgets the second line this row has always allowed. The default
/// leading is exactly the font size, which shows nothing while a caller passes a
/// short constant — 'Fork session', 'Open link' — and puts one line's
/// descenders into the next line's ascenders the moment a title composes in text
/// of the user's own length. Pass it wherever the title is not a constant.
Widget abDialogTitle(
String title, {
required VoidCallback onClose,
bool wraps = false,
}) {
return Row(
children: [
Expanded(
Expand All @@ -24,7 +34,9 @@ Widget abDialogTitle(String title, {required VoidCallback onClose}) {
style: AbTokens.sansStyle(
fontSize: AbTokens.fontBody,
fontWeight: FontWeight.w600,
height: 1.0,
// AbListRow's own leading for wrapped chrome text, so a title and
// the rows under it break at the same rhythm.
height: wraps ? 1.2 : 1.0,
),
),
),
Expand Down
42 changes: 39 additions & 3 deletions app/lib/design/widgets/ab_text_field.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show TextInputFormatter;

import '../ab_icons.dart';
import '../ab_tokens.dart';
Expand All @@ -7,7 +8,7 @@ import 'ab_control_box.dart';
import 'ab_icon.dart';
import 'ab_icon_button.dart';

/// Single-line text input primitive for the Antgrid design system.
/// Text input primitive for the Antgrid design system.
///
/// Owns the visual chrome (1px [context.antgrid.borderDefault] outline,
/// [AbTokens.borderRadius5], monospace text, accent cursor) and the
Expand Down Expand Up @@ -38,7 +39,10 @@ class AbTextField extends StatefulWidget {
this.enableSuggestions = true,
this.keyboardType,
this.textInputAction,
this.inputFormatters,
this.autofillHints,
this.minLines,
this.maxLines = 1,
this.fillColor,
this.height,
this.contentPadding,
Expand Down Expand Up @@ -89,13 +93,27 @@ class AbTextField extends StatefulWidget {
final TextInputType? keyboardType;
final TextInputAction? textInputAction;

/// Forwarded verbatim to the inner [TextField]. A pass-through, not a
/// validation feature: the bound a value has to respect is known by the
/// caller that knows the wire, never by the box that draws it.
final List<TextInputFormatter>? inputFormatters;

/// What the platform password manager should offer here (e.g.
/// [AutofillHints.username]). Null — the default — opts the field OUT of
/// autofill entirely, which is what every field but the sign-in form wants:
/// Flutter only enrols a field the caller has named, and an unnamed one is
/// never filled and never prompts a save.
final Iterable<String>? autofillHints;

/// Line budget, forwarded to the inner [TextField]. The default of 1 is the
/// single-row control every other field here is; anything else makes the box
/// grow with its text, starting at [height] and expanding from there.
///
/// [minLines] opens the box at that many lines, so a field meant to be
/// written into does not start as a slot the size of one word.
final int? minLines;
final int? maxLines;

/// Background fill. Defaults to [context.antgrid.bgSurface].
final Color? fillColor;

Expand Down Expand Up @@ -213,6 +231,10 @@ class _AbTextFieldState extends State<AbTextField> {
final showClear =
widget.showClearButton && enabled && _controller.text.isNotEmpty;
final effHeight = widget.height ?? AbTokens.rowHeightSm;
// A wrapping field has no single row to centre against: its own text sets
// the box height, and the prefix and clear slots belong beside the FIRST
// line rather than halfway down the paragraph.
final wraps = widget.maxLines != 1;

// Clear button, optionally centred in a square slot of [suffixSlotWidth]
// (matches a same-width prefix slot for equal margins on all sides).
Expand All @@ -238,11 +260,22 @@ class _AbTextFieldState extends State<AbTextField> {
behavior: HitTestBehavior.opaque,
onTap: enabled ? _focusNode.requestFocus : null,
child: AbControlBox(
height: effHeight,
height: wraps ? null : effHeight,
minHeight: wraps ? effHeight : null,
focused: _focusNode.hasFocus,
fillColor: widget.fillColor,
padding: widget.contentPadding,
padding:
widget.contentPadding ??
(wraps
? const EdgeInsets.symmetric(
horizontal: AbTokens.space8,
vertical: AbTokens.space6,
)
: null),
child: Row(
crossAxisAlignment: wraps
? CrossAxisAlignment.start
: CrossAxisAlignment.center,
children: [
if (widget.prefixIcon != null)
SizedBox(
Expand All @@ -266,7 +299,10 @@ class _AbTextFieldState extends State<AbTextField> {
enableSuggestions: widget.enableSuggestions,
keyboardType: widget.keyboardType,
textInputAction: widget.textInputAction,
inputFormatters: widget.inputFormatters,
autofillHints: widget.autofillHints,
minLines: widget.minLines,
maxLines: widget.maxLines,
onChanged: widget.onChanged,
onSubmitted: widget.onSubmitted,
onTap: widget.onTap,
Expand Down
22 changes: 19 additions & 3 deletions app/lib/models/handler_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -594,8 +594,8 @@ class HandlerActivityRecord {
// unrenderable feed row, never at compile time.
// 'continue' | 'handle' | 'escalate' | 'armed' | 'goal_edited' |
// 'item_done' | 'item_blocked' | 'item_skipped' | 'item_failed' |
// 'instruction_dropped' | 'floor_warning' | 'evidence_rejected' |
// 'wrapped_up' | 'parked' | 'resumed'
// 'instruction_dropped' | 'instruction_authorized' | 'instruction_amended' |
// 'floor_warning' | 'evidence_rejected' | 'wrapped_up' | 'parked' | 'resumed'
final String decision;
final String reason;
final String? detail;
Expand Down Expand Up @@ -631,6 +631,13 @@ class HandlerState {
/// second tap from looking live during the round trip.
final Set<String> pendingUndo;

/// Instructions whose `handler:instruct` is out and whose extracted items
/// have not come back, keyed by terminalId, oldest first. Held as the user's
/// own sentence because that is all there is to hold: the message is
/// unacknowledged, the bridge mints the ids, and extraction rewrites the text
/// — so nothing that comes back can be matched to what went out.
final Map<String, List<String>> pendingInstructions;

const HandlerState({
this.defaultTool,
this.defaultNotifyOnly = false,
Expand All @@ -639,6 +646,7 @@ class HandlerState {
required this.activity,
this.snapshots = const [],
this.pendingUndo = const {},
this.pendingInstructions = const {},
});

const HandlerState.initial()
Expand All @@ -648,7 +656,8 @@ class HandlerState {
escalations = const [],
activity = const [],
snapshots = const [],
pendingUndo = const {};
pendingUndo = const {},
pendingInstructions = const {};

// Absence of any session is the wire's implicit 'off' — there is no
// standalone off/on flag now that arming is per-terminal.
Expand All @@ -660,6 +669,11 @@ class HandlerState {
String? get latestEscalationId =>
escalations.isEmpty ? null : escalations.last.escalationId;

/// What [terminalId] has in flight, oldest first — empty for a terminal with
/// nothing outstanding, so no caller needs a null branch to ask.
List<String> pendingInstructionsFor(String terminalId) =>
pendingInstructions[terminalId] ?? const [];

HandlerState copyWith({
String? defaultTool,
bool? defaultNotifyOnly,
Expand All @@ -668,6 +682,7 @@ class HandlerState {
List<HandlerActivityRecord>? activity,
List<HandlerSnapshot>? snapshots,
Set<String>? pendingUndo,
Map<String, List<String>>? pendingInstructions,
}) {
return HandlerState(
defaultTool: defaultTool ?? this.defaultTool,
Expand All @@ -677,6 +692,7 @@ class HandlerState {
activity: activity ?? this.activity,
snapshots: snapshots ?? this.snapshots,
pendingUndo: pendingUndo ?? this.pendingUndo,
pendingInstructions: pendingInstructions ?? this.pendingInstructions,
);
}
}
5 changes: 5 additions & 0 deletions app/lib/providers/first_run.dart
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,11 @@ class FirstRunController extends Notifier<FirstRunState> {
if (state.handlerAwayHintDismissed) return;
_commit(state.copyWith(handlerAwayHintDismissed: true));
}

void dismissHandlerDisclaimer() {
if (state.handlerDisclaimerDismissed) return;
_commit(state.copyWith(handlerDisclaimerDismissed: true));
}
}

final firstRunProvider = NotifierProvider<FirstRunController, FirstRunState>(
Expand Down
8 changes: 8 additions & 0 deletions app/lib/providers/new_session_action.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import 'projects.dart';
import 'provider_retry.dart';
import 'providers.dart';
import 'recent_agents.dart';
import 'session_opening_prompt.dart';
import 'sessions.dart';
import 'ui_attention_providers.dart';

Expand Down Expand Up @@ -355,6 +356,13 @@ Future<void> startNewSession(
initialPrompt: prompt.isEmpty ? null : prompt,
raiseRefusal: true,
);
// Nothing else keeps this sentence: the bridge takes it as one-shot argv
// and the draft is cleared below. Arming Handler happens later, on
// another surface, and this is what lets that arm carry the user's own
// words as the session goal instead of none.
ref
.read(sessionOpeningPromptsProvider.notifier)
.remember(created.id, prompt);

// A start survives the user walking away from the canvas, so only steal
// the focus of someone still standing on it — otherwise the session they
Expand Down
33 changes: 28 additions & 5 deletions app/lib/providers/relay_connection.dart
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ class RelayConnection {
this.onDeviceRevoked,
// Test seam: inject a fake RelayService. Production passes null.
RelayService? relayOverride,
}) : relay = relayOverride ?? RelayService(crypto: crypto);
}) : relay =
relayOverride ??
RelayService(crypto: crypto, logger: _logRelayService);

/// Fires when the relay tells us this device has been revoked from the
/// account. Distinct from the supervisor's `Blocked(deviceRevoked)`, which
Expand Down Expand Up @@ -219,10 +221,13 @@ class RelayConnection {
}
}

/// App resume: hand the supervisor a plain re-evaluate so a connection that
/// was sitting on a long backoff while the app was in the background climbs
/// now instead of waiting out a timer the OS may have frozen.
void noteResume() => _supervisor?.noteResume();
/// App resume: validate an authenticated socket whose timers may have frozen,
/// then hand the supervisor a plain re-evaluate so a connection sitting on a
/// long backgrounded backoff climbs without waiting for that frozen timer.
void noteResume() {
relay.onResume();
_supervisor?.noteResume();
}

/// The relay reports a drop to the SENDER only, so this counts the frames
/// *we* lost — outbound requests. Dropped responses are the bridge's to
Expand Down Expand Up @@ -305,6 +310,24 @@ class RelayConnection {
bool get isDisposed => _disposed;
}

void _logRelayService(
RelayLogLevel level,
String message, {
Map<String, Object?>? fields,
}) {
const component = 'RelayService';
switch (level) {
case RelayLogLevel.debug:
AbLog.debug(component, message, fields: fields);
case RelayLogLevel.info:
AbLog.info(component, message, fields: fields);
case RelayLogLevel.warn:
AbLog.warn(component, message, fields: fields);
case RelayLogLevel.error:
AbLog.error(component, message, fields: fields);
}
}

/// Holds the app's live relay sockets, one [RelayConnection] per bare machine
/// `deviceUuid`. Every machine gets exactly one socket; project streams
/// multiplex inside it.
Expand Down
Loading
Loading