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
37 changes: 35 additions & 2 deletions app/lib/models/handler_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,30 @@ class HandlerEscalationChoice {
}
}

/// Urgent first, then oldest-first within each band.
///
/// The band exists for the rows that unblock a session for one tap: the engine
/// mints `high` itself, with no judge call at all, for a blocking prompt — a
/// permission request or a question the agent is stopped on right now — and a
/// flat oldest-first order filed those under every stale question already on
/// the list.
///
/// It is NOT only that. `escalate` passes the judge's own `notify.urgency`
/// through (bridge/src/handler/engine.ts), and the decide prompt offers the
/// model both words, so a judge-authored `high` sorts into the same band and
/// wears the same marker. Nothing on the wire tells the two apart today;
/// anything that needs to must reach for `kind`, not `urgency`.
///
/// Age still decides WITHIN a band and never across it: a `normal` that has
/// waited an hour is not the thing holding the agent up. An urgency a newer
/// bridge invents ranks as `normal` — the safe band, since it claims nothing.
int compareEscalations(HandlerEscalation a, HandlerEscalation b) {
final byUrgency = _urgencyRank(a.urgency) - _urgencyRank(b.urgency);
return byUrgency != 0 ? byUrgency : a.at.compareTo(b.at);
}

int _urgencyRank(String urgency) => urgency == 'high' ? 0 : 1;

class HandlerEscalation {
final String escalationId;
final String terminalId;
Expand Down Expand Up @@ -666,8 +690,17 @@ class HandlerState {
int get pendingEscalations =>
sessions.values.fold(0, (n, s) => n + s.pendingEscalations);

String? get latestEscalationId =>
escalations.isEmpty ? null : escalations.last.escalationId;
// 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 —
// can never be it.
String? get latestEscalationId {
HandlerEscalation? newest;
for (final e in escalations) {
if (newest == null || e.at >= newest.at) newest = e;
}
return newest?.escalationId;
}

/// What [terminalId] has in flight, oldest first — empty for a terminal with
/// nothing outstanding, so no caller needs a null branch to ask.
Expand Down
11 changes: 11 additions & 0 deletions app/lib/providers/handler_discovery.dart
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,14 @@ class HandlerAwayAttentionSinceNotifier extends Notifier<DateTime?> {
/// 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.
typedef FocusedSessionCoverage = ({
String? agent,
String? agentLabel,
bool? observable,
bool? judgeCapable,
});

final focusedSessionCoverageProvider =
Expand All @@ -120,6 +124,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,
);
});

Expand Down
11 changes: 9 additions & 2 deletions app/lib/services/handler_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ class HandlerService {
// it too. This is what lets the "needs you" rows survive an app restart
// or reconnect instead of leaving a badge that points at nothing.
final escalations = [for (final s in sessions.values) ...s.escalations]
..sort((a, b) => a.at.compareTo(b.at));
..sort(compareEscalations);
// An id the bridge no longer replays has been retired there, so nothing is
// left to suppress and the set cannot grow with the session's history.
_answeredEscalations.retainWhere(
Expand Down Expand Up @@ -329,7 +329,14 @@ class HandlerService {
choices: msg.choices,
);
_emit(
_state.copyWith(escalations: [..._state.escalations, escalation]),
_state.copyWith(
// Sorted on the way in, not appended: a status frame re-sorts
// within milliseconds, but the push is what raises the toast, and
// between the two the row the user came to answer would be sitting
// at the bottom of the list.
escalations: [..._state.escalations, escalation]
..sort(compareEscalations),
),
);
// Read back out of the state rather than forwarded: the floors in
// [_withChoiceFloors] may have withdrawn the card on the way in, and a
Expand Down
15 changes: 7 additions & 8 deletions app/lib/widgets/agent_panel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -445,18 +445,17 @@ class HandlerHeaderControl extends ConsumerWidget {
notifyOnly: state.defaultNotifyOnly,
agentObservable: coverage.observable,
agentLabel: coverage.agentLabel,
judgeCapable: coverage.judgeCapable,
),
);
}

// Arming is one tap, so this tooltip is the only place the pre-arm
// coverage answer can reach the user — an agent that reports nothing
// arms just as silently as one that is merely quiet.
final shieldTooltip = session != null
? 'Disarm Handler'
: coverage.observable == false
? unwatchableNotice(coverage.agentLabel)
: 'Arm Handler';
final shieldTooltip = handlerShieldTooltip(
armed: session != null,
observable: coverage.observable,
judgeCapable: coverage.judgeCapable,
agentLabel: coverage.agentLabel,
);

return Row(
mainAxisSize: MainAxisSize.min,
Expand Down
17 changes: 16 additions & 1 deletion app/lib/widgets/handler/handler_arm_explainer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,21 @@ import 'handler_item_status.dart';
/// 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.
///
/// [judgeCapable] is the second, independent half of the same coverage answer
/// — the session IS watched, but its judge cannot run headless, so every pause
/// reaches the user. The bridge already reports it post-arm as
/// `escalate_only`, on a chip found only after walking away and coming back.
///
/// On the `true` arm only, and only when the catalog said so outright. The
/// `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({
required bool? agentObservable,
String? agentLabel,
bool hasOpeningPrompt = false,
bool? judgeCapable,
}) {
const base =
"Handler watches this session while you're away. When the agent pauses "
Expand All @@ -42,7 +53,7 @@ String handlerArmExplainerBody({
'session, and queues that as your backlog.'
: base;
return switch (agentObservable) {
true => head,
true => judgeCapable == false ? '$head\n\n$escalateOnlyNotice' : head,
false => '$base\n\n${unwatchableNotice(agentLabel)}',
null =>
"$head\n\nThis agent hasn't reported what Handler can see here, so it "
Expand All @@ -57,13 +68,15 @@ Future<bool> showHandlerArmExplainer(
required bool? agentObservable,
String? agentLabel,
bool hasOpeningPrompt = false,
bool? judgeCapable,
}) => AbConfirmDialog.show(
context: context,
title: 'Arm Handler',
body: handlerArmExplainerBody(
agentObservable: agentObservable,
agentLabel: agentLabel,
hasOpeningPrompt: hasOpeningPrompt,
judgeCapable: judgeCapable,
),
confirmLabel: 'Arm Handler',
cancelLabel: 'Not now',
Expand Down Expand Up @@ -98,6 +111,7 @@ Future<void> armWithFirstRunExplainer({
required bool notifyOnly,
required bool? agentObservable,
String? agentLabel,
bool? judgeCapable,
}) async {
final goal = container.read(sessionOpeningPromptsProvider)[terminalId];
if (!container.read(firstRunProvider).handlerArmedOnce) {
Expand All @@ -106,6 +120,7 @@ Future<void> armWithFirstRunExplainer({
agentObservable: agentObservable,
agentLabel: agentLabel,
hasOpeningPrompt: goal != null,
judgeCapable: judgeCapable,
);
if (!ok) return;
}
Expand Down
1 change: 1 addition & 0 deletions app/lib/widgets/handler/handler_away_hint.dart
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ class HandlerAwayHint extends ConsumerWidget {
notifyOnly: handlerState.defaultNotifyOnly,
agentObservable: coverage.observable,
agentLabel: coverage.agentLabel,
judgeCapable: coverage.judgeCapable,
),
);
},
Expand Down
24 changes: 24 additions & 0 deletions app/lib/widgets/handler/handler_item_status.dart
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,30 @@ String unwatchableNotice(String? agentLabel) =>
const escalateOnlyNotice =
"This judge can't run headless, so every pause comes to you.";

/// 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.
///
/// [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.
String handlerShieldTooltip({
required bool armed,
required bool? observable,
required bool? judgeCapable,
String? agentLabel,
}) {
if (armed) return 'Disarm Handler';
if (observable == false) return unwatchableNotice(agentLabel);
if (judgeCapable == false) return escalateOnlyNotice;
return 'Arm Handler';
}

/// Statuses an item never leaves, so they are the ones that don't count as
/// remaining work.
const _terminalItemStatuses = {'done', 'skipped', 'failed'};
Expand Down
68 changes: 60 additions & 8 deletions app/lib/widgets/handler/handler_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ 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_confirm_dialog.dart';
import '../../design/widgets/ab_empty_state.dart';
import '../../design/widgets/ab_icon.dart';
import '../../design/widgets/ab_list_row.dart';
Expand All @@ -18,6 +19,7 @@ import '../../design/widgets/ab_tooltip.dart';
import '../../models/handler_state.dart';
import '../../providers/providers.dart';
import '../../providers/sessions.dart';
import '../../util/detached.dart';
import '../../util/relative_time.dart';
import 'handler_backlog_drawer.dart';
import 'handler_blocked_action_card.dart';
Expand Down Expand Up @@ -108,12 +110,55 @@ class HandlerScreen extends ConsumerWidget {
focusedServiceOrNull(container, (s) => s.handlerService)?.reply(e, text);
}

Widget meta(String terminalId, int at) => _RowMeta(
// Confirmed for one action out of four. Undoing a hard reset, a recursive
// delete or a clean touches this machine only; undoing a force push writes
// to a shared remote, and the row it is offered on is a scrolling list row
// whose whole body is the tap target (§5.2 buys prevention back as one tap).
// The dialog is the only thing standing between a thumb landing where the
// scroll stopped and a ref overwritten for everyone on it.
//
// Re-resolved after the dialog for the same reason `answer` re-resolves
// after its sheet: the focused project's session can be rebuilt while the
// dialog is open, and the build-time instance is disposed by then.
Future<void> undo(HandlerSnapshot s) async {
if (s.action == 'force_push') {
final ok = await AbConfirmDialog.show(
context: context,
title: 'Undo this force push?',
// No promise of recovery: the bridge pins the current remote tip
// before overwriting it, but only when the ref still exists there —
// a ref already gone from the remote is restored with a bare
// `--force` and nothing pinned (snapshot.ts).
body:
'This force-pushes the remote back to where it was before the '
"agent's push. Whatever is on it now is overwritten.\n\n"
'${s.summary}',
confirmLabel: 'Undo force push',
destructive: true,
);
if (!ok) return;
}
focusedServiceOrNull(container, (x) => x.handlerService)?.undo(s);
}

// `urgent` rides the meta column rather than each row's own body: an
// escalation renders as one of three unrelated widgets (blocked card,
// 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,
);

// 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');

return CustomScrollView(
slivers: [
// Actionable first. The old order opened with the session headers, so
Expand All @@ -130,7 +175,7 @@ class HandlerScreen extends ConsumerWidget {
if (e.kind == 'guard_blocked')
HandlerBlockedActionCard(
escalation: e,
trailing: meta(e.terminalId, e.at),
trailing: escalationMeta(e),
// Re-resolved through the container for the same reason
// `answer` re-resolves after its sheet: the build-time
// instance can be disposed by the time a tap lands.
Expand All @@ -145,7 +190,7 @@ class HandlerScreen extends ConsumerWidget {
else if (e.choices != null)
HandlerDecisionCard(
escalation: e,
trailing: meta(e.terminalId, e.at),
trailing: escalationMeta(e),
// The id, not the choice: the service resolves it against
// the escalation's own offered set, so the text on the wire
// is always the one the bridge authored.
Expand Down Expand Up @@ -196,7 +241,7 @@ class HandlerScreen extends ConsumerWidget {
),
],
),
trailing: meta(e.terminalId, e.at),
trailing: escalationMeta(e),
onTap: () => answer(e),
),
],
Expand Down Expand Up @@ -253,10 +298,8 @@ class HandlerScreen extends ConsumerWidget {
snapshot: s,
meta: meta(s.terminalId, s.at),
pending: state.pendingUndo.contains(s.snapshotId),
onUndo: () => focusedServiceOrNull(
container,
(x) => x.handlerService,
)?.undo(s),
onUndo: () =>
detached('HandlerScreen', 'undo snapshot', () => undo(s)),
p: p,
);
},
Expand Down Expand Up @@ -314,11 +357,16 @@ class _RowMeta extends StatelessWidget {
required this.sessionName,
required this.at,
required this.p,
this.urgent = false,
});
final String? sessionName;
final int at;
final AbColors p;

/// Only escalations pass this. Snapshots and activity rows are history, and
/// nothing about them is waiting on the user.
final bool urgent;

@override
Widget build(BuildContext context) {
final style = AbTokens.monoStyle(
Expand All @@ -329,6 +377,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.
if (urgent) AbChip.system(label: 'URGENT', color: p.warning),
if (sessionName != null) Text(sessionName!, style: style),
Text(_fmtTime(at), style: style),
],
Expand Down
Loading