feat(learn): the assistant's conversation a dialog on the desktop and in expanded windows (Todo's ADR 0033, D11)

- halkyon_ui's sheetShowsAsDialog says whether showAdaptiveSheet shows
  a dialog here, for a sheet of its own that can't go through it.
- Learn's conversation, a half-height sheet dragged up, asks it: where
  sheets are dialogs it is a dialog up to 720 wide and as tall as the
  window, with no handle to drag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-01 18:20:58 +01:00
commit 4898d0a04a
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 145 additions and 23 deletions

View file

@ -25,7 +25,9 @@ import 'package:material_ui/material_ui.dart';
/// Opens a conversation with [choice] in [scope] for [task] in a sheet over
/// the screen it is asked on (ADR 0021, D47): at half height, dragged up to
/// the full, with follow-up questions at its foot. Asks [question] with
/// the full, with follow-up questions at its foot; where halkyon_ui's
/// sheets are dialogs (from expanded windows on, and on the desktop), a
/// dialog as tall as the window allows. Asks [question] with
/// [chosen] at once where given, a new conversation called [title] where
/// given, or continues the kept conversation [conversationId]. [choice]
/// null shows a kept conversation whose assistant has gone, which can't go
@ -66,15 +68,65 @@ Future<void> showConversation(
await cubit.ask(question, chosen);
}
}());
// Closing the sheet closes the cubit, which stops an answer on its way.
Widget provided(Widget child) =>
BlocProvider(create: (context) => cubit, child: child);
// A dialog where halkyon_ui's sheets are (Todo's ADR 0033, D11): from
// expanded windows on, and on the desktop at every width.
if (sheetShowsAsDialog(context)) {
return showDialog<void>(
context: context,
builder: (context) => provided(
_ConversationDialog(name: name, scope: scope, choice: choice),
),
);
}
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (context) => BlocProvider(
// Closing the sheet closes the cubit, which stops an answer on its
// way.
create: (context) => cubit,
child: _ConversationSheet(name: name, scope: scope, choice: choice),
builder: (context) =>
provided(_ConversationSheet(name: name, scope: scope, choice: choice)),
);
}
/// The conversation as a dialog, as tall as the window lets it, up to a
/// reading width: nothing to drag.
class const _ConversationDialog({
required final String name,
required final AssistantScope scope,
required final AssistantChoice? choice,
}) extends StatefulWidget {
@override
State<_ConversationDialog> createState() => _ConversationDialogState();
}
class _ConversationDialogState() extends State<_ConversationDialog> {
final _controller = ScrollController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => Dialog(
insetPadding: context.windowSize.isAtLeastMedium
? const EdgeInsets.symmetric(horizontal: 40, vertical: 32)
: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
clipBehavior: Clip.antiAlias,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720),
child: SizedBox.expand(
child: _ConversationView(
controller: _controller,
name: widget.name,
scope: widget.scope,
choice: widget.choice,
handle: false,
),
),
),
);
}
@ -99,6 +151,7 @@ class const _ConversationSheet({
name: name,
scope: scope,
choice: choice,
handle: true,
),
),
);
@ -112,6 +165,9 @@ class const _ConversationView({
/// Who answers; null for an assistant no longer offered, which can't go
/// on.
required final AssistantChoice? choice,
/// Whether a handle to drag it by sits on top: in a bottom sheet.
required final bool handle,
}) extends StatelessWidget {
/// How close to its end the conversation counts as followed: closer, it
/// follows the answer as it grows.
@ -141,7 +197,7 @@ class const _ConversationView({
controller: controller,
slivers: [
PinnedHeaderSliver(
child: _Header(name: name, scope: scope),
child: _Header(name: name, scope: scope, handle: handle),
),
if (!state.keeps)
SliverToBoxAdapter(
@ -202,11 +258,12 @@ class const _ConversationView({
}
}
/// The handle to drag the sheet by, who answers and how far it looks, and
/// Close.
/// The handle to drag the sheet by (where [handle]), who answers and how
/// far it looks, and Close.
class const _Header({
required final String name,
required final AssistantScope scope,
required final bool handle,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
@ -217,19 +274,20 @@ class const _Header({
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(top: 12),
child: Container(
width: 32,
height: 4,
decoration: BoxDecoration(
color: theme.colorScheme.onSurfaceVariant.withValues(
alpha: 0.4,
if (handle)
Padding(
padding: const EdgeInsets.only(top: 12),
child: Container(
width: 32,
height: 4,
decoration: BoxDecoration(
color: theme.colorScheme.onSurfaceVariant.withValues(
alpha: 0.4,
),
borderRadius: BorderRadius.circular(2),
),
borderRadius: BorderRadius.circular(2),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 4, 4),
child: Row(

View file

@ -1,6 +1,6 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
@ -265,6 +265,30 @@ void main() {
);
});
testWidgets('on the desktop the conversation opens in a dialog, however '
"narrow the window, with no handle to drag (Todo's ADR 0033, D11)", (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.linux;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
await showApp(tester);
assistants.answers.add(_says('Because it joins clauses.'));
await ask(tester, 'Why て-form?');
expect(find.byType(Dialog), findsOneWidget);
expect(find.byType(BottomSheet), findsNothing);
expect(find.byType(DraggableScrollableSheet), findsNothing);
expect(
find.text('Because it joins clauses.', findRichText: true),
findsOneWidget,
);
await tester.tap(find.byTooltip('Close'));
await tester.pumpAndSettle();
expect(find.byType(Dialog), findsNothing);
debugDefaultTargetPlatformOverride = null;
});
testWidgets('an assistant not agreed to asks for agreement first, sending '
'nothing (D40)', (tester) async {
assistants.offer = AssistantsOffer(assistants: [_claude(agreed: false)]);