feat(app): the operator's overview of screens, grants to several people at once with one budget each, assistants by provider

Halkyon Learn's ADR 0021, D57 to D63, on halkyon_base's budgets and
effort:

- The operator's screen is an overview (Waiting, Operators, Accounts,
  Assistants, each with its count) whose rows open screens of their own,
  read 50 at a time as they scroll (OperatorListCubit, OperatorPage), so
  hundreds of accounts push nothing else down (D62).
- A handle is picked among the accounts there are (AccountSuggestionField):
  appointing an operator and granting take no other (D61).
- AssistantGrantsScreen replaces AssistantGrantsCard: the assistants set
  up by provider, with their efforts and prices; the people granted, found
  by their handle's start; one sheet granting several assistants, a
  provider's at once, to several people, each with one budget in money and
  tokens a day and a month, or changing one person's (D58, D59).
- Assistants are grouped by provider wherever they are listed, each under
  its name with a dot in its colour (ProviderLabel, D60).
- AssistantsCard shows what is left of the person's budget for all their
  assistants together; the months of the figures run oldest first (D57).
- AssistantChoice says the efforts a question may ask at and the ceiling
  on output each asks for (D63); a person's own key gets none and keeps
  4,096 tokens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-26 16:05:49 +01:00
commit cc87120bc8
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -6,8 +6,9 @@ import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
/// An app's settings' Assistants card (Halkyon Learn's ADR 0021, points 6,
/// 9 and 10): what is granted and left, agreeing to what goes where once,
/// the person's use, and assistants with their own key where allowed.
/// 9 and 10, D58, D60): what is granted, by provider, and left of the
/// budget for them all, agreeing to what goes where once, the person's
/// use, and assistants with their own key where allowed.
void main() {
late TestAppDependencies dependencies;
late FakeAssistantGateway gateway;
@ -20,9 +21,6 @@ void main() {
host: 'api.anthropic.com',
model: 'claude-sonnet-5-5',
images: true,
unit: AssistantLimitUnit.money,
dailyLimit: 1000000,
usedToday: 8100,
);
setUp(() {
@ -32,6 +30,9 @@ void main() {
assistants: [claude],
ownKeys: true,
currency: 'EUR',
budget: AssistantBudget(dailyCostMicros: 1000000),
usedToday: BudgetUse(costMicros: 8100, tokens: 900),
usedThisMonth: BudgetUse(costMicros: 8100, tokens: 900),
);
store = MemoryOwnAssistantStore();
});
@ -77,48 +78,89 @@ void main() {
expect(find.text('Assistants'), findsNothing);
});
testWidgets('shows who answers and what is left of its limits', (
tester,
) async {
testWidgets('shows who answers, and what is left of the budget for them '
'all', (tester) async {
await show(tester);
expect(find.text('Assistants'), findsOneWidget);
expect(find.text('Your budget, for all of them'), findsOneWidget);
expect(find.text('€0.99 of €1.00 left today'), findsOneWidget);
expect(find.text('Anthropic'), findsOneWidget);
expect(find.text('Claude'), findsOneWidget);
expect(
find.text(
'claude-sonnet-5-5 at api.anthropic.com\n€0.99 of €1.00 left today\n'
'Read and agree',
),
find.text('claude-sonnet-5-5 at api.anthropic.com\nRead and agree'),
findsOneWidget,
);
expect(find.text('Your use'), findsOneWidget);
});
testWidgets('a limit in tokens names them once', (tester) async {
testWidgets('a budget in tokens names them once', (tester) async {
gateway.offer = const AssistantsOffer(
assistants: [
OfferedAssistant(
id: 'local',
name: 'Local',
protocol: 'openai',
host: 'models.example.org',
model: 'qwen',
monthlyLimit: 2000000,
usedThisMonth: 350000,
),
],
assistants: [claude],
budget: AssistantBudget(dailyTokens: 100000, monthlyTokens: 2000000),
usedToday: BudgetUse(tokens: 350000),
usedThisMonth: BudgetUse(tokens: 350000),
);
await show(tester);
expect(
find.text(
'qwen at models.example.org\n1,650,000 of 2,000,000 tokens left this '
'month\nRead and agree',
'0 of 100,000 tokens left today\n'
'1,650,000 of 2,000,000 tokens left this month',
),
findsOneWidget,
);
});
testWidgets('a budget without limits says so', (tester) async {
gateway.offer = const AssistantsOffer(assistants: [claude]);
await show(tester);
expect(find.text('No limits'), findsOneWidget);
});
testWidgets('groups the assistants by provider, in the order the operator '
'set them', (tester) async {
gateway.offer = const AssistantsOffer(
assistants: [
claude,
OfferedAssistant(
id: 'gemini',
name: 'Gemini',
protocol: 'openai',
host: 'generativelanguage.googleapis.com',
model: 'gemini-3-pro',
),
OfferedAssistant(
id: 'opus',
name: 'Opus',
protocol: 'anthropic',
host: 'api.anthropic.com',
model: 'claude-opus-5-5',
),
],
);
await show(tester);
final labels = [
for (final text in tester.widgetList<Text>(
find.descendant(
of: find.byType(AssistantsCard),
matching: find.byType(Text),
),
))
if (const {
'Anthropic',
'Google',
'Claude',
'Opus',
'Gemini',
}.contains(text.data))
text.data,
];
expect(labels, ['Anthropic', 'Claude', 'Opus', 'Google', 'Gemini']);
});
testWidgets('says what goes where before agreeing, and agrees only when '
'asked to (D40)', (tester) async {
await show(tester);